diff --git a/internal/web/job/periodic_traffic_reset_job.go b/internal/web/job/periodic_traffic_reset_job.go index 6cf7734a8..ffe1e2802 100644 --- a/internal/web/job/periodic_traffic_reset_job.go +++ b/internal/web/job/periodic_traffic_reset_job.go @@ -5,6 +5,7 @@ import ( "sync/atomic" "time" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -42,6 +43,13 @@ func monthlyResetDue(resetDay int, now time.Time) bool { return now.Day() == min(resetDay, lastDay) } +// inboundQuotaDisabled mirrors disableInvalidInbounds: a local inbound its own +// traffic cap switched off, not one that expired or was switched off by hand. +func inboundQuotaDisabled(ib *model.Inbound, nowMs int64) bool { + return !ib.Enable && ib.NodeID == nil && ib.Total > 0 && ib.Up+ib.Down >= ib.Total && + (ib.ExpiryTime <= 0 || ib.ExpiryTime > nowMs) +} + func forEachResetBounded(n int, reset func(i int)) { sem := make(chan struct{}, periodicResetConcurrency) var wg sync.WaitGroup @@ -87,11 +95,22 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() { logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds)) var resetCount atomic.Int32 + var needRestart atomic.Bool + nowMs := time.Now().UnixMilli() forEachResetBounded(len(inbounds), func(i int) { inbound := inbounds[i] + quotaOff := inboundQuotaDisabled(inbound, nowMs) resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id) if resetInboundErr != nil { logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr) + } else if quotaOff { + nr, err := j.inboundService.SetInboundEnable(inbound.Id, true) + if err != nil { + logger.Warning("Failed to re-enable inbound", inbound.Id, "after traffic reset:", err) + } + if nr { + needRestart.Store(true) + } } resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id) @@ -104,6 +123,10 @@ func (j *PeriodicTrafficResetJob) resetInboundsOnSchedule() { } }) + if needRestart.Load() { + j.xrayService.SetToNeedRestart() + } + if count := resetCount.Load(); count > 0 { logger.Infof("Periodic traffic reset completed: %d inbounds reset", count) } diff --git a/internal/web/job/periodic_traffic_reset_job_test.go b/internal/web/job/periodic_traffic_reset_job_test.go index 2c7c19880..c4f90cab7 100644 --- a/internal/web/job/periodic_traffic_reset_job_test.go +++ b/internal/web/job/periodic_traffic_reset_job_test.go @@ -3,6 +3,9 @@ package job import ( "testing" "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func TestMonthlyResetDue(t *testing.T) { @@ -29,3 +32,46 @@ func TestMonthlyResetDue(t *testing.T) { }) } } + +func TestPeriodicTrafficResetReenablesQuotaDisabledInbound(t *testing.T) { + cases := []struct { + name string + total int64 + expiryTime int64 + want bool + }{ + {"switched off by its own quota", 1000, 0, true}, + {"switched off by hand below its quota", 100000, 0, false}, + {"over quota but expired", 1000, time.Now().Add(-time.Hour).UnixMilli(), false}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + initResetJobDB(t) + db := database.GetDB() + ib := model.Inbound{ + UserId: 1, Enable: true, Port: 41201 + i, Protocol: model.VLESS, Tag: "inbound-quota", + TrafficReset: "daily", Total: tc.total, ExpiryTime: tc.expiryTime, Up: 600, Down: 500, + Settings: `{"clients":[]}`, + } + if err := db.Create(&ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("enable", false).Error; err != nil { + t.Fatalf("disable inbound: %v", err) + } + + NewPeriodicTrafficResetJob("daily", time.UTC).Run() + + var got model.Inbound + if err := db.First(&got, ib.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if got.Up != 0 || got.Down != 0 { + t.Fatalf("inbound traffic not reset: up=%d down=%d", got.Up, got.Down) + } + if got.Enable != tc.want { + t.Fatalf("inbound enable = %v after its reset, want %v", got.Enable, tc.want) + } + }) + } +}