diff --git a/backend/internal/traffic/cycle_renew.go b/backend/internal/traffic/cycle_renew.go index 0ae6d74..53f0b76 100644 --- a/backend/internal/traffic/cycle_renew.go +++ b/backend/internal/traffic/cycle_renew.go @@ -23,32 +23,23 @@ func MaybeApplyUserCycles(db *gorm.DB) { for _, u := range users { updates := map[string]interface{}{} if u.TrafficResetDays > 0 { - need := false if u.LastTrafficCycleReset == nil { - need = true + // Enabling the cycle must not wipe current counters on the first tick. + updates["last_traffic_cycle_reset"] = now } else { next := u.LastTrafficCycleReset.Add(time.Duration(u.TrafficResetDays) * 24 * time.Hour) if !now.Before(next) { - need = true + updates["traffic_used"] = 0 + updates["upload_bytes"] = 0 + updates["download_bytes"] = 0 + updates["last_traffic_cycle_reset"] = now } } - if need { - updates["traffic_used"] = 0 - updates["upload_bytes"] = 0 - updates["download_bytes"] = 0 - updates["last_traffic_cycle_reset"] = now - } } - if u.ExpireRenewDays > 0 && !u.ExpireTime.IsZero() { - // Renew when expired or within 24h of expiry. - window := u.ExpireTime.Add(-24 * time.Hour) - if !now.Before(window) { - base := u.ExpireTime - if base.Before(now) { - base = now - } - updates["expire_time"] = base.Add(time.Duration(u.ExpireRenewDays) * 24 * time.Hour) - } + if u.ExpireRenewDays > 0 && !u.ExpireTime.IsZero() && u.ExpireTime.Before(now) { + // Only extend after the period has ended (avoids renewing every scheduler tick + // while still inside a pre-expiry window). + updates["expire_time"] = now.Add(time.Duration(u.ExpireRenewDays) * 24 * time.Hour) } if len(updates) == 0 { continue diff --git a/backend/internal/traffic/cycle_renew_test.go b/backend/internal/traffic/cycle_renew_test.go new file mode 100644 index 0000000..687f3a5 --- /dev/null +++ b/backend/internal/traffic/cycle_renew_test.go @@ -0,0 +1,82 @@ +package traffic + +import ( + "testing" + "time" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func testDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:cycle?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&models.ProxyUser{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestExpireRenewOnlyAfterExpiry(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + // Within 24h window but not yet expired — must NOT extend. + u := models.ProxyUser{ + Username: "soon", UUID: "u1", SubToken: "t1", + ExpireTime: now.Add(12 * time.Hour), ExpireRenewDays: 30, + Enabled: true, + } + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + MaybeApplyUserCycles(db) + var got models.ProxyUser + if err := db.First(&got, u.ID).Error; err != nil { + t.Fatal(err) + } + if !got.ExpireTime.Equal(u.ExpireTime) { + t.Fatalf("pre-expiry renew mutated expire_time: %v -> %v", u.ExpireTime, got.ExpireTime) + } + + // Already expired — must extend from now by 30 days. + past := now.Add(-time.Hour) + if err := db.Model(&got).Update("expire_time", past).Error; err != nil { + t.Fatal(err) + } + MaybeApplyUserCycles(db) + if err := db.First(&got, u.ID).Error; err != nil { + t.Fatal(err) + } + if !got.ExpireTime.After(now.Add(29 * 24 * time.Hour)) { + t.Fatalf("expected ~30d extension, got %v", got.ExpireTime) + } +} + +func TestTrafficCycleDoesNotWipeOnFirstEnable(t *testing.T) { + db := testDB(t) + u := models.ProxyUser{ + Username: "tuser", UUID: "u2", SubToken: "t2", + TrafficUsed: 12345, TrafficResetDays: 7, Enabled: true, + } + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + MaybeApplyUserCycles(db) + var got models.ProxyUser + if err := db.First(&got, u.ID).Error; err != nil { + t.Fatal(err) + } + if got.TrafficUsed != 12345 { + t.Fatalf("first tick wiped traffic: %d", got.TrafficUsed) + } + if got.LastTrafficCycleReset == nil { + t.Fatal("expected last_traffic_cycle_reset to be set") + } +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index acee0fb..f436f84 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -140,6 +140,10 @@ const Users: React.FC = () => { ip_limit: record.ip_limit || 0, hwid_limit: record.hwid_limit || 0, remark: record.remark || '', + group: record.group || '', + tags: record.tags || '', + traffic_reset_days: record.traffic_reset_days || 0, + expire_renew_days: record.expire_renew_days || 0, }); setModalOpen(true); }; @@ -513,6 +517,10 @@ const Users: React.FC = () => { ip_limit: record.ip_limit || 0, hwid_limit: record.hwid_limit || 0, remark: record.remark, + group: record.group || '', + tags: record.tags || '', + traffic_reset_days: record.traffic_reset_days || 0, + expire_renew_days: record.expire_renew_days || 0, }); setModalOpen(true); }} @@ -559,6 +567,10 @@ const Users: React.FC = () => { ip_limit: record.ip_limit || 0, hwid_limit: record.hwid_limit || 0, remark: record.remark, + group: record.group || '', + tags: record.tags || '', + traffic_reset_days: record.traffic_reset_days || 0, + expire_renew_days: record.expire_renew_days || 0, }); setModalOpen(true); },