feat(clients): renew on a calendar day instead of a rolling interval (#6239)

* feat(clients): renew on a calendar day instead of a rolling interval

Auto-renew advances the expiry by a fixed number of milliseconds, so a client
set to 30 days drifts against the calendar: renewing on 31 January lands on
2 March, and by the end of the year the billing day has wandered a fortnight
from where the operator's own plan resets.

Add a per-client renewal day. When set, the expiry steps whole calendar months
at midnight in the panel's time zone. A month too short for the chosen day
renews on its last day and the following month returns to the chosen one, so
the 31st does not decay into the 28th permanently.

Zero keeps the interval mode, so existing clients are untouched.

The interval branch now also refuses a zero step. It is unreachable while the
selection filter holds, but that loop runs on the single traffic writer, and a
zero interval there would hang every panel mutation behind it.

* fix(clients): persist the calendar renewal day on the client record

resetDay lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero: an ordinary edit, an attach to a second inbound, a traffic reset on
a disabled client. Calendar mode turned itself off during normal use and
the operator only found out a month later.

Adds reset_day to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge and the record update map, so the value survives
the round trip. The clients page filter and ClientSlim now recognise the
mode, nodeClientRenewed classifies a calendar renewal as a renewal, the
node snapshot merge carries reset_day, and the service layer rejects a
day outside 0-31 rather than clamping it silently.

Also renames the label keys to renewOnDay to keep them apart from the
existing renewDays, translates them and the new RESET_DAY subscription
placeholder in all 13 locales, adds the field to the bulk-add modal, and
drops the stray internal/web/dist/.gitkeep build stub.

* fix(clients): let the billing day be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so moving a
client from the 20th to the 5th updated the inbound settings JSON while
clients.reset_day kept the old value and the renewal kept using it.

The existing test did not catch it: it asserted the day survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheBillingDay moves the day and then
switches calendar mode off again; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
n0ctal
2026-08-18 15:46:00 +05:00
committed by GitHub
parent 1872659d83
commit b8903fadf4
34 changed files with 730 additions and 10 deletions
+42
View File
@@ -0,0 +1,42 @@
package service
import "time"
// nextCalendarRenewal returns the next renewal strictly after from, at midnight
// in loc; a missing day clamps to the month's last, so the 31st comes back (#6106).
func nextCalendarRenewal(from time.Time, day int, loc *time.Location) time.Time {
if loc == nil {
loc = time.UTC
}
if day < 1 {
day = 1
}
if day > 31 {
day = 31
}
local := from.In(loc)
candidate := calendarDay(local.Year(), local.Month(), day, loc)
if !candidate.After(local) {
year, month := local.Year(), local.Month()+1
if month > time.December {
year, month = year+1, time.January
}
candidate = calendarDay(year, month, day, loc)
}
return candidate
}
// Clamped rather than normalized: time.Date rolls 31 February into March, which
// is the drift this mode exists to avoid.
func calendarDay(year int, month time.Month, day int, loc *time.Location) time.Time {
last := daysInMonth(year, month)
if day > last {
day = last
}
return time.Date(year, month, day, 0, 0, 0, 0, loc)
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
+116
View File
@@ -0,0 +1,116 @@
package service
import (
"testing"
"time"
)
func TestNextCalendarRenewal_ClampsToShortMonths(t *testing.T) {
utc := time.UTC
cases := []struct {
name string
from time.Time
day int
want time.Time
}{
{
name: "31st in a 28-day February",
from: time.Date(2026, time.January, 31, 0, 0, 0, 0, utc),
day: 31,
want: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
},
{
name: "31st returns to the 31st after the short month",
from: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc),
day: 31,
want: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
},
{
name: "29th in a leap February",
from: time.Date(2028, time.January, 29, 0, 0, 0, 0, utc),
day: 29,
want: time.Date(2028, time.February, 29, 0, 0, 0, 0, utc),
},
{
name: "31st in a 30-day month",
from: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc),
day: 31,
want: time.Date(2026, time.April, 30, 0, 0, 0, 0, utc),
},
{
name: "December rolls into January",
from: time.Date(2026, time.December, 5, 0, 0, 0, 0, utc),
day: 5,
want: time.Date(2027, time.January, 5, 0, 0, 0, 0, utc),
},
{
name: "later in the same month renews this month",
from: time.Date(2026, time.June, 3, 12, 0, 0, 0, utc),
day: 20,
want: time.Date(2026, time.June, 20, 0, 0, 0, 0, utc),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := nextCalendarRenewal(tc.from, tc.day, utc)
if !got.Equal(tc.want) {
t.Fatalf("next renewal = %s, want %s", got.Format(time.RFC3339), tc.want.Format(time.RFC3339))
}
})
}
}
// The renewal instant is midnight in the panel's zone, not in UTC: an operator
// billing on the 1st expects the period to turn over at their local midnight.
func TestNextCalendarRenewal_UsesPanelZone(t *testing.T) {
loc, err := time.LoadLocation("Asia/Tehran")
if err != nil {
t.Skip("zone database unavailable")
}
from := time.Date(2026, time.June, 15, 12, 0, 0, 0, time.UTC)
got := nextCalendarRenewal(from, 1, loc)
if got.Location() != loc {
t.Fatalf("renewal computed in %s, want the panel zone", got.Location())
}
y, m, d := got.Date()
if y != 2026 || m != time.July || d != 1 {
t.Fatalf("renewal date = %04d-%02d-%02d, want 2026-07-01", y, m, d)
}
if h, mi, s := got.Clock(); h != 0 || mi != 0 || s != 0 {
t.Fatalf("renewal at %02d:%02d:%02d, want local midnight", h, mi, s)
}
}
// Crossing a DST boundary must still land on local midnight rather than
// drifting an hour, which a fixed 24h*N step cannot promise.
func TestNextCalendarRenewal_SurvivesDaylightSaving(t *testing.T) {
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skip("zone database unavailable")
}
// Berlin moves to summer time on 29 March 2026.
from := time.Date(2026, time.March, 10, 0, 0, 0, 0, loc)
got := nextCalendarRenewal(from, 10, loc)
if h, mi, _ := got.Clock(); h != 0 || mi != 0 {
t.Fatalf("renewal at %02d:%02d local, want midnight across the DST change", h, mi)
}
if got.Day() != 10 || got.Month() != time.April {
t.Fatalf("renewal = %s, want 10 April", got.Format(time.RFC3339))
}
}
func TestNextCalendarRenewal_AlwaysMovesForward(t *testing.T) {
utc := time.UTC
from := time.Date(2026, time.May, 20, 0, 0, 0, 0, utc)
// Same day: the boundary has already passed today, so the next one is a
// month away rather than the instant we started from.
got := nextCalendarRenewal(from, 20, utc)
if !got.After(from) {
t.Fatalf("next renewal %s is not after %s", got, from)
}
if got.Month() != time.June {
t.Fatalf("next renewal = %s, want June", got.Format(time.RFC3339))
}
}
+16
View File
@@ -43,6 +43,15 @@ func validateClientSubID(subID string) error {
return nil
}
// Rejected rather than clamped: nextCalendarRenewal would silently move an
// out-of-range day, and a negative one drops out of the renewal query entirely.
func validateClientResetDay(day int) error {
if day < 0 || day > 31 {
return common.NewError("client resetDay must be between 0 and 31, got:", day)
}
return nil
}
// Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
// but selects nothing, so the client would silently stop renewing.
func validateClientResetMax(resetMax int) error {
@@ -66,6 +75,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if err := validateClientSubID(client.SubID); err != nil {
return false, err
}
if err := validateClientResetDay(client.ResetDay); err != nil {
return false, err
}
if err := validateClientResetMax(client.ResetMax); err != nil {
return false, err
}
@@ -356,6 +368,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := validateClientSubID(updated.SubID); err != nil {
return false, err
}
if err := validateClientResetDay(updated.ResetDay); err != nil {
return false, err
}
if err := validateClientResetMax(updated.ResetMax); err != nil {
return false, err
}
@@ -481,6 +496,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_max": merged.ResetMax,
}).Error; err != nil {
return needRestart, err
+1
View File
@@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
}
row.Comment = incoming.Comment
row.Reset = incoming.Reset
row.ResetDay = incoming.ResetDay
row.ResetMax = incoming.ResetMax
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt
+4 -2
View File
@@ -26,6 +26,7 @@ type ClientSlim struct {
LimitIP int `json:"limitIp"`
LimitHwid int `json:"limitHwid"`
Reset int `json:"reset"`
ResetDay int `json:"resetDay"`
ResetMax int `json:"resetMax"`
Group string `json:"group,omitempty"`
Comment string `json:"comment,omitempty"`
@@ -246,9 +247,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [
}
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
case "on":
where("COALESCE(c.reset, 0) > 0")
where("(COALESCE(c.reset, 0) > 0 OR COALESCE(c.reset_day, 0) > 0)")
case "off":
where("COALESCE(c.reset, 0) <= 0")
where("(COALESCE(c.reset, 0) <= 0 AND COALESCE(c.reset_day, 0) <= 0)")
}
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
case "yes":
@@ -606,6 +607,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
LimitIP: c.LimitIP,
LimitHwid: c.LimitHwid,
Reset: c.Reset,
ResetDay: c.ResetDay,
ResetMax: c.ResetMax,
Group: c.Group,
Comment: c.Comment,
@@ -0,0 +1,391 @@
package service
import (
"encoding/json"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// pinPanelZone fixes the panel time zone so the assertions below can talk about
// calendar days without the test machine's own zone shifting them.
func pinPanelZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Skipf("zone database unavailable: %v", err)
}
if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil {
t.Fatalf("pin panel zone: %v", err)
}
return loc
}
// Calendar mode renews on the same day each month. The interval mode drifts —
// 30 days from 31 January is 2 March — which is the whole reason for the mode.
func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
// Expired two calendar months ago, billed on the 15th.
past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC)
clients := []model.Client{
{Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "cal@x", Enable: false, Up: 5, Down: 6,
ResetDay: 15, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
if got.Day() != 15 {
t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339))
}
if !got.After(time.Now()) {
t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339))
}
if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 {
t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s)
}
if row.Up != 0 || row.Down != 0 {
t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down)
}
if !row.Enable {
t.Fatal("a renewed client must be re-enabled")
}
}
// A client billed on the 31st keeps that day, borrowing the last day only in
// months that are too short for it.
func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC)
clients := []model.Client{
{Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "eom@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone)
if !got.Equal(want) {
t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
// Deliberately not built on nextCalendarRenewal: it walks a day at a time and
// derives month length from time.Date's own zero-day trick, so it can disagree.
func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time {
t.Helper()
cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc)
for i := 0; i < 400; i++ {
cur = cur.AddDate(0, 0, 1)
want := day
if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last {
want = last
}
if cur.Day() == want {
return cur
}
}
t.Fatalf("no billing midnight for day %d within a year of %s", day, from)
return time.Time{}
}
// Interval clients must be untouched by the new field.
func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past},
}
ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "days@x", Enable: false, Reset: 30, ExpiryTime: past,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if want := past + 30*86400000; row.ExpiryTime != want {
t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want)
}
}
// The selection filter is what keeps a row with neither mode configured out of
// the renewal loop. That matters more than it looks: the interval step is
// reset*24h, so a zero interval reaching that loop would spin forever on the
// single traffic writer. The guard in the loop is a second line of defence and
// is deliberately unreachable while this filter holds.
func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past},
}
ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
// Seeded straight into the table with both modes off, the shape the
// selection filter is supposed to exclude.
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past,
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
// Asserted against the query rather than by running the loop: the failure
// this guards is a hang, and a stuck goroutine outlives the test's DB.
var selected int64
if err := db.Model(&xray.ClientTraffic{}).
Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()).
Where("email = ?", "none@x").
Count(&selected).Error; err != nil {
t.Fatal(err)
}
if selected != 0 {
t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever")
}
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 0 {
t.Fatalf("renewed count = %d, want 0", count)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ExpiryTime != past {
t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime)
}
}
// The billing day has to survive the clients table, not just the settings JSON:
// an ordinary edit rebuilds the client from the record and writes it back (#6106).
func TestClientEditKeepsTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
clients := []model.Client{
{Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
}
ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
mkTraffic(t, ib.Id, "keep@x", 10, 20, 0, 0, true)
rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x")
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.ResetDay != 20 {
t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay)
}
// What the edit dialog does: hydrate the record, change something else, save.
edited := rec.ToClient()
edited.Comment = "renamed"
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update: %v", err)
}
// The inbound JSON is what xray and the edit dialog read back, and it is
// rebuilt from the record, so it is where a dropped converter field shows.
var stored model.Inbound
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
t.Fatalf("parse inbound settings: %v", err)
}
if len(settings.Clients) != 1 {
t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
}
if settings.Clients[0].ResetDay != 20 {
t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x")
if err != nil {
t.Fatalf("GetRecordByEmail after edit: %v", err)
}
if rec.ResetDay != 20 {
t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ResetDay != 20 {
t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay)
}
}
// The billing day is useless if it can only be chosen once. The test above
// passes even without the record write, because nothing overwrites the value
// it checks; this one fails without it.
func TestClientEditChangesTheBillingDay(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
clients := []model.Client{
{
Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20,
ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
},
}
ib := mkInbound(t, 30206, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
edited := rec.ToClient()
edited.ResetDay = 5
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update: %v", err)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail after edit: %v", err)
}
if rec.ResetDay != 5 {
t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay)
}
// Turning calendar mode off has to work too.
edited = rec.ToClient()
edited.ResetDay = 0
edited.Reset = 30
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update back to interval mode: %v", err)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail after switching mode: %v", err)
}
if rec.ResetDay != 0 {
t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay)
}
}
// The two renewal features meet here: a calendar client is capped like an
// interval one, spending one allowance per month rather than per tick.
func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
zone := pinPanelZone(t, "UTC")
// Three calendar months behind with one allowance left: a single month step
// cannot reach the present, so the client stays expired on its billing day.
past := time.Now().In(zone).AddDate(0, -3, 0)
past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone)
clients := []model.Client{
{Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()},
}
ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2,
Up: 111, Down: 222, ExpiryTime: past.UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
}
var row xray.ClientTraffic
if err := db.Where("email = ?", "calcap@x").First(&row).Error; err != nil {
t.Fatal(err)
}
got := time.UnixMilli(row.ExpiryTime).In(zone)
if want := past.AddDate(0, 1, 0); !got.Equal(want) {
t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339))
}
if row.ResetCount != 3 {
t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount)
}
if row.Enable {
t.Fatal("a client still expired after a truncated catch-up was enabled")
}
if row.Up != 111 || row.Down != 222 {
t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down)
}
}
+6 -5
View File
@@ -237,7 +237,7 @@ func mergeActivationExpiry(existing, node int64) int64 {
// nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved
// forward while the node's cumulative counter fell below the stored baseline.
func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool {
if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
if (cs.Reset <= 0 && cs.ResetDay <= 0) || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
return false
}
if cs.ExpiryTime <= existing.ExpiryTime {
@@ -844,6 +844,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
Total: cs.Total,
ExpiryTime: cs.ExpiryTime,
Reset: cs.Reset,
ResetDay: cs.ResetDay,
Up: seedUp,
Down: seedDown,
LastOnline: cs.LastOnline,
@@ -879,12 +880,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
fmt.Sprintf(
`UPDATE client_traffics
SET up = ?, down = ?, enable = ?, total = ?,
expiry_time = ?, reset = ?, last_online = %s
expiry_time = ?, reset = ?, reset_day = ?, last_online = %s
WHERE email = ?`,
database.GreatestExpr("last_online", "?"),
),
canon.Up, canon.Down, cs.Enable, cs.Total,
cs.ExpiryTime, cs.Reset,
cs.ExpiryTime, cs.Reset, cs.ResetDay,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
@@ -906,7 +907,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
`UPDATE client_traffics
SET up = %s, down = %s, enable = %s, total = ?,
expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
reset = ?, last_online = %s
reset = ?, reset_day = ?, last_online = %s
WHERE email = ?`,
database.ClampedAddExpr("up"),
database.ClampedAddExpr("down"),
@@ -914,7 +915,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.Enable, cs.Total,
cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
cs.ExpiryTime, cs.ExpiryTime, cs.Reset, cs.ResetDay,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
+26 -3
View File
@@ -333,7 +333,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
// attached to, so it could be a node inbound even when the client also has
// local inbounds. The email-based join through client_inbounds is authoritative.
err = tx.Model(xray.ClientTraffic{}).
Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", now).
// A prepaid plan stops itself: once as many renewals have fired as the
// operator allowed, the client is left to expire like any other.
Where("reset_max <= 0 or reset_count < reset_max").
@@ -351,6 +351,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
return false, 0, nil
}
renewLocation, locErr := (&SettingService{}).GetTimeLocation()
if locErr != nil || renewLocation == nil {
// Falling back to UTC keeps renewals happening; the alternative is
// skipping them entirely because a setting could not be read.
logger.Warning("autoRenewClients: could not read the panel time zone, using UTC:", locErr)
renewLocation = time.UTC
}
var inbound_ids []int
var inbounds []*model.Inbound
needRestart := false
@@ -417,12 +425,25 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
// One allowance per period, not per tick: a client away for three
// cycles must not catch up three of them against a prepaid cap.
newExpiryTime := traffic.ExpiryTime
if traffic.ResetDay <= 0 && traffic.Reset <= 0 {
// Unreachable while the selection filter holds: a zero step below
// would spin forever on the single traffic writer and hang the panel.
continue
}
at := time.UnixMilli(newExpiryTime)
renewals := 0
for newExpiryTime < now {
if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
break
}
newExpiryTime += (int64(traffic.Reset) * 86400000)
if traffic.ResetDay > 0 {
// Calendar mode: step whole months in the panel's zone, so the
// renewal date does not drift the way a fixed 30-day step does.
at = nextCalendarRenewal(at, traffic.ResetDay, renewLocation)
newExpiryTime = at.UnixMilli()
} else {
newExpiryTime += (int64(traffic.Reset) * 86400000)
}
renewals++
}
if renewals == 0 {
@@ -528,11 +549,12 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
ExpiryTime: client.ExpiryTime,
Enable: client.Enable,
Reset: client.Reset,
ResetDay: client.ResetDay,
ResetMax: client.ResetMax,
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_day", "reset_max"}),
}).Create(&clientTraffic).Error
}
@@ -545,6 +567,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
"total": client.TotalGB,
"expiry_time": client.ExpiryTime,
"reset": client.Reset,
"reset_day": client.ResetDay,
"reset_max": client.ResetMax,
})
err := result.Error