fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles

- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
fix(user): expire renew only after expiry; preserve traffic on cycle enable; edit form fills group/tags/cycles
- MaybeApplyUserCycles no longer extends every tick inside a 24h pre-expiry window
- First enable of traffic_reset_days records anchor without wiping counters
- Users openEdit (and related setFieldsValue paths) populate group/tags/cycle fields so save does not clear them
- Add unit tests for cycle behaviour
This commit is contained in:
Z User committed 2026-09-11 15:44:58 +00:00
1 parent d150574ad3
commit bf895d036c
3 files changed
+104 -19

No files matched your search

+10 -19
View File
@@ -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
@@ -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")
}
}
+12
View File
@@ -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);
},