diff --git a/internal/web/service/inbound_autorenew_test.go b/internal/web/service/inbound_autorenew_test.go index 54a7b6840..4961cab45 100644 --- a/internal/web/service/inbound_autorenew_test.go +++ b/internal/web/service/inbound_autorenew_test.go @@ -102,3 +102,93 @@ func TestAutoRenewClients_MultiInbound(t *testing.T) { } } } + +// Every state is reached through the calls that produce it on a live panel: both +// operator paths clear client_traffics.enable as well, which a hand-seeded row hides. +func TestAutoRenewClients_RestoresOnlyWhatTheLifecycleJobDisabled(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + clientSvc := &ClientService{} + + ib, _, err := inboundSvc.AddInbound(&model.Inbound{ + UserId: 1, Enable: true, Port: 30301, Protocol: model.VLESS, Tag: "in-30301", + Settings: `{"clients":[],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + // A whole second, as autoRenewClients compares against those, and far enough + // away for the operator's calls below to land before it. + deadline := (time.Now().Unix() + 3) * 1000 + wantEnable := map[string]bool{ + "bulk-banned@x": false, + "edit-banned@x": false, + "quota-spent@x": true, + "time-up@x": true, + } + for email := range wantEnable { + if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{ + Client: model.Client{Email: email, Enable: true, Reset: 30, ExpiryTime: deadline, TotalGB: 100}, + InboundIds: []int{ib.Id}, + }); err != nil { + t.Fatalf("Create %s: %v", email, err) + } + } + record := func(email string) *model.ClientRecord { + t.Helper() + rec, err := clientSvc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail %s: %v", email, err) + } + return rec + } + + if _, _, err := clientSvc.BulkSetEnable(inboundSvc, []string{"bulk-banned@x"}, false); err != nil { + t.Fatalf("BulkSetEnable: %v", err) + } + rec := record("edit-banned@x") + edited := rec.ToClient() + edited.Enable = false + if _, err := clientSvc.Update(inboundSvc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "quota-spent@x", Up: 60, Down: 60}}); err != nil { + t.Fatalf("AddTraffic: %v", err) + } + if time.Now().UnixMilli() >= deadline { + t.Fatal("the operator's calls landed after the deadline; the setup is too slow") + } + + time.Sleep(time.Until(time.UnixMilli(deadline))) + // A tick this close to the deadline disables what the next one renews. + if _, _, _, err := inboundSvc.disableInvalidClients(database.GetDB(), newTrafficMutationBatch()); err != nil { + t.Fatalf("disableInvalidClients: %v", err) + } + for email := range wantEnable { + if record(email).Enable { + t.Fatalf("%s is still enabled before the renewal", email) + } + } + + time.Sleep(time.Until(time.UnixMilli(deadline + 1000))) + if _, _, err := inboundSvc.AddTraffic(nil, nil); err != nil { + t.Fatalf("AddTraffic: %v", err) + } + for email, want := range wantEnable { + rec := record(email) + if rec.ExpiryTime <= deadline { + t.Errorf("%s was not renewed: expiry = %d", email, rec.ExpiryTime) + } + if rec.Enable != want { + t.Errorf("%s enable after the renewal = %v, want %v", email, rec.Enable, want) + } + var traffic xray.ClientTraffic + if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil { + t.Fatalf("read client_traffics %s: %v", email, err) + } + if traffic.Enable != want { + t.Errorf("%s client_traffics.enable after the renewal = %v, want %v", email, traffic.Enable, want) + } + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index c9cbd4330..46cdbba51 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -288,3 +288,32 @@ func (s *InboundService) DepletedClientEmails(emails []string) (map[string]struc } return depleted, nil } + +// lifecycleDisabledEmails picks, out of switched-off stats rows, the ones that +// disableInvalidClients cleared; an operator's disable clears the flag as well. +func lifecycleDisabledEmails(tx *gorm.DB, rows []xray.ClientTraffic) (map[string]struct{}, error) { + emails := make([]string, 0, len(rows)) + for i := range rows { + emails = append(emails, rows[i].Email) + } + records, err := clientRecordsByEmail(tx, emails) + if err != nil { + return nil, err + } + // Cross-panel usage depletes too (depletedClientsCond); the overlay raises + // the counters in place, so callers hand it copies. + overlayGlobalTrafficValues(tx, rows) + disabled := make(map[string]struct{}, len(rows)) + for i := range rows { + r := &rows[i] + depleted := r.Total > 0 && r.Up+r.Down >= r.Total + // The job stamps the record once the deadline has passed (whole seconds + // is all a stamp pushed to a node keeps); an operator's disable came earlier. + record := records[r.Email] + expired := record != nil && r.ExpiryTime > 0 && record.UpdatedAt >= r.ExpiryTime/1000*1000 + if depleted || expired { + disabled[r.Email] = struct{}{} + } + } + return disabled, nil +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 6722b0e20..595c58e43 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -462,13 +462,18 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut // instead of a linear scan of every expired row (O(clients × expired) per // inbound, quadratic at scale). Pointers keep the in-place mutation below. trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics)) - // Keep the pre-renewal quota state: the shared pointer becomes enabled while - // processing the first inbound, while an already-enabled row paired with - // disabled settings represents an operator-disabled client we must preserve. - trafficWasEnabled := make(map[string]bool, len(traffics)) + // Judged on the pre-renewal rows, as the shared pointer is enabled while processing + // the first inbound: only what the lifecycle job switched off comes back. + var switchedOff []xray.ClientTraffic for i := range traffics { trafficByEmail[traffics[i].Email] = traffics[i] - trafficWasEnabled[traffics[i].Email] = traffics[i].Enable + if !traffics[i].Enable { + switchedOff = append(switchedOff, *traffics[i]) + } + } + lifecycleDisabled, err := lifecycleDisabledEmails(tx, switchedOff) + if err != nil { + return false, 0, err } renewedEmails := make([]string, 0, len(traffics)) for inbound_index := range inbounds { @@ -508,7 +513,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut traffic.Up = 0 renewedEmails = append(renewedEmails, email) } - if !trafficWasEnabled[email] { + if _, restore := lifecycleDisabled[email]; restore { traffic.Enable = true c["enable"] = true key := inboundClientKey{inboundID: inbounds[inbound_index].Id, email: email}