diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index 5255194c7..ab790f938 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -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 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 270316e56..9b90660cc 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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": [ { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 270316e56..9b90660cc 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": [ { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1d5cf3bc5..7e2fb34af 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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.' }], }, { diff --git a/internal/web/service/bulk_traffic_test.go b/internal/web/service/bulk_traffic_test.go index b3fc9fdfa..a49651f7c 100644 --- a/internal/web/service/bulk_traffic_test.go +++ b/internal/web/service/bulk_traffic_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 2b336d9b9..eb1135b9a 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -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)) diff --git a/internal/web/service/client_reverse_readd_test.go b/internal/web/service/client_reverse_readd_test.go index 19b8694fb..498074658 100644 --- a/internal/web/service/client_reverse_readd_test.go +++ b/internal/web/service/client_reverse_readd_test.go @@ -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) } } diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index fb05aa5f4..73d663d2e 100644 --- a/internal/web/service/client_traffic.go +++ b/internal/web/service/client_traffic.go @@ -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 +} diff --git a/internal/web/service/client_update_enable_test.go b/internal/web/service/client_update_enable_test.go index 191c73533..599797a81 100644 --- a/internal/web/service/client_update_enable_test.go +++ b/internal/web/service/client_update_enable_test.go @@ -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) diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 595c58e43..ef6e96564 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -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()