mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-09 21:00:58 +00:00
inbounds: allow custom monthly traffic reset days (#6071)
This commit is contained in:
@@ -54,6 +54,7 @@ type Inbound struct {
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
TrafficResetDay int `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"` // Day of month for monthly traffic resets
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
@@ -13,15 +15,25 @@ type PeriodicTrafficResetJob struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
period Period
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
|
||||
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
|
||||
func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
|
||||
return &PeriodicTrafficResetJob{
|
||||
period: period,
|
||||
period: period,
|
||||
location: location,
|
||||
}
|
||||
}
|
||||
|
||||
func monthlyResetDue(resetDay int, now time.Time) bool {
|
||||
if resetDay < 1 {
|
||||
resetDay = 1
|
||||
}
|
||||
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
return now.Day() == min(resetDay, lastDay)
|
||||
}
|
||||
|
||||
// Run resets traffic statistics for all inbounds that match the configured reset period.
|
||||
func (j *PeriodicTrafficResetJob) Run() {
|
||||
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
|
||||
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
if j.period == "monthly" {
|
||||
now := time.Now().In(j.location)
|
||||
due := inbounds[:0]
|
||||
for _, inbound := range inbounds {
|
||||
if monthlyResetDue(inbound.TrafficResetDay, now) {
|
||||
due = append(due, inbound)
|
||||
}
|
||||
}
|
||||
inbounds = due
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
|
||||
|
||||
resetCount := 0
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMonthlyResetDue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resetDay int
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
|
||||
{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
|
||||
{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
|
||||
{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
|
||||
{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
|
||||
{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
|
||||
{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
|
||||
t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
|
||||
if ib.TrafficReset != "" {
|
||||
v.Set("trafficReset", ib.TrafficReset)
|
||||
}
|
||||
if ib.TrafficResetDay > 0 {
|
||||
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWireInboundTrafficReset(t *testing.T) {
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
|
||||
if got := with.Get("trafficReset"); got != "daily" {
|
||||
t.Fatalf("trafficReset = %q, want daily", got)
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
||||
if got := with.Get("trafficReset"); got != "monthly" {
|
||||
t.Fatalf("trafficReset = %q, want monthly", got)
|
||||
}
|
||||
if got := with.Get("trafficResetDay"); got != "15" {
|
||||
t.Fatalf("trafficResetDay = %q, want 15", got)
|
||||
}
|
||||
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
||||
without := wireInbound(&model.Inbound{}, 0)
|
||||
|
||||
@@ -34,6 +34,13 @@ type InboundService struct {
|
||||
fallbackService FallbackService
|
||||
}
|
||||
|
||||
func normalizeTrafficResetDay(day int) int {
|
||||
if day < 1 {
|
||||
return 1
|
||||
}
|
||||
return min(day, 31)
|
||||
}
|
||||
|
||||
func normalizeInboundShareAddrStrategy(strategy string) string {
|
||||
strategy = strings.TrimSpace(strategy)
|
||||
switch strategy {
|
||||
@@ -905,6 +912,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
@@ -1331,6 +1339,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
@@ -1460,6 +1469,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Enable = inbound.Enable
|
||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||
oldInbound.TrafficReset = inbound.TrafficReset
|
||||
oldInbound.TrafficResetDay = inbound.TrafficResetDay
|
||||
oldInbound.Listen = inbound.Listen
|
||||
oldInbound.Port = inbound.Port
|
||||
oldInbound.Protocol = inbound.Protocol
|
||||
|
||||
@@ -311,7 +311,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
|
||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||
c.StreamSettings != snapIb.StreamSettings ||
|
||||
c.Sniffing != snapIb.Sniffing ||
|
||||
c.TrafficReset != snapIb.TrafficReset
|
||||
c.TrafficReset != snapIb.TrafficReset ||
|
||||
c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
}
|
||||
|
||||
// adoptedWireInbound is the central inbound as it reads after adopting the
|
||||
@@ -330,6 +331,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
|
||||
a.StreamSettings = snapIb.StreamSettings
|
||||
a.Sniffing = snapIb.Sniffing
|
||||
a.TrafficReset = snapIb.TrafficReset
|
||||
a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
return &a
|
||||
}
|
||||
|
||||
@@ -561,6 +563,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
StreamSettings: snapIb.StreamSettings,
|
||||
Sniffing: snapIb.Sniffing,
|
||||
TrafficReset: snapIb.TrafficReset,
|
||||
TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay),
|
||||
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||
Enable: snapIb.Enable,
|
||||
Remark: snapIb.Remark,
|
||||
@@ -616,6 +619,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
|
||||
if adoptedWireChanged(c, snapIb, adoptedSettings) {
|
||||
adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTrafficResetDay(t *testing.T) {
|
||||
tests := map[int]int{
|
||||
0: 1,
|
||||
1: 1,
|
||||
15: 15,
|
||||
31: 31,
|
||||
32: 31,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := normalizeTrafficResetDay(input); got != want {
|
||||
t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "استيراد إدخال",
|
||||
"periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
|
||||
"periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
|
||||
"periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
|
||||
"lastReset": "آخر إعادة تعيين",
|
||||
"periodicTrafficReset": {
|
||||
"never": "أبداً",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Import an Inbound",
|
||||
"periodicTrafficResetTitle": "Traffic Reset",
|
||||
"periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
|
||||
"periodicTrafficResetDay": "Monthly reset day",
|
||||
"lastReset": "Last Reset",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Never",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Importar un entrante",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfico",
|
||||
"periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
|
||||
"periodicTrafficResetDay": "Día de reinicio mensual",
|
||||
"lastReset": "Último reinicio",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "افزودن یک ورودی",
|
||||
"periodicTrafficResetTitle": "بازنشانی ترافیک",
|
||||
"periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
|
||||
"periodicTrafficResetDay": "روز بازنشانی ماهانه",
|
||||
"lastReset": "آخرین بازنشانی",
|
||||
"periodicTrafficReset": {
|
||||
"never": "هرگز",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Impor Masuk",
|
||||
"periodicTrafficResetTitle": "Reset Trafik Berkala",
|
||||
"periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
|
||||
"periodicTrafficResetDay": "Hari reset bulanan",
|
||||
"lastReset": "Reset Terakhir",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Tidak Pernah",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "インバウンドルールをインポート",
|
||||
"periodicTrafficResetTitle": "トラフィックリセット",
|
||||
"periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
|
||||
"periodicTrafficResetDay": "毎月のリセット日",
|
||||
"lastReset": "最後のリセット",
|
||||
"periodicTrafficReset": {
|
||||
"never": "なし",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Importar um Inbound",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfego",
|
||||
"periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
|
||||
"periodicTrafficResetDay": "Dia da redefinição mensal",
|
||||
"lastReset": "Último Reset",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Импорт подключений",
|
||||
"periodicTrafficResetTitle": "Сброс трафика",
|
||||
"periodicTrafficResetDesc": "Автоматический сброс счетчика трафика через указанные интервалы",
|
||||
"periodicTrafficResetDay": "День ежемесячного сброса",
|
||||
"lastReset": "Последний сброс",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Никогда",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Gelen Bağlantı İçe Aktar",
|
||||
"periodicTrafficResetTitle": "Trafik Sıfırlama",
|
||||
"periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
|
||||
"periodicTrafficResetDay": "Aylık sıfırlama günü",
|
||||
"lastReset": "Son Sıfırlama",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Asla",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Імпортувати вхідний",
|
||||
"periodicTrafficResetTitle": "Скидання трафіку",
|
||||
"periodicTrafficResetDesc": "Автоматично скидати лічильник трафіку через певні проміжки часу",
|
||||
"periodicTrafficResetDay": "День щомісячного скидання",
|
||||
"lastReset": "Останнє скидання",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Ніколи",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "Nhập inbound",
|
||||
"periodicTrafficResetTitle": "Đặt lại lưu lượng",
|
||||
"periodicTrafficResetDesc": "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định",
|
||||
"periodicTrafficResetDay": "Ngày đặt lại hàng tháng",
|
||||
"lastReset": "Đặt lại lần cuối",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Không bao giờ",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "导入入站规则",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定间隔自动重置流量计数器",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficReset": {
|
||||
"never": "从不",
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"importInbound": "匯入入站規則",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定間隔自動重置流量計數器",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficReset": {
|
||||
"never": "從不",
|
||||
|
||||
+7
-7
@@ -302,7 +302,7 @@ const (
|
||||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||
func (s *Server) startTask(restartXray bool) {
|
||||
func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
if restartXray {
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
@@ -344,13 +344,13 @@ func (s *Server) startTask(restartXray bool) {
|
||||
|
||||
// Inbound traffic reset jobs
|
||||
// Run every hour
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
|
||||
// Run once a day, midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
|
||||
// Run once a week, midnight between Sat/Sun
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
|
||||
// Run once a month, midnight, first of month
|
||||
_, _ = s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// LDAP sync scheduling
|
||||
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
|
||||
@@ -651,7 +651,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
}
|
||||
})
|
||||
|
||||
s.startTask(restartXray)
|
||||
s.startTask(restartXray, loc)
|
||||
|
||||
if startTgBot {
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
|
||||
Reference in New Issue
Block a user