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:
aaaamoooo
2026-10-20 15:10:13 +00:00
parent f4ef3d96a6
commit 7c21742810
7 changed files with 744 additions and 1 deletions
+22
View File
@@ -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 != "" {