gendesign/tradein-mvp/backend/app/services/account_quota.py
bot-backend 9c12407c09
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/quota): per-user quota override + no burn on empty result
Two bugs hit the first paying pilot (user2/Брусника):

1. Bonus attempts were granted via negative `used` hack
   (account_estimate_usage.used = -35), which made /quota return
   {limit:15, used:-35, remaining:50} -> frontend rendered the absurd
   "Осталось 50 из 15". Replaced with a proper personal monthly limit:
   account_quota_overrides table (migration 184) + account_quota.user_limit()
   used across get_status/check_and_raise/increment instead of the hardcoded
   global MONTHLY_LIMIT. get_status now clamps remaining = max(0, limit -
   max(0, used)) so remaining can never exceed limit even if a negative
   used leaks through again. Migration seeds user2 -> monthly_limit=50 and
   resets the old hack (used<0 -> 0), so user2 now sees "50 из 50".

2. POST /estimate unconditionally called account_quota.increment() after
   estimate_quality, even when the address failed to geocode and the
   handler returned the _empty_estimate fallback (median=0, n_analogs=0,
   insufficient_data=True, HTTP 200) -- burning a paid slot for a null
   result. Now increment is skipped when result.insufficient_data is True;
   the #747 TOCTOU-safe atomic increment/429 gate is unchanged for real
   results.
2026-07-13 22:53:14 +03:00

211 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
Правила:
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
default 15.
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
см. миграцию 184_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
через negative `used` (ломал /quota — «Осталось 50 из 15»).
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited,
лимит не применяется (fail-open).
- При исчерпании лимита поднимается 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
LIMIT_EXHAUSTED_MESSAGE = (
f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
)
def current_period() -> str:
"""Возвращает текущий период в формате 'YYYY-MM' (UTC)."""
return datetime.now(UTC).strftime("%Y-%m")
def is_unlimited(username: str) -> bool:
"""True если пользователь не ограничен квотой.
Unlimited: роль admin ИЛИ username == 'kopylov'.
KeyError (неизвестный пользователь) → трактуется как limited (False).
"""
if username == "kopylov":
return True
try:
role = get_role(username)
return role == "admin"
except KeyError:
return False
def user_limit(db: Session, username: str) -> int:
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
Источник override — таблица account_quota_overrides (см. миграцию
184_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
«Осталось 50 из 15»).
"""
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 MONTHLY_LIMIT
def get_status(db: Session, username: str | None) -> dict:
"""Возвращает статус квоты для пользователя.
Если username is None → unlimited True, used 0, remaining = MONTHLY_LIMIT.
Если unlimited → used = фактический или 0, remaining = limit (per-user override
или глобальный MONTHLY_LIMIT).
"""
if username is None:
return {
"limit": MONTHLY_LIMIT,
"used": 0,
"remaining": MONTHLY_LIMIT,
"unlimited": True,
}
unlimited = is_unlimited(username)
period = current_period()
limit = user_limit(db, username)
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) -> None:
"""Проверяет лимит квоты и поднимает 429 если исчерпан.
Если username is None или пользователь unlimited → no-op.
"""
if username is None:
return
if is_unlimited(username):
return
period = current_period()
limit = user_limit(db, username)
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)
def increment(db: Session, username: str | None) -> bool:
"""Атомарно-условный инкремент счётчика успешных оценок (#747).
Возвращает True если инкремент успешен; False если лимит исчерпан.
None / unlimited → True (no-op success).
Защита от 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(username):
return True
period = current_period()
lim = user_limit(db, username)
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