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:
@@ -76,8 +76,13 @@ type Account struct {
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
||||
MergedInto uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||
// rate-limiter rejections first crossed the sustained threshold (R3). An
|
||||
// operator clears it in the admin console; it never gates any request.
|
||||
FlaggedHighRateAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Identity is one of an account's platform/email identities, surfaced on the
|
||||
@@ -422,6 +427,43 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
// a profile edit, so updated_at is untouched; it never gates any request (R3).
|
||||
// It reports whether the flag was newly set.
|
||||
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.FlaggedHighRateAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ClearHighRateFlag removes the high-rate marker — the operator's reversible
|
||||
// action in the admin console. Clearing an unflagged account is a no-op.
|
||||
func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.NULL).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||
// user authenticated through. It is called on every Telegram login — new and
|
||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||
@@ -452,6 +494,10 @@ func modelToAccount(row model.Accounts) Account {
|
||||
if row.ServiceLanguage != nil {
|
||||
serviceLanguage = *row.ServiceLanguage
|
||||
}
|
||||
var flaggedHighRateAt time.Time
|
||||
if row.FlaggedHighRateAt != nil {
|
||||
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
@@ -467,6 +513,7 @@ func modelToAccount(row model.Accounts) Account {
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user