R3: backend rate-limit observability — ratewatch, auto-flag, admin throttled view

- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
  contour schema is wiped after merge); jet regenerated — the regen also picks
  up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
  GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
  bounded in-memory episode window for the console and applies the
  conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
  sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
  badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
  integration coverage for the store round-trip and the console flow.
This commit is contained in:
Ilia Denisov
2026-06-10 02:14:00 +02:00
parent 8878711cf3
commit ab58062565
27 changed files with 1081 additions and 33 deletions
+43 -2
View File
@@ -2,6 +2,7 @@ package account
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
@@ -18,6 +19,9 @@ type UserListItem struct {
PreferredLanguage string
IsGuest bool
IsRobot bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list (R3).
FlaggedHighRateAt time.Time
CreatedAt time.Time
}
@@ -65,7 +69,7 @@ func userListWhere(f UserFilter) (string, []any) {
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
@@ -77,14 +81,51 @@ FROM backend.accounts a WHERE ` + where +
var out []UserListItem
for rows.Next() {
var it UserListItem
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
if flagged.Valid {
it.FlaggedHighRateAt = flagged.Time
}
out = append(out, it)
}
return out, rows.Err()
}
// FlaggedAccount is one row of the console's high-rate review queue.
type FlaggedAccount struct {
ID uuid.UUID
DisplayName string
FlaggedHighRateAt time.Time
}
// flaggedListCap bounds the console's flagged-account list; the operator clears
// flags as they are reviewed, so the queue stays short in practice.
const flaggedListCap = 200
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
// recently flagged first (R3).
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, display_name, flagged_high_rate_at
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
if err != nil {
return nil, fmt.Errorf("account: list flagged: %w", err)
}
defer rows.Close()
var out []FlaggedAccount
for rows.Next() {
var fa FlaggedAccount
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
return nil, fmt.Errorf("account: scan flagged: %w", err)
}
out = append(out, fa)
}
return out, rows.Err()
}
// CountUsers counts the filtered admin user list, for pagination.
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
where, args := userListWhere(f)