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:
@@ -1652,6 +1652,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resetDay, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := &Customer{
|
||||
ID: NewID("cus"),
|
||||
SubscriptionToken: RandomToken(32),
|
||||
@@ -1665,11 +1669,15 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
ExpiresAt: expires,
|
||||
IPLimit: int64(ipLimit),
|
||||
DeviceLimit: int64(deviceLimit),
|
||||
TrafficResetDay: resetDay,
|
||||
Tags: sliceStrings(asIds(body["tags"]), 20),
|
||||
Notes: CleanText(body["notes"], 1000),
|
||||
CreatedAt: NowISO(),
|
||||
UpdatedAt: NowISO(),
|
||||
}
|
||||
// Arm the monthly-reset schedule: it starts with the next
|
||||
// reset instant, it never fires retroactively at creation.
|
||||
ArmTrafficReset(next, time.Now())
|
||||
d.Customers = append(d.Customers, next)
|
||||
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
|
||||
return cloneJSONValue(next), nil
|
||||
@@ -1736,6 +1744,20 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if _, ok := body["tags"]; ok {
|
||||
item.Tags = sliceStrings(asIds(body["tags"]), 20)
|
||||
}
|
||||
if _, ok := body["trafficResetDay"]; ok {
|
||||
v, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v != item.TrafficResetDay {
|
||||
item.TrafficResetDay = v
|
||||
// A changed schedule is re-armed rather than fired:
|
||||
// it takes effect from the next reset instant. An
|
||||
// unchanged value (the UI echoes it on every edit)
|
||||
// leaves the marker — and any pending reset — alone.
|
||||
ArmTrafficReset(item, time.Now())
|
||||
}
|
||||
}
|
||||
reason := CustomerBlockReason(d, item)
|
||||
statusWanted := JSString(body["status"])
|
||||
if statusWanted == "active" && reason != "" {
|
||||
|
||||
Reference in New Issue
Block a user