"""Сервис квоты оценок trade-in — N успешных оценок в месяц на ключ (аккаунт ИЛИ анонимная сессия+IP, см. #b2c-antiabuse-2). Правила: - Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15. Это дефолт для АУТЕНТИФИЦИРОВАННЫХ (X-Authenticated-User) ключей. Анонимные ключи (см. app.api.v1.trade_in._resolve_quota_identity) используют СВОЙ, гораздо более строгий default через параметр `default_limit=` — все функции ниже принимают username-подобный `key: str | None` без разбора, реальный аккаунт это или составной anon-ключ ("anon::"). - Персональный override: таблица account_quota_overrides (username → monthly_limit), см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток через negative `used` (ломал /quota — «Осталось 50 из 15»). Работает одинаково для anon-ключей (в норме нет override-строки → falls back на переданный `default_limit`), так и для обычных username. - `used` в account_estimate_usage защищён CHECK (used >= 0) на уровне схемы, см. миграцию 189_account_estimate_usage_nonnegative.sql — 185 сбросила негативный used только для user2, 189 закрывает остальные аккаунты + запрещает регресс. В коде декремента `used` НЕТ — increment() только `used + 1` под TOCTOU-guard (#747); любой negative used приходит исключительно извне (ручной UPDATE). - Без лимита (unlimited): роль admin (без похода в БД) ИЛИ персональный грант account_quota_overrides.unlimited = true (миграция 191_account_quota_unlimited_flag.sql). До миграции 191 unlimited для non-admin аккаунтов был захардкожен как `username == 'kopylov'` прямо в коде — данные (kopylov + praktika) заменяют этот хардкод целиком, единый источник правды для всех безлимитных non-admin грантов. Анонимные ключи никогда не unlimited (get_role() кидает KeyError на составной anon-ключ → is_unlimited() шорткатится в False БЕЗ похода в БД). - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). - key is None → unlimited, лимит не применяется (fail-open). #b2c-antiabuse-2: ЭТОТ модуль как был, так и остаётся fail-open на None — но с этапа anti-abuse вызывающая сторона (app.api.v1.trade_in._resolve_quota_identity) передаёт None ТОЛЬКО за явным флагом settings.quota_dev_fail_open (по умолчанию ВЫКЛЮЧЕН). Анонимный запрос без этого флага получает anon-ключ (см. app.core.anon_session), а не None — то есть на практике анонимные пользователи в проде квоту получают, а не безлимит. - При исчерпании лимита поднимается HTTPException(429). """ from __future__ import annotations import logging from datetime import UTC, datetime from fastapi import HTTPException from sqlalchemy import text from sqlalchemy.orm import Session from app.core.auth import get_role from app.core.config import settings logger = logging.getLogger(__name__) # Лимит успешных оценок за календарный месяц — конфигурируется через # env ESTIMATE_QUOTA_LIMIT (core.config.Settings), default 15 (#658). MONTHLY_LIMIT = settings.estimate_quota_limit def limit_exhausted_message(limit: int) -> str: """Текст 429 при исчерпании лимита — параметризован реальным лимитом (может отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ anon default_limit, см. #b2c-antiabuse-2).""" return ( f"Лимит из {limit} оценок в этом месяце исчерпан. " "За полной версией обращайтесь к Копылову." ) # Backward-compat константа для MONTHLY_LIMIT-based сценариев (тесты, existing # imports) — байт-в-байт совпадает с limit_exhausted_message(MONTHLY_LIMIT). LIMIT_EXHAUSTED_MESSAGE = limit_exhausted_message(MONTHLY_LIMIT) def current_period() -> str: """Возвращает текущий период в формате 'YYYY-MM' (UTC).""" return datetime.now(UTC).strftime("%Y-%m") def is_unlimited(db: Session, username: str) -> bool: """True если пользователь не ограничен квотой. Unlimited если: - роль admin (RBAC roles.yaml, in-memory, БЕЗ похода в БД — admin гарантированно безлимитен по дизайну RBAC, отдельная per-user запись не нужна); - ЛИБО персональный грант account_quota_overrides.unlimited = true (миграция 191) — единственный источник правды для non-admin безлимитных аккаунтов, включая kopylov (перенесён сюда этой же миграцией, до 191 был захардкожен как `username == 'kopylov'`) и praktika (пилот восстановлен 2026-07-27). KeyError (неизвестный пользователь, не в roles.yaml) → трактуется как limited (False), БЕЗ похода в БД — override-таблица не источник правды для юзеров, которых вообще нет в RBAC-конфиге. """ try: role = get_role(username) except KeyError: return False if role == "admin": return True row = db.execute( text( """ SELECT unlimited FROM account_quota_overrides WHERE username = :u """ ), {"u": username}, ).fetchone() return bool(row is not None and row.unlimited) def user_limit(db: Session, username: str, *, default: int = MONTHLY_LIMIT) -> int: """Персональный месячный лимит для username, иначе *default*. Источник override — таблица account_quota_overrides (см. миграцию 185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 — «Осталось 50 из 15»). *default* параметризован (не всегда MONTHLY_LIMIT) ради anon-ключей (#b2c-antiabuse-2): анонимный ("anon::") ключ в норме не имеет override-строки → падает на *default*, который вызывающая сторона задаёт равным settings.anon_estimate_quota_limit (гораздо строже пилот-лимита). """ row = db.execute( text( """ SELECT monthly_limit FROM account_quota_overrides WHERE username = :u """ ), {"u": username}, ).fetchone() if row is not None and row.monthly_limit is not None: return int(row.monthly_limit) return default def get_status(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> dict: """Возвращает статус квоты для пользователя (или anon-ключа, #b2c-antiabuse-2). Если username is None → unlimited True, used 0, remaining = default_limit (fail-open — вызывающая сторона передаёт None ТОЛЬКО за явным dev-флагом, см. app.api.v1.trade_in._resolve_quota_identity). Если unlimited → used = фактический или 0, remaining = limit (per-user override или *default_limit*). """ if username is None: return { "limit": default_limit, "used": 0, "remaining": default_limit, "unlimited": True, } unlimited = is_unlimited(db, username) period = current_period() limit = user_limit(db, username, default=default_limit) row = db.execute( text( """ SELECT used FROM account_estimate_usage WHERE username = :u AND period_month = :p """ ), {"u": username, "p": period}, ).fetchone() used = row.used if row is not None else 0 if unlimited: return { "limit": limit, "used": used, "remaining": limit, "unlimited": True, } # Защитный кламп: remaining никогда не превышает limit, даже если used всё же # снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет # отрицательный used перед вычитанием. remaining = max(0, limit - max(0, used)) return { "limit": limit, "used": used, "remaining": remaining, "unlimited": False, } def check_and_raise( db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT ) -> None: """Проверяет лимит квоты и поднимает 429 если исчерпан. Если username is None или пользователь unlimited → no-op. *default_limit* задаёт лимит для ключей без персонального override (пилот → MONTHLY_LIMIT, anon-ключ → settings.anon_estimate_quota_limit, см. #b2c-antiabuse-2). """ if username is None: return if is_unlimited(db, username): return period = current_period() limit = user_limit(db, username, default=default_limit) row = db.execute( text( """ SELECT used FROM account_estimate_usage WHERE username = :u AND period_month = :p """ ), {"u": username, "p": period}, ).fetchone() used = row.used if row is not None else 0 if used >= limit: logger.warning( "quota exhausted: username=%r period=%s used=%d limit=%d", username, period, used, limit, ) raise HTTPException(status_code=429, detail=limit_exhausted_message(limit)) def increment(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> bool: """Атомарно-условный инкремент счётчика успешных оценок (#747). Возвращает True если инкремент успешен; False если лимит исчерпан. None / unlimited → True (no-op success). *default_limit* — см. check_and_raise. Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE — два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim` — персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT. """ if username is None or is_unlimited(db, username): return True period = current_period() lim = user_limit(db, username, default=default_limit) row = db.execute( text( """ INSERT INTO account_estimate_usage (username, period_month, used, updated_at) VALUES (:u, :p, 1, NOW()) ON CONFLICT (username, period_month) DO UPDATE SET used = account_estimate_usage.used + 1, updated_at = NOW() WHERE account_estimate_usage.used < :lim RETURNING used """ ), {"u": username, "p": period, "lim": lim}, ).fetchone() db.commit() ok = row is not None if ok: logger.debug("quota incremented: username=%r period=%s used=%s", username, period, row[0]) else: logger.warning( "quota increment refused (atomic, #747): username=%r period=%s limit=%d", username, period, lim, ) return ok