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
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user