mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
b8903fadf4
* 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>
392 lines
14 KiB
Go
392 lines
14 KiB
Go
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)
|
|
}
|
|
}
|