fix: resolve multiple production errors and performance issues

- tickets.py: guard against non-text messages in waiting_for_title FSM state
- payments.py: fix Wata webhook using wrong field name (order_id vs orderId),
  add full payload to error log
- tariff.py: stop overwriting admin tariff settings on every bot restart,
  sync_default_tariff_from_config now only creates if no tariff exists
- start.py: catch TelegramBadRequest specifically for "message is not modified"
  instead of bare except with useless retry
- admin/tickets.py: downgrade ticket notification log from error to warning
  for expected case of OAuth/email users without telegram_id
- pricing.py, countries.py, purchase.py: guard against expired FSM state
  causing KeyError on 'period_days'
- blacklist_service.py: add 5-min in-memory cache to is_user_blacklisted()
  to reduce DB load from per-request checks
- remnawave_service.py: fix "Session is closed" race condition — create
  new RemnaWaveAPI instance per get_api_client() call instead of reusing
  shared instance whose aiohttp session gets overwritten by parallel coroutines
This commit is contained in:
Fringg
2026-02-08 17:40:51 +03:00
parent 5f3e426750
commit 071c23dd52
10 changed files with 86 additions and 51 deletions
+24 -23
View File
@@ -492,8 +492,8 @@ async def reorder_tariffs(
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
"""
Синхронизирует дефолтный тариф из конфига (.env) в БД.
Создаёт тариф "Стандартный" если в БД нет тарифов.
Обновляет цены существующего тарифа если он есть.
Создаёт тариф "Стандартный" только если в БД нет тарифов.
Существующий тариф НЕ перезаписывается — админ управляет им через кабинет.
Returns:
Tariff или None если не требуется синхронизация
@@ -519,13 +519,9 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
existing_tariff = result.scalar_one_or_none()
if existing_tariff:
# Обновляем цены существующего тарифа
existing_tariff.period_prices = period_prices
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
await db.commit()
await db.refresh(existing_tariff)
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
# Админ управляет тарифом через кабинет, синхронизация не нужна.
logger.info("Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id)
return existing_tariff
if tariff_count == 0:
@@ -571,21 +567,26 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
)
tariff = result.scalar_one_or_none()
if tariff and tariff.period_prices:
# Преобразуем строковые ключи в int
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
if period_prices:
set_period_prices_from_db(period_prices)
logger.info(
"Загружены периоды из тарифа '%s': %s",
tariff.name,
{f'{d}д': f'{p // 100}' for d, p in period_prices.items()},
)
else:
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
else:
if not tariff:
logger.info('Активные тарифы не найдены, используются цены из .env')
return
if not tariff.period_prices:
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
return
# Преобразуем строковые ключи в int
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
if period_prices:
set_period_prices_from_db(period_prices)
logger.info(
"Загружены периоды из тарифа '%s': %s",
tariff.name,
{f'{d}д': f'{p // 100}' for d, p in period_prices.items()},
)
else:
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
except Exception as e:
logger.error('Ошибка загрузки периодов из БД: %s', e)
+3 -2
View File
@@ -1045,10 +1045,11 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
return
if not getattr(user, 'telegram_id', None):
logger.error(
'Cannot notify ticket #%s user without telegram_id (username=%s)',
logger.warning(
'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)',
ticket.id,
getattr(user, 'username', None),
getattr(user, 'auth_type', None),
)
return
+6 -7
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramForbiddenError
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -776,12 +776,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
try:
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
except Exception as e:
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
try:
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
except:
pass
except TelegramBadRequest as e:
if 'message is not modified' in str(e):
pass # Сообщение уже содержит нужный текст
else:
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}')
+4
View File
@@ -468,6 +468,10 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
if 'period_days' not in data:
await callback.answer('❌ Данные подписки устарели. Начните оформление заново.', show_alert=True)
return
selected_countries = data.get('countries', [])
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
+4
View File
@@ -24,6 +24,10 @@ async def _prepare_subscription_summary(
texts,
) -> tuple[str, dict[str, Any]]:
summary_data = dict(data)
if 'period_days' not in summary_data:
raise KeyError('period_days missing from subscription data — FSM state likely expired')
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = calculate_months_from_days(summary_data['period_days'])
+5
View File
@@ -1402,6 +1402,11 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
prepared_cart_data = dict(cart_data)
if 'period_days' not in prepared_cart_data:
await callback.answer('❌ Корзина повреждена. Оформите подписку заново.', show_alert=True)
await user_cart_service.delete_user_cart(db_user.id)
return
if not settings.is_devices_selection_enabled():
try:
from .pricing import _prepare_subscription_summary
+3
View File
@@ -80,6 +80,9 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
return
"""Обработать ввод заголовка тикета"""
if not message.text:
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
return
title = message.text.strip()
data_prompt = await state.get_data()
+17 -1
View File
@@ -5,6 +5,7 @@
import asyncio
import logging
import time
from datetime import datetime, timedelta
import aiohttp
@@ -27,6 +28,9 @@ class BlacklistService:
interval_hours = self.get_blacklist_update_interval_hours()
self.update_interval = timedelta(hours=interval_hours)
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
# Кэш результатов проверки: {telegram_id: (is_blacklisted, reason, timestamp)}
self._check_cache: dict[int, tuple[bool, str | None, float]] = {}
self._cache_ttl = 300 # 5 минут
def is_blacklist_check_enabled(self) -> bool:
"""Проверяет, включена ли проверка черного списка"""
@@ -117,6 +121,7 @@ class BlacklistService:
self.blacklist_data = blacklist_data
self.last_update = datetime.utcnow()
self._check_cache.clear()
logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей')
return True
@@ -141,9 +146,17 @@ class BlacklistService:
if not self.is_blacklist_check_enabled():
return False, None
# Проверяем кэш
now = time.monotonic()
cached = self._check_cache.get(telegram_id)
if cached is not None:
is_bl, reason, ts = cached
if now - ts < self._cache_ttl:
return is_bl, reason
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
if self.should_ignore_admins() and self.is_admin(telegram_id):
logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка')
self._check_cache[telegram_id] = (False, None, now)
return False, None
# Если черный список пуст или устарел, обновляем его
@@ -156,6 +169,7 @@ class BlacklistService:
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}')
self._check_cache[telegram_id] = (True, bl_reason, now)
return True, bl_reason
# Проверяем по username, если он передан
@@ -166,8 +180,10 @@ class BlacklistService:
logger.info(
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
)
self._check_cache[telegram_id] = (True, bl_reason, now)
return True, bl_reason
self._check_cache[telegram_id] = (False, None, now)
return False, None
async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]:
+18 -16
View File
@@ -151,19 +151,20 @@ class RemnaWaveService:
elif not api_key:
self._config_error = 'REMNAWAVE_API_KEY не настроен'
self.api: RemnaWaveAPI | None
if self._config_error:
self.api = None
else:
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=auth_params.get('secret_key'),
username=auth_params.get('username'),
password=auth_params.get('password'),
caddy_token=auth_params.get('caddy_token'),
auth_type=auth_params.get('auth_type') or 'api_key',
)
# Сохраняем параметры для создания новых экземпляров API клиента
# (каждый вызов get_api_client создаёт свой экземпляр, чтобы
# параллельные корутины не перезаписывали друг другу aiohttp-сессию)
self._api_kwargs: dict | None = None
if not self._config_error:
self._api_kwargs = {
'base_url': base_url,
'api_key': api_key,
'secret_key': auth_params.get('secret_key'),
'username': auth_params.get('username'),
'password': auth_params.get('password'),
'caddy_token': auth_params.get('caddy_token'),
'auth_type': auth_params.get('auth_type') or 'api_key',
}
@property
def is_configured(self) -> bool:
@@ -174,7 +175,7 @@ class RemnaWaveService:
return self._config_error
def _ensure_configured(self) -> None:
if not self.is_configured or self.api is None:
if not self.is_configured or self._api_kwargs is None:
raise RemnaWaveConfigurationError(self._config_error or 'RemnaWave API не настроен')
def _ensure_user_remnawave_uuid(
@@ -228,8 +229,9 @@ class RemnaWaveService:
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
assert self._api_kwargs is not None
api = RemnaWaveAPI(**self._api_kwargs)
async with api:
yield api
def _now_utc(self) -> datetime:
+2 -2
View File
@@ -524,8 +524,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
if success:
return JSONResponse({'status': 'ok'})
order_id = payload.get('order_id', 'unknown')
logger.error('Wata webhook processing failed: order_id=%s', order_id)
order_id = payload.get('orderId') or payload.get('order_id') or 'unknown'
logger.error('Wata webhook processing failed: order_id=%s, payload=%s', order_id, payload)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,