Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc29c3421 | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 |
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
|
||||
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
|
||||
|
||||
@@ -84,14 +84,52 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Лимит на стакинг (макс активаций в день)
|
||||
if not promo_limiter.can_activate(message.from_user.id):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
if result['success']:
|
||||
promo_limiter.record_activation(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result['description']),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
# Записываем неудачную попытку только для not_found (перебор)
|
||||
if result['error'] == 'not_found':
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
error_messages = {
|
||||
'not_found': texts.PROMOCODE_INVALID,
|
||||
'expired': texts.PROMOCODE_EXPIRED,
|
||||
@@ -104,6 +142,10 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
|
||||
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
|
||||
),
|
||||
'daily_limit': texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
'server_error': texts.ERROR,
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -159,10 +159,27 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
if len(potential_code) < 3 or len(potential_code) > 50:
|
||||
return False
|
||||
|
||||
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
|
||||
if not validate_promo_format(potential_code):
|
||||
return False
|
||||
|
||||
# Rate-limit на перебор промокодов
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return True
|
||||
|
||||
# Сначала проверяем реферальный код
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if referrer:
|
||||
@@ -217,7 +234,10 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
|
||||
return True
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
|
||||
@@ -969,8 +989,26 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return
|
||||
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
if referrer:
|
||||
@@ -1000,7 +1038,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачу
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}')
|
||||
return
|
||||
|
||||
+61
-65
@@ -100,40 +100,18 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
if len(title) < 5:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:')
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
if len(title) > 255:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
# Глобальный блок
|
||||
@@ -156,20 +134,8 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
await state.update_data(title=title)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'),
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_message)
|
||||
|
||||
@@ -244,7 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
)
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||||
try:
|
||||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(text_msg)
|
||||
else:
|
||||
await message.answer(text_msg)
|
||||
await state.clear()
|
||||
@@ -261,15 +230,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
err_text = texts.t(
|
||||
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=err_text,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(err_text)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -323,13 +284,16 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
]
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=creation_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=creation_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
@@ -723,6 +687,28 @@ async def user_delete_message(callback: types.CallbackQuery):
|
||||
await callback.answer('✅')
|
||||
|
||||
|
||||
async def _edit_or_send(
|
||||
message: types.Message,
|
||||
chat_id: int | None,
|
||||
message_id: int | None,
|
||||
text: str,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
|
||||
if chat_id and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=get_ticket_cancel_keyboard(language),
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
|
||||
|
||||
|
||||
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
|
||||
try:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
@@ -740,10 +726,20 @@ async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_u
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_reply)
|
||||
await callback.answer()
|
||||
|
||||
@@ -12,10 +12,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
"""
|
||||
Двухуровневый rate-limiter:
|
||||
1. Общий троттлинг — 0.5 сек между любыми сообщениями (UX)
|
||||
2. /start burst-лимит — макс N вызовов за окно (anti-spam)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate_limit: float = 0.5,
|
||||
start_max_calls: int = 3,
|
||||
start_window: float = 60.0,
|
||||
):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: dict[int, float] = {}
|
||||
|
||||
# /start anti-spam: sliding window per user
|
||||
self.start_max_calls = start_max_calls
|
||||
self.start_window = start_window
|
||||
self.start_buckets: dict[int, list[float]] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
@@ -30,10 +46,37 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
|
||||
# --- /start burst rate-limit ---
|
||||
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
|
||||
timestamps = self.start_buckets.get(user_id, [])
|
||||
# Оставляем только вызовы внутри окна
|
||||
timestamps = [ts for ts in timestamps if now - ts < self.start_window]
|
||||
|
||||
if len(timestamps) >= self.start_max_calls:
|
||||
cooldown = int(self.start_window - (now - timestamps[0])) + 1
|
||||
logger.warning(
|
||||
'Rate-limit /start для %s: %d вызовов за %ds (лимит %d)',
|
||||
user_id,
|
||||
len(timestamps),
|
||||
int(self.start_window),
|
||||
self.start_max_calls,
|
||||
)
|
||||
try:
|
||||
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
|
||||
except Exception:
|
||||
pass
|
||||
self.start_buckets[user_id] = timestamps
|
||||
return None
|
||||
|
||||
timestamps.append(now)
|
||||
self.start_buckets[user_id] = timestamps
|
||||
|
||||
# --- Общий троттлинг (0.5 сек) ---
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f'🚫 Throttling для пользователя {user_id}')
|
||||
logger.warning(f'Throttling для пользователя {user_id}')
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
@@ -61,9 +104,17 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
# Периодическая очистка старых записей
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold
|
||||
}
|
||||
# Очистка /start бакетов (раз в ~60 сек, лениво)
|
||||
if len(self.start_buckets) > 500:
|
||||
self.start_buckets = {
|
||||
uid: [ts for ts in tss if now - ts < self.start_window]
|
||||
for uid, tss in self.start_buckets.items()
|
||||
if any(now - ts < self.start_window for ts in tss)
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -54,6 +54,18 @@ class PromoCodeService:
|
||||
if existing_use:
|
||||
return {'success': False, 'error': 'already_used_by_user'}
|
||||
|
||||
# Лимит на количество активаций за день (анти-стакинг)
|
||||
from app.database.crud.promocode import count_user_recent_activations
|
||||
|
||||
recent_count = await count_user_recent_activations(db, user_id, hours=24)
|
||||
if recent_count >= 5:
|
||||
logger.warning(
|
||||
'Promo stacking limit: user %s has %d activations in 24h',
|
||||
self._format_user_log(user),
|
||||
recent_count,
|
||||
)
|
||||
return {'success': False, 'error': 'daily_limit'}
|
||||
|
||||
# Проверка "только для первой покупки"
|
||||
if getattr(promocode, 'first_purchase_only', False):
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Только буквы, цифры, дефис, подчёркивание
|
||||
PROMO_CODE_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
|
||||
# Лимиты
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
FAILED_WINDOW_SECONDS = 300 # 5 минут
|
||||
MAX_ACTIVATIONS_PER_DAY = 5
|
||||
ACTIVATION_WINDOW_SECONDS = 86400 # 24 часа
|
||||
|
||||
|
||||
class PromoRateLimiter:
|
||||
"""
|
||||
In-memory rate limiter для промокодов:
|
||||
1. Лимит на неудачные попытки (перебор)
|
||||
2. Лимит на количество активаций за день (стакинг)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# user_id → list[timestamp] неудачных попыток
|
||||
self._failed_attempts: dict[int, list[float]] = {}
|
||||
# user_id → list[timestamp] успешных активаций
|
||||
self._activations: dict[int, list[float]] = {}
|
||||
|
||||
def record_failed_attempt(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
attempts.append(now)
|
||||
self._failed_attempts[user_id] = attempts
|
||||
|
||||
if len(attempts) >= MAX_FAILED_ATTEMPTS:
|
||||
logger.warning(
|
||||
'Promo brute-force: user %s — %d failed attempts in %ds',
|
||||
user_id,
|
||||
len(attempts),
|
||||
FAILED_WINDOW_SECONDS,
|
||||
)
|
||||
|
||||
def is_blocked(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
self._failed_attempts[user_id] = attempts
|
||||
return len(attempts) >= MAX_FAILED_ATTEMPTS
|
||||
|
||||
def get_block_cooldown(self, user_id: int) -> int:
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
if not attempts:
|
||||
return 0
|
||||
oldest = attempts[0]
|
||||
remaining = int(FAILED_WINDOW_SECONDS - (time.time() - oldest)) + 1
|
||||
return max(remaining, 0)
|
||||
|
||||
def record_activation(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
activations.append(now)
|
||||
self._activations[user_id] = activations
|
||||
|
||||
def can_activate(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
self._activations[user_id] = activations
|
||||
return len(activations) < MAX_ACTIVATIONS_PER_DAY
|
||||
|
||||
def get_activations_left(self, user_id: int) -> int:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
return max(0, MAX_ACTIVATIONS_PER_DAY - len(activations))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
now = time.time()
|
||||
if len(self._failed_attempts) > 500:
|
||||
self._failed_attempts = {
|
||||
uid: [ts for ts in tss if now - ts < FAILED_WINDOW_SECONDS]
|
||||
for uid, tss in self._failed_attempts.items()
|
||||
if any(now - ts < FAILED_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
if len(self._activations) > 500:
|
||||
self._activations = {
|
||||
uid: [ts for ts in tss if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
for uid, tss in self._activations.items()
|
||||
if any(now - ts < ACTIVATION_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
|
||||
|
||||
def validate_promo_format(code: str) -> bool:
|
||||
"""Проверяет формат промокода: 3-50 символов, только буквы/цифры/дефис/подчёркивание."""
|
||||
if not code or len(code) < 3 or len(code) > 50:
|
||||
return False
|
||||
return bool(PROMO_CODE_PATTERN.match(code))
|
||||
|
||||
|
||||
# Глобальный синглтон
|
||||
promo_limiter = PromoRateLimiter()
|
||||
@@ -335,7 +335,7 @@ async def start_round_now(
|
||||
tags=['contests'],
|
||||
)
|
||||
async def list_rounds(
|
||||
status_filter: str = Query('active', regex='^(active|finished|any)$'),
|
||||
status_filter: str = Query('active', pattern='^(active|finished|any)$'),
|
||||
template_id: int | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
+13
-13
@@ -7,20 +7,20 @@ license = { text = 'MIT' }
|
||||
requires-python = '==3.13.*'
|
||||
dependencies = [
|
||||
"aiogram>=3.25.0",
|
||||
'sqlalchemy>=2.0.43',
|
||||
'alembic>=1.16.5',
|
||||
'asyncpg>=0.30.0',
|
||||
'aiosqlite>=0.21.0',
|
||||
'fastapi[standard]>=0.115.6',
|
||||
'redis>=5.0.1',
|
||||
'pyyaml>=6.0.2',
|
||||
'yookassa>=3.9.0',
|
||||
'sqlalchemy>=2.0.46',
|
||||
'alembic>=1.18.4',
|
||||
'asyncpg>=0.31.0',
|
||||
'aiosqlite>=0.22.1',
|
||||
'fastapi[standard]>=0.129.0',
|
||||
'redis>=7.1.1',
|
||||
'pyyaml>=6.0.3',
|
||||
'yookassa>=3.10.0',
|
||||
'python-dateutil>=2.9.0.post0',
|
||||
'cryptography>=41.0.0',
|
||||
'qrcode[pil]>=7.4.2',
|
||||
'packaging>=23.2',
|
||||
'bcrypt>=4.2.0',
|
||||
'pyjwt>=2.8.0',
|
||||
'cryptography>=44.0.1',
|
||||
'qrcode[pil]>=8.0',
|
||||
'packaging>=26.0',
|
||||
'bcrypt>=5.0.0',
|
||||
'pyjwt>=2.11.0',
|
||||
'pyzipper>=0.3.6',
|
||||
]
|
||||
|
||||
|
||||
+22
-22
@@ -1,24 +1,24 @@
|
||||
# Основные зависимости
|
||||
aiogram==3.22.0
|
||||
aiohttp==3.12.15
|
||||
asyncpg==0.30.0
|
||||
SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
aiogram==3.25.0
|
||||
aiohttp==3.13.3
|
||||
asyncpg==0.31.0
|
||||
SQLAlchemy==2.0.46
|
||||
alembic==1.18.4
|
||||
aiosqlite==0.22.1
|
||||
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.0
|
||||
python-dotenv==1.2.1
|
||||
redis==7.1.1
|
||||
PyYAML==6.0.3
|
||||
fastapi==0.129.0
|
||||
uvicorn==0.40.0
|
||||
websockets>=12.0
|
||||
python-multipart==0.0.9
|
||||
python-multipart==0.0.22
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.9.0
|
||||
yookassa==3.10.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
# nalogo - используем локальную исправленную версию в app/lib/nalogo/
|
||||
@@ -28,21 +28,21 @@ httpx # зависимость для nalogo
|
||||
structlog==23.2.0
|
||||
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.11.0
|
||||
APScheduler==3.11.2
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
qrcode[pil]==7.4.2
|
||||
cryptography>=44.0.1
|
||||
qrcode[pil]==8.2
|
||||
|
||||
# Личный кабинет (Cabinet)
|
||||
bcrypt==4.2.0
|
||||
PyJWT==2.8.0
|
||||
email-validator==2.1.0
|
||||
bcrypt==5.0.0
|
||||
PyJWT==2.11.0
|
||||
email-validator==2.3.0
|
||||
|
||||
# Для работы с версиями
|
||||
packaging==23.2
|
||||
packaging==26.0
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
|
||||
@@ -94,16 +94,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.1"
|
||||
version = "1.18.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -413,17 +413,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
version = "0.129.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -977,11 +978,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,11 +1106,11 @@ pil = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.0"
|
||||
version = "7.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1146,21 +1147,21 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiogram", specifier = ">=3.25.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||
{ name = "alembic", specifier = ">=1.16.5" },
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "packaging", specifier = ">=23.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||
{ name = "alembic", specifier = ">=1.18.4" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.1" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "pyzipper", specifier = ">=0.3.6" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" },
|
||||
{ name = "redis", specifier = ">=5.0.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.43" },
|
||||
{ name = "yookassa", specifier = ">=3.9.0" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
{ name = "redis", specifier = ">=7.1.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||
{ name = "yookassa", specifier = ">=3.10.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1545,7 +1546,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yookassa"
|
||||
version = "3.9.0"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
@@ -1554,4 +1555,4 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/05/cbacdfd7d5478956e184cb9e1e99321e1ae14f325cb8eadaf6e7a64206ab/yookassa-3.9.0.tar.gz", hash = "sha256:e8a78fcd96543a5700a4ae9e1411b54f729998f4c051c06c182b9946926e67f5", size = 139247, upload-time = "2025-12-17T09:11:55.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/d7/d5ee3948cbf0379a6af4e0a6da2c39e2e455a84b10651c8ea75c75fea50f/yookassa-3.10.0.tar.gz", hash = "sha256:5d138fa568129989688f632122719741a8ad983bb7c1f1f1b6294e7a83d67915", size = 140883, upload-time = "2026-01-28T12:23:50.413Z" }
|
||||
|
||||
Reference in New Issue
Block a user