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'
- depth: 2
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
inbounds and pushed to Xray/remote nodes. Returns the count of
successfully reset clients.
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'
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.
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
title: List all client groups with their member counts. Merges persisted groups
(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.
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
title: 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.
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'
title: 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.
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
title: Manually adjust a client’s upload + download counters. Useful for
migrations from external accounting systems.
@@ -457,10 +459,11 @@ _openapi:
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
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.
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
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.
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
groups (rows in client_groups, including empty placeholders) with the
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
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
across every attached inbound and pushes the change to Xray (or the
remote node) so depleted users can connect again immediately.
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
- content: 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.
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
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": [
"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",
"requestBody": {
"required": true,
@@ -10504,7 +10504,7 @@
"tags": [
"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",
"parameters": [
{
+2 -2
View File
@@ -10163,7 +10163,7 @@
"tags": [
"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",
"requestBody": {
"required": true,
@@ -10504,7 +10504,7 @@
"tags": [
"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",
"parameters": [
{
+2 -2
View File
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/bulkResetTraffic',
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}',
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
},
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/resetTraffic/:email',
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.' }],
},
{
+103 -1
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"testing"
"time"
@@ -48,7 +49,7 @@ func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
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, "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)
}
}
// 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 {
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
if within != nil {
in = make(map[string]bool, len(within))
@@ -137,12 +137,12 @@ func depleteClientTraffic(t *testing.T, inboundId int, email string) {
t.Helper()
db := database.GetDB()
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 {
t.Fatalf("deplete traffic: %v", res.Error)
}
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)
}
}
+92 -27
View File
@@ -1,6 +1,7 @@
package service
import (
"slices"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -24,6 +25,11 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
if err != nil {
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
if len(inboundIds) == 0 {
@@ -44,9 +50,9 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
needRestart = nr
}
// Enable only once the counters are zero: a still-depleted client enabled
// first is switched off again by the next traffic tick.
if !rec.Enable {
// Enable only a client the quota switched off, once the counters are zero: a
// hand-disabled one stays off, and a still-depleted one would be switched off again.
if !rec.Enable && len(quotaDisabled) > 0 {
updated := rec.ToClient()
updated.Enable = true
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 {
return 0, err
}
affected := 0
var affected int64
var restore []string
var resetNodes []int
err = submitTrafficWrite(func() error {
db := database.GetDB()
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 {
return err
}
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
res := tx.Model(xray.ClientTraffic{}).
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 affected, err = zeroClientTraffics(tx, cleanEmails, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
return err
@@ -110,7 +119,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
inboundSvc.deliverNodeResetsNow(resetNodes)
// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
// client first lets the next traffic tick switch it off again.
for _, e := range cleanEmails {
for _, e := range restore {
rec := recordsByEmail[e]
if rec == nil || rec.Enable {
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)
}
}
return affected, nil
return int(affected), nil
}
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 {
return qErr
}
keepOff, qErr := handDisabledEmails(tx, resetEmails)
if qErr != nil {
return qErr
}
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
return err
}
result := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", resetEmails).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if result.Error != nil {
return result.Error
if _, err := zeroClientTraffics(tx, resetEmails, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
@@ -213,7 +222,7 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
result := tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
@@ -234,13 +243,13 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil {
return qErr
}
res := tx.Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if res.Error != nil {
return res.Error
keepOff, qErr := handDisabledEmails(tx, nil)
if qErr != nil {
return qErr
}
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 {
return err
}
@@ -266,3 +275,59 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
}
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{}
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}))
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
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 {
t.Fatalf("ResetTrafficByEmail: %v", err)
@@ -142,6 +142,7 @@ func TestResetTrafficByEmail_NoInbound_LeavesRecordEnableTrue(t *testing.T) {
t.Fatalf("create record: %v", err)
}
forceRecordDisabled(t, svc, email)
mkTraffic(t, 0, email, 10, 20, 30, 0, false)
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
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 {
return err
}
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
keepOff, err := handDisabledEmails(tx, []string{clientEmail})
if err != nil {
return err
}
if err := tx.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
if _, err := zeroClientTraffics(tx, []string{clientEmail}, keepOff); err != nil {
return err
}
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
return err
}
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 {
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)
if err != nil {
return false, nil, err
@@ -766,7 +774,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
traffic.Up = 0
traffic.Down = 0
traffic.Enable = true
traffic.Enable = !handDisabled
db := database.GetDB()
now := time.Now().UnixMilli()