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 }