mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 09:27:15 +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:
@@ -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