feat(queue): Rate-Limit und Ruhezeiten als Gesetz im Worker-Loop

Das Anti-Spam-Gesetz wird an den Loop verwired (die Rate-Limiter-Struktur
aus Task 04): Jede gestartete Ansage bucht das Fenster — low über dem
Budget verfällt (beim Eintritt, Admission nennt den Grund für die
202-Antwort), normal parkt bis zum Slot (Slot-Wecker broadcastet die
Cond, geparkte bleiben in der Queue und in /status sichtbar), high darf
mit Burst-Recht verstoßen, now bricht bewusst aus Rate-Limit UND
Ruhezeit aus und bucht trotzdem ein neues Fenster. Ruhezeiten
(quiet.go: lokales Fenster HH:MM-HH:MM über Mitternacht) verwerfen still
oder ersetzen durch Toast — gilt auch für high. Rangordnung: mute (API)
> now > Ruhezeit > Rate-Limit. Enqueue liefert Admission statt nur ID;
LawState für /status. Lock-Ordnung Queue → RateLimiter bleibt gewahrt.
This commit is contained in:
m3ta-chiron committed 2026-10-18 17:40:41 +02:00
1 parent b20e6dd319
commit 5385a4b87d
6 files changed
+785 -81

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
package queue
import (
"fmt"
"strconv"
"strings"
"time"
)
// Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
const (
QuietModeDiscard = "discard"
QuietModeToast = "toast"
)
// clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
type clockTime struct{ hour, minute int }
// minutes liefert Minuten seit Mitternacht.
func (c clockTime) minutes() int { return c.hour*60 + c.minute }
// String rendert die Uhrzeit als "HH:MM".
func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
// QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
// (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
// rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
type QuietHours struct {
Start, End clockTime
}
// String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
func (q QuietHours) String() string {
if q.Start == q.End {
return ""
}
return q.Start.String() + "-" + q.End.String()
}
// ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
// 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
// (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
// Leerstring ist das deaktivierte Fenster (kein Fehler).
func ParseQuietHours(s string) (QuietHours, error) {
if s == "" {
return QuietHours{}, nil
}
startS, endS, ok := strings.Cut(s, "-")
if !ok {
return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
}
start, err := parseClock(startS)
if err != nil {
return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
}
end, err := parseClock(endS)
if err != nil {
return QuietHours{}, fmt.Errorf("quiet_hours: %v", err)
}
return QuietHours{Start: start, End: end}, nil
}
// parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
func parseClock(s string) (clockTime, error) {
hourS, minuteS, ok := strings.Cut(s, ":")
if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
}
hour, err := strconv.Atoi(hourS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
}
minute, err := strconv.Atoi(minuteS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
}
return clockTime{hour: hour, minute: minute}, nil
}
// Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
// Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
// Nachtfenster (Start > End) überspannen Mitternacht.
func (q QuietHours) Active(t time.Time) bool {
if q.Start == q.End {
return false
}
m := t.Hour()*60 + t.Minute()
s, e := q.Start.minutes(), q.End.minutes()
if s < e {
return m >= s && m < e
}
return m >= s || m < e
}
// Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
// wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
func (q QuietHours) Until(t time.Time) time.Time {
if !q.Active(t) {
return time.Time{}
}
end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
}
return end
}