mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user