Files
NexusGate/panel-go/reset.go
T
aaaamoooo c6f6b9dbea feat(panel-go): monthly traffic reset for customers
- 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
2026-10-21 03:16:30 +08:00

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
}