mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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.' }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user