- trafficResetDay/Period/At on the shared customer record (omitempty, inert for the Node panel, which preserves unknown fields) - housekeeping applies due resets before quota evaluation: period-key idempotency, short-month clamping (31st fires Feb 28), missed-period catch-up after panel downtime, traffic suspensions auto-resume - first sight of a schedule only arms the period marker, never zeroes; API create/PATCH accept trafficResetDay (0=off) and re-arm on change - UI: reset-day input + last-reset display in the customer form - tests: calendar math, apply pass, API lifecycle, persistence; full Go/Node suites green, difftest formal 50/50
133 lines
5.0 KiB
Go
133 lines
5.0 KiB
Go
package main
|
|
|
|
// Monthly traffic reset. A customer with trafficResetDay (1-31) gets the
|
|
// cycle counters (usedBytes / usedUplinkBytes / usedDownlinkBytes — the
|
|
// same fields the quota checks and the subscription userinfo read) zeroed
|
|
// once per monthly period, from the regular 60s housekeeping pass.
|
|
//
|
|
// The design is deliberately stateless and restart-safe: whether a reset
|
|
// is due is derived only from the persisted trafficResetPeriod marker, so
|
|
// a reset instant missed while the panel was down fires at the first
|
|
// housekeeping pass after it comes back — no in-memory schedule to lose.
|
|
// All dates are panel-local, matching the rest of housekeeping.
|
|
|
|
import "time"
|
|
|
|
// daysInMonth returns the number of days in the given month (pure
|
|
// calendar math, location-independent).
|
|
func daysInMonth(year int, month time.Month) int {
|
|
// Day 0 of the next month is the last day of this one.
|
|
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
|
}
|
|
|
|
// resetInstant returns the scheduled reset instant inside the given
|
|
// month: the reset day clamped to the month's length (the 31st fires on
|
|
// Feb 28), at 00:00 in loc.
|
|
func resetInstant(year int, month time.Month, day int, loc *time.Location) time.Time {
|
|
if dim := daysInMonth(year, month); day > dim {
|
|
day = dim
|
|
}
|
|
return time.Date(year, month, day, 0, 0, 0, 0, loc)
|
|
}
|
|
|
|
func prevMonth(year int, month time.Month) (int, time.Month) {
|
|
if month == time.January {
|
|
return year - 1, time.December
|
|
}
|
|
return year, month - 1
|
|
}
|
|
|
|
// TrafficResetPeriodKey names the monthly period `now` belongs to for the
|
|
// given reset day: the "2006-01" of the most recent scheduled reset
|
|
// instant at or before now. Crossing a reset instant flips the key.
|
|
func TrafficResetPeriodKey(now time.Time, day int) string {
|
|
loc := now.Location()
|
|
year, month, _ := now.Date()
|
|
cand := resetInstant(year, month, day, loc)
|
|
if now.Before(cand) {
|
|
// This month's reset instant is still ahead: the current period
|
|
// began at last month's reset instant.
|
|
year, month = prevMonth(year, month)
|
|
cand = resetInstant(year, month, day, loc)
|
|
}
|
|
return cand.Format("2006-01")
|
|
}
|
|
|
|
// ArmTrafficReset (re)arms a customer's period marker for its current
|
|
// reset day WITHOUT zeroing usage: the schedule takes effect from the next
|
|
// reset instant. Used when the reset day is set or changed through the
|
|
// API, and for customers whose marker is empty (data written before this
|
|
// feature existed, or externally — e.g. by the future 3x-ui converter,
|
|
// which only has to write trafficResetDay).
|
|
func ArmTrafficReset(customer *Customer, now time.Time) {
|
|
if customer.TrafficResetDay < 1 || customer.TrafficResetDay > 31 {
|
|
customer.TrafficResetPeriod = ""
|
|
return
|
|
}
|
|
customer.TrafficResetPeriod = TrafficResetPeriodKey(now, customer.TrafficResetDay)
|
|
}
|
|
|
|
// ApplyMonthlyTrafficResets performs the due monthly resets for all
|
|
// customers in d as of now. Idempotent per period via the persisted
|
|
// marker; safe to call on every housekeeping pass.
|
|
func ApplyMonthlyTrafficResets(d *Data, now time.Time) error {
|
|
for _, customer := range d.Customers {
|
|
day := customer.TrafficResetDay
|
|
if day < 1 || day > 31 {
|
|
continue
|
|
}
|
|
key := TrafficResetPeriodKey(now, day)
|
|
if customer.TrafficResetPeriod == "" {
|
|
// First sight: arm the marker only — usage already accrued in
|
|
// the current period must survive until the next reset day.
|
|
customer.TrafficResetPeriod = key
|
|
continue
|
|
}
|
|
if customer.TrafficResetPeriod == key {
|
|
continue
|
|
}
|
|
// A new period has begun since the last applied reset. Zero the
|
|
// cycle counters exactly like the manual reset-usage action (the
|
|
// model has no separate lifetime counter: these three fields are
|
|
// also what subscriptions report as upload/download).
|
|
previousUsed := customer.UsedBytes
|
|
customer.UsedBytes = 0
|
|
customer.UsedUplinkBytes = 0
|
|
customer.UsedDownlinkBytes = 0
|
|
customer.TrafficResetPeriod = key
|
|
resetAt := ISOFromTime(now)
|
|
customer.TrafficResetAt = &resetAt
|
|
customer.UpdatedAt = resetAt
|
|
Audit(d, "system:monthly_reset", "reset_customer_usage_monthly", customer.ID, map[string]any{
|
|
"previousUsedBytes": previousUsed,
|
|
"resetDay": day,
|
|
"period": key,
|
|
})
|
|
// Mirror the manual reset: a customer suspended only for the
|
|
// traffic limit comes back once nothing blocks it any more.
|
|
if customer.Status == "suspended" && strVal(customer.SuspendReason) == "traffic_limit" &&
|
|
CustomerBlockReason(d, customer) == "" {
|
|
customer.Status = "active"
|
|
customer.SuspendReason = nil
|
|
customer.SuspendReasonPresent = true
|
|
if _, err := ResumeCustomerResources(d, customer, "system:monthly_reset"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseResetDay validates a trafficResetDay request value: 0 disables
|
|
// the schedule, 1-31 is the day of month. JSON null/absent means 0.
|
|
func ParseResetDay(v any) (int, error) {
|
|
n, err := NonNegativeNumber(v, "每月重置日", true)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n > 31 {
|
|
return 0, httpErr(400, "每月重置日须在 0–31 之间(0 表示不重置)")
|
|
}
|
|
return int(n), nil
|
|
}
|