From 6e1d671df22f8563264374d2bd800e5d1a8e9f4c Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sat, 10 Jan 2026 00:47:23 +0300 Subject: [PATCH 01/15] =?UTF-8?q?feat(traffic):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0=20v2=20=D1=81=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=BE=D0=B9=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=82=D1=8B=20=D0=B8=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT --- .env.example | 32 +- app/config.py | 44 +- app/handlers/admin/monitoring.py | 80 +- app/services/payment/cloudpayments.py | 5 +- app/services/payment/cryptobot.py | 7 +- app/services/payment/freekassa.py | 8 +- app/services/payment/mulenpay.py | 8 +- app/services/payment/pal24.py | 8 +- app/services/payment/platega.py | 8 +- app/services/payment/stars.py | 7 +- app/services/payment/wata.py | 8 +- app/services/payment/yookassa.py | 12 +- .../subscription_auto_purchase_service.py | 213 ++- app/services/traffic_monitoring_service.py | 1206 ++++++++++++----- app/services/tribute_service.py | 8 +- main.py | 7 +- .../services/test_traffic_monitoring_redis.py | 380 ++++++ 17 files changed, 1517 insertions(+), 524 deletions(-) create mode 100644 tests/services/test_traffic_monitoring_redis.py diff --git a/.env.example b/.env.example index 51233971..c5071757 100644 --- a/.env.example +++ b/.env.example @@ -66,11 +66,33 @@ ADMIN_REPORTS_CHAT_ID= # Опционально: чат ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета -# Мониторинг трафика -TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей -TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление) -TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24) -SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат) +# ===== МОНИТОРИНГ ТРАФИКА ===== +# Логика: при запуске бота создаётся snapshot трафика всех пользователей. +# Через указанный интервал проверяется дельта (разница) трафика. +# Если дельта превышает порог — отправляется уведомление админам. + +# Быстрая проверка (дельта трафика за интервал) +TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку +TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах +TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал) + +# Суточная проверка (трафик за 24 часа через bandwidth API) +TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку +TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC) +TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ + +# Куда отправлять уведомления +SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности + +# Фильтрация по серверам (UUID нод через запятую) +TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все) +TRAFFIC_IGNORED_NODES= # Исключить эти ноды + +# Производительность +TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей +TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API +TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты) +TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте) # Черный список BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку diff --git a/app/config.py b/app/config.py index 58ff2239..2489c969 100644 --- a/app/config.py +++ b/app/config.py @@ -237,11 +237,32 @@ class Settings(BaseSettings): MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API # Настройки мониторинга трафика - TRAFFIC_MONITORING_ENABLED: bool = False - TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки - TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки) + TRAFFIC_MONITORING_ENABLED: bool = False # Глобальный переключатель (для обратной совместимости) + TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки (для обратной совместимости) + TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (для обратной совместимости) SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + # Новый мониторинг трафика v2 + # Быстрая проверка (текущий использованный трафик) + TRAFFIC_FAST_CHECK_ENABLED: bool = False + TRAFFIC_FAST_CHECK_INTERVAL_MINUTES: int = 10 # Интервал проверки в минутах + TRAFFIC_FAST_CHECK_THRESHOLD_GB: float = 5.0 # Порог в ГБ для быстрой проверки + + # Суточная проверка (трафик за 24 часа) + TRAFFIC_DAILY_CHECK_ENABLED: bool = False + TRAFFIC_DAILY_CHECK_TIME: str = "00:00" # Время суточной проверки (HH:MM) + TRAFFIC_DAILY_THRESHOLD_GB: float = 50.0 # Порог суточного трафика в ГБ + + # Фильтрация по серверам (UUID нод через запятую) + TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все) + TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды + + # Параллельность и кулдаун + TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей + TRAFFIC_CHECK_CONCURRENCY: int = 10 # Параллельных запросов + TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES: int = 60 # Кулдаун уведомлений (минуты) + TRAFFIC_SNAPSHOT_TTL_HOURS: int = 24 # TTL для snapshot трафика в Redis (часы) + AUTOPAY_WARNING_DAYS: str = "3,1" ENABLE_AUTOPAY: bool = False @@ -829,6 +850,23 @@ class Settings(BaseSettings): def get_remnawave_auto_sync_times(self) -> List[time]: return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES) + def get_traffic_monitored_nodes(self) -> List[str]: + """Возвращает список UUID нод для мониторинга (пусто = все)""" + if not self.TRAFFIC_MONITORED_NODES: + return [] + return [n.strip() for n in self.TRAFFIC_MONITORED_NODES.split(",") if n.strip()] + + def get_traffic_ignored_nodes(self) -> List[str]: + """Возвращает список UUID нод для исключения из мониторинга""" + if not self.TRAFFIC_IGNORED_NODES: + return [] + return [n.strip() for n in self.TRAFFIC_IGNORED_NODES.split(",") if n.strip()] + + def get_traffic_daily_check_time(self) -> Optional[time]: + """Возвращает время суточной проверки трафика""" + times = self.parse_daily_time_list(self.TRAFFIC_DAILY_CHECK_TIME) + return times[0] if times else None + def get_display_name_banned_keywords(self) -> List[str]: raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS if raw_value is None: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index fff926ae..178d3e0c 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -774,81 +774,53 @@ async def force_check_callback(callback: CallbackQuery): @router.callback_query(F.data == "admin_mon_traffic_check") @admin_required async def traffic_check_callback(callback: CallbackQuery): - """Ручная проверка трафика всех пользователей.""" + """Ручная проверка трафика — использует snapshot и дельту.""" try: # Проверяем, включен ли мониторинг трафика if not traffic_monitoring_scheduler.is_enabled(): await callback.answer( "⚠️ Мониторинг трафика отключен в настройках\n" - "Включите TRAFFIC_MONITORING_ENABLED=true в .env", + "Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env", show_alert=True ) return - await callback.answer("⏳ Запускаем проверку трафика...") + await callback.answer("⏳ Запускаем проверку трафика (дельта)...") + + # Используем run_fast_check — он сравнивает с snapshot и отправляет уведомления + from app.services.traffic_monitoring_service import traffic_monitoring_scheduler_v2 # Устанавливаем бота, если не установлен - if not traffic_monitoring_scheduler.bot: - traffic_monitoring_scheduler.set_bot(callback.bot) + if not traffic_monitoring_scheduler_v2.bot: + traffic_monitoring_scheduler_v2.set_bot(callback.bot) - checked_count = 0 - exceeded_count = 0 - exceeded_users = [] + violations = await traffic_monitoring_scheduler_v2.run_fast_check_now() - async for db in get_db(): - from app.database.crud.user import get_users_with_active_subscriptions - - users = await get_users_with_active_subscriptions(db) - - for user in users: - if user.remnawave_uuid: - is_exceeded, traffic_info = await traffic_monitoring_service.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - checked_count += 1 - - if is_exceeded: - exceeded_count += 1 - total_gb = traffic_info.get('total_gb', 0) - exceeded_users.append({ - 'telegram_id': user.telegram_id, - 'name': user.full_name or str(user.telegram_id), - 'traffic_gb': total_gb - }) - - # Отправляем уведомление админам - if traffic_monitoring_scheduler._should_send_notification(user.remnawave_uuid): - await traffic_monitoring_service.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - callback.bot - ) - traffic_monitoring_scheduler._record_notification(user.remnawave_uuid) - - break - - threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY + # Получаем информацию о snapshot + snapshot_age = await traffic_monitoring_scheduler_v2.service.get_snapshot_age_minutes() + threshold_gb = traffic_monitoring_scheduler_v2.service.get_fast_check_threshold_gb() text = f""" 📊 Проверка трафика завершена -🔍 Результаты: -• Проверено пользователей: {checked_count} -• Превышений порога: {exceeded_count} -• Порог: {threshold_gb} ГБ/сутки +🔍 Результаты (дельта): +• Превышений за интервал: {len(violations)} +• Порог дельты: {threshold_gb} ГБ +• Возраст snapshot: {snapshot_age:.1f} мин 🕐 Время проверки: {datetime.now().strftime('%H:%M:%S')} """ - if exceeded_users: - text += "\n⚠️ Пользователи с превышением:\n" - for u in exceeded_users[:10]: - text += f"• {u['name']}: {u['traffic_gb']:.1f} ГБ\n" - if len(exceeded_users) > 10: - text += f"... и ещё {len(exceeded_users) - 10}\n" + if violations: + text += "\n⚠️ Превышения дельты:\n" + for v in violations[:10]: + name = v.full_name or v.user_uuid[:8] + text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n" + if len(violations) > 10: + text += f"... и ещё {len(violations) - 10}\n" + text += "\n📨 Уведомления отправлены (с учётом кулдауна)" + else: + text += "\n✅ Превышений не обнаружено" from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton keyboard = InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index e0d9bca0..dfad134f 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -267,7 +267,10 @@ class CloudPaymentsPaymentMixin: # Умная автоактивация если автопокупка не сработала if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + # Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений + await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=amount_kopeks + ) except Exception as error: logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error) diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 95855c56..2252b6a5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -377,12 +377,14 @@ class CryptoBotPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup( + _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, bot=bot_instance, + topup_amount=amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -392,7 +394,8 @@ class CryptoBotPaymentMixin: exc_info=True, ) - if has_saved_cart and bot_instance: + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and bot_instance and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 163b2734..95ad19d0 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -411,9 +411,12 @@ class FreekassaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -422,7 +425,8 @@ class FreekassaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 3d1705d6..9dd3b8c7 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -396,9 +396,12 @@ class MulenPayPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -407,7 +410,8 @@ class MulenPayPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 1086c63b..3000155c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -499,9 +499,12 @@ class Pal24PaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -510,7 +513,8 @@ class Pal24PaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 3149d0bb..eb6d5501 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -485,9 +485,12 @@ class PlategaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -496,7 +499,8 @@ class PlategaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index a5cff2e7..5cf2841e 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -534,12 +534,14 @@ class TelegramStarsMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup( + _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, bot=getattr(self, "bot", None), + topup_amount=amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -549,7 +551,8 @@ class TelegramStarsMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: texts = get_texts(user.language) cart_message = texts.t( "BALANCE_TOPUP_CART_REMINDER_DETAILED", diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 44d03369..ebaf2db8 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -569,9 +569,12 @@ class WataPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None)) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -580,7 +583,8 @@ class WataPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: from app.localization.texts import get_texts texts = get_texts(user.language) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 678837c9..6f25c28c 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -687,8 +687,9 @@ class YooKassaPaymentMixin: exc_info=True, # Добавляем полный стек вызовов для отладки ) - # Отправляем уведомление пользователю - if getattr(self, "bot", None): + # Отправляем уведомление пользователю (если не включен режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP, + # т.к. в этом случае уведомление будет отправлено из auto_activate_subscription_after_topup) + if getattr(self, "bot", None) and not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: try: # Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой await self._send_payment_success_notification( @@ -738,12 +739,14 @@ class YooKassaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup( + _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, bot=getattr(self, "bot", None), + topup_amount=payment.amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -753,7 +756,8 @@ class YooKassaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 04628830..10fcec90 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -717,7 +717,8 @@ async def auto_activate_subscription_after_topup( user: User, *, bot: Optional[Bot] = None, -) -> bool: + topup_amount: Optional[int] = None, +) -> tuple[bool, bool]: """ Умная автоактивация после пополнения баланса. @@ -727,6 +728,14 @@ async def auto_activate_subscription_after_topup( - Если подписки нет — создаёт новую с дефолтными параметрами Выбирает максимальный период, который можно оплатить из баланса. + + Args: + topup_amount: Сумма пополнения в копейках (для отображения в уведомлении) + + Returns: + tuple[bool, bool]: (success, notification_sent) + - success: True если подписка активирована + - notification_sent: True если уведомление отправлено пользователю """ from datetime import datetime from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription @@ -739,70 +748,98 @@ async def auto_activate_subscription_after_topup( from app.services.admin_notification_service import AdminNotificationService if not user or not getattr(user, "id", None): - return False + return (False, False) subscription = await get_subscription_by_user_id(db, user.id) - # Если автоактивация отключена - только отправляем предупреждение + # Если автоактивация отключена - только отправляем уведомление if not settings.is_auto_activate_after_topup_enabled(): - # Отправляем предупреждение если включен режим и нет активной подписки - if ( - settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP - and bot - and (not subscription or subscription.status not in ("active", "ACTIVE")) - ): + notification_sent = False + # Отправляем уведомление если включен режим + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot: try: texts = get_texts(getattr(user, "language", "ru")) - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"{'─' * 25}\n\n" - f"⚠️ ВАЖНО! ⚠️\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="subscription_buy", - )], - [InlineKeyboardButton( - text="💎 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 ДОБАВИТЬ УСТРОЙСТВА", - callback_data="subscription_add_devices", - )], - ] + has_active_subscription = ( + subscription + and subscription.status in ("active", "ACTIVE") ) + + # Формируем строку с суммой пополнения + topup_line = "" + if topup_amount: + topup_line = f"➕ Пополнено: {settings.format_price(topup_amount)}\n" + + # Определяем состояние подписки + is_trial = subscription and getattr(subscription, 'is_trial', False) + + if has_active_subscription and not is_trial: + # Активная платная подписка — 2 кнопки + warning_message = ( + f"✅ Баланс пополнен!\n\n" + f"{topup_line}" + f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" + f"👇 Выберите действие:" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="💎 Продлить подписку", + callback_data="subscription_extend", + )], + [InlineKeyboardButton( + text="📱 Изменить устройства", + callback_data="subscription_change_devices", + )], + ] + ) + else: + # Триал или подписка закончилась — 1 кнопка + warning_message = ( + f"✅ Баланс пополнен!\n\n" + f"{topup_line}" + f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" + f"{'━' * 20}\n\n" + f"🚨🚨🚨 ВНИМАНИЕ! 🚨🚨🚨\n\n" + f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" + f"⚠️ Пополнение баланса НЕ активирует подписку автоматически!\n\n" + f"👇 Обязательно оформите подписку:" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="🚀 КУПИТЬ ПОДПИСКУ", + callback_data="menu_buy", + )], + ] + ) + await bot.send_message( chat_id=user.telegram_id, text=warning_message, reply_markup=keyboard, parse_mode="HTML", ) + notification_sent = True logger.info( - "⚠️ Отправлено предупреждение об активации подписки пользователю %s (автоактивация выключена)", + "⚠️ Отправлено уведомление о пополнении баланса пользователю %s (автоактивация выключена, подписка %s)", user.telegram_id, + "активна" if has_active_subscription else "неактивна", ) except Exception as notify_error: logger.warning( - "⚠️ Не удалось отправить предупреждение пользователю %s: %s", + "⚠️ Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, notify_error, ) - return False + return (False, notification_sent) - # Если подписка активна — ничего не делаем + # Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть) if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): logger.info( "🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем", user.telegram_id, ) - return False + return (False, False) # Определяем параметры подписки if subscription: @@ -839,7 +876,7 @@ async def auto_activate_subscription_after_topup( if not available_periods: logger.warning("🔁 Автоактивация: нет доступных периодов подписки") - return False + return (False, False) subscription_service = SubscriptionService() @@ -875,56 +912,84 @@ async def auto_activate_subscription_after_topup( user.telegram_id, balance, ) - # Отправляем предупреждение пользователю если включен режим и подписки нет - if ( - settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP - and bot - and (not subscription or subscription.status not in ("active", "ACTIVE")) - ): + notification_sent = False + # Отправляем уведомление если включен режим + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot: try: texts = get_texts(getattr(user, "language", "ru")) - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" - f"{'─' * 25}\n\n" - f"⚠️ ВАЖНО! ⚠️\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="subscription_buy", - )], - [InlineKeyboardButton( - text="💎 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 ДОБАВИТЬ УСТРОЙСТВА", - callback_data="subscription_add_devices", - )], - ] + has_active_subscription = ( + subscription + and subscription.status in ("active", "ACTIVE") ) + + # Формируем строку с суммой пополнения + topup_line2 = "" + if topup_amount: + topup_line2 = f"➕ Пополнено: {settings.format_price(topup_amount)}\n" + + # Определяем состояние подписки + is_trial2 = subscription and getattr(subscription, 'is_trial', False) + + if has_active_subscription and not is_trial2: + # Активная платная подписка — 2 кнопки + warning_message = ( + f"✅ Баланс пополнен!\n\n" + f"{topup_line2}" + f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" + f"👇 Выберите действие:" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="💎 Продлить подписку", + callback_data="subscription_extend", + )], + [InlineKeyboardButton( + text="📱 Изменить устройства", + callback_data="subscription_change_devices", + )], + ] + ) + else: + # Триал или подписка закончилась — 1 кнопка + warning_message = ( + f"✅ Баланс пополнен!\n\n" + f"{topup_line2}" + f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" + f"{'━' * 20}\n\n" + f"🚨🚨🚨 ВНИМАНИЕ! 🚨🚨🚨\n\n" + f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" + f"⚠️ Пополнение баланса НЕ активирует подписку автоматически!\n\n" + f"👇 Обязательно оформите подписку:" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="🚀 КУПИТЬ ПОДПИСКУ", + callback_data="menu_buy", + )], + ] + ) + await bot.send_message( chat_id=user.telegram_id, text=warning_message, reply_markup=keyboard, parse_mode="HTML", ) + notification_sent = True logger.info( - "⚠️ Отправлено предупреждение об активации подписки пользователю %s", + "⚠️ Отправлено уведомление о пополнении баланса пользователю %s (недостаточно средств, подписка %s)", user.telegram_id, + "активна" if has_active_subscription else "неактивна", ) except Exception as notify_error: logger.warning( - "⚠️ Не удалось отправить предупреждение пользователю %s: %s", + "⚠️ Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, notify_error, ) - return False + return (False, notification_sent) texts = get_texts(getattr(user, "language", "ru")) @@ -1085,7 +1150,7 @@ async def auto_activate_subscription_after_topup( notify_error, ) - return True + return (True, True) # success=True, notification_sent=True (об активации) except Exception as e: logger.error( @@ -1094,7 +1159,7 @@ async def auto_activate_subscription_after_topup( e, exc_info=True, ) - return False + return (False, False) __all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"] diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index e3fc2412..9b583128 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -1,432 +1,912 @@ """ -Сервис для мониторинга трафика пользователей -Проверяет, не превышает ли пользователь заданный порог трафика за сутки +Сервис для мониторинга трафика пользователей v2 +Быстрая проверка текущего трафика + суточная проверка """ -import logging import asyncio -from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple, Set +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, time +from typing import Dict, List, Optional, Set from app.config import settings from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveService from app.database.crud.user import get_user_by_remnawave_uuid -from app.database.database import get_db -from app.database.models import User +from app.database.database import AsyncSessionLocal +from app.utils.cache import cache, cache_key from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) +# Ключи для хранения snapshot в Redis +TRAFFIC_SNAPSHOT_KEY = "traffic:snapshot" +TRAFFIC_SNAPSHOT_TIME_KEY = "traffic:snapshot:time" +TRAFFIC_NOTIFICATION_CACHE_KEY = "traffic:notifications" -class TrafficMonitoringService: + +@dataclass +class TrafficViolation: + """Информация о превышении трафика""" + user_uuid: str + telegram_id: Optional[int] + full_name: Optional[str] + username: Optional[str] + used_traffic_gb: float + threshold_gb: float + last_node_uuid: Optional[str] + last_node_name: Optional[str] + check_type: str # "fast" или "daily" + + +class TrafficMonitoringServiceV2: """ - Сервис для мониторинга трафика пользователей + Улучшенный сервис мониторинга трафика + - Батчевая загрузка пользователей + - Параллельная обработка + - Быстрая проверка (каждые N минут) с дельтой + - Суточная проверка + - Фильтрация по нодам + - Хранение snapshot в Redis (персистентность при перезапуске) """ - + def __init__(self): self.remnawave_service = RemnaWaveService() - self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных проверок + self._nodes_cache: Dict[str, str] = {} # {node_uuid: node_name} + # Fallback на память если Redis недоступен + self._memory_snapshot: Dict[str, float] = {} + self._memory_snapshot_time: Optional[datetime] = None + self._memory_notification_cache: Dict[str, datetime] = {} - def is_traffic_monitoring_enabled(self) -> bool: - """Проверяет, включен ли мониторинг трафика""" - return getattr(settings, 'TRAFFIC_MONITORING_ENABLED', False) + # ============== Настройки ============== - def get_traffic_threshold_gb(self) -> float: - """Получает порог трафика в ГБ за сутки""" - return getattr(settings, 'TRAFFIC_THRESHOLD_GB_PER_DAY', 10.0) + def is_fast_check_enabled(self) -> bool: + # Поддержка старого параметра TRAFFIC_MONITORING_ENABLED + return settings.TRAFFIC_FAST_CHECK_ENABLED or settings.TRAFFIC_MONITORING_ENABLED - def get_monitoring_interval_hours(self) -> int: - """Получает интервал мониторинга в часах""" - return getattr(settings, 'TRAFFIC_MONITORING_INTERVAL_HOURS', 24) + def is_daily_check_enabled(self) -> bool: + return settings.TRAFFIC_DAILY_CHECK_ENABLED - def get_suspicious_notifications_topic_id(self) -> Optional[int]: - """Получает ID топика для уведомлений о подозрительной активности""" - return getattr(settings, 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID', None) + def get_fast_check_interval_seconds(self) -> int: + # Если используется старый параметр — конвертируем часы в секунды + if settings.TRAFFIC_MONITORING_ENABLED and not settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_MONITORING_INTERVAL_HOURS * 3600 + return settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES * 60 - async def get_user_daily_traffic(self, user_uuid: str) -> Dict: - """ - Получает статистику трафика пользователя за последние 24 часа + def get_fast_check_threshold_gb(self) -> float: + # Если используется старый параметр — используем старый порог + if settings.TRAFFIC_MONITORING_ENABLED and not settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_THRESHOLD_GB_PER_DAY + return settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB - Args: - user_uuid: UUID пользователя в Remnawave + def get_daily_threshold_gb(self) -> float: + return settings.TRAFFIC_DAILY_THRESHOLD_GB - Returns: - Словарь с информацией о трафике - """ + def get_batch_size(self) -> int: + return settings.TRAFFIC_CHECK_BATCH_SIZE + + def get_concurrency(self) -> int: + return settings.TRAFFIC_CHECK_CONCURRENCY + + def get_notification_cooldown_seconds(self) -> int: + return settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES * 60 + + def get_monitored_nodes(self) -> List[str]: + return settings.get_traffic_monitored_nodes() + + def get_ignored_nodes(self) -> List[str]: + return settings.get_traffic_ignored_nodes() + + def get_daily_check_time(self) -> Optional[time]: + return settings.get_traffic_daily_check_time() + + def get_snapshot_ttl_seconds(self) -> int: + """TTL для snapshot в Redis (по умолчанию 24 часа)""" + return getattr(settings, 'TRAFFIC_SNAPSHOT_TTL_HOURS', 24) * 3600 + + # ============== Redis операции для snapshot ============== + + async def _save_snapshot_to_redis(self, snapshot: Dict[str, float]) -> bool: + """Сохраняет snapshot трафика в Redis""" try: - # Получаем время начала и конца суток (сегодня) - now = datetime.utcnow() - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999) + # Сохраняем snapshot как JSON + snapshot_data = {uuid: bytes_val for uuid, bytes_val in snapshot.items()} + ttl = self.get_snapshot_ttl_seconds() - # Форматируем даты в ISO формат - start_date = start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z") - end_date = end_of_day.strftime("%Y-%m-%dT%H:%M:%S.999Z") - - # Получаем API клиент и вызываем метод получения статистики - async with self.remnawave_service.get_api_client() as api: - traffic_data = await api.get_user_stats_usage(user_uuid, start_date, end_date) - - # Обрабатываем ответ API - if traffic_data and 'response' in traffic_data: - response = traffic_data['response'] - - # Вычисляем общий трафик - total_gb = 0 - nodes_info = [] - - if isinstance(response, list): - for item in response: - node_name = item.get('nodeName', 'Unknown') - total_bytes = item.get('total', 0) - total_gb_item = round(total_bytes / (1024**3), 2) # Конвертируем в ГБ - total_gb += total_gb_item - - nodes_info.append({ - 'node': node_name, - 'gb': total_gb_item - }) - else: - # Если response - это уже результат обработки (как в примере) - total_gb = response.get('total_gb', 0) - nodes_info = response.get('nodes', []) - - return { - 'total_gb': total_gb, - 'nodes': nodes_info, - 'date_range': { - 'start': start_date, - 'end': end_date - } - } + success = await cache.set(TRAFFIC_SNAPSHOT_KEY, snapshot_data, expire=ttl) + if success: + # Сохраняем время создания snapshot + await cache.set( + TRAFFIC_SNAPSHOT_TIME_KEY, + datetime.utcnow().isoformat(), + expire=ttl + ) + logger.info(f"📦 Snapshot сохранён в Redis: {len(snapshot)} пользователей, TTL {ttl//3600}ч") else: - logger.warning(f"Нет данных о трафике для пользователя {user_uuid}") - return { - 'total_gb': 0, - 'nodes': [], - 'date_range': { - 'start': start_date, - 'end': end_date - } - } + logger.warning(f"⚠️ Не удалось сохранить snapshot в Redis") + return success + except Exception as e: + logger.error(f"❌ Ошибка сохранения snapshot в Redis: {e}") + return False + + async def _load_snapshot_from_redis(self) -> Optional[Dict[str, float]]: + """Загружает snapshot трафика из Redis""" + try: + snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY) + if snapshot_data and isinstance(snapshot_data, dict): + # Конвертируем обратно в float + result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()} + logger.debug(f"📦 Snapshot загружен из Redis: {len(result)} пользователей") + return result + return None + except Exception as e: + logger.error(f"❌ Ошибка загрузки snapshot из Redis: {e}") + return None + + async def _get_snapshot_time_from_redis(self) -> Optional[datetime]: + """Получает время создания snapshot из Redis""" + try: + time_str = await cache.get(TRAFFIC_SNAPSHOT_TIME_KEY) + if time_str: + return datetime.fromisoformat(time_str) + return None + except Exception as e: + logger.error(f"❌ Ошибка получения времени snapshot: {e}") + return None + + async def _save_notification_to_redis(self, user_uuid: str) -> bool: + """Сохраняет время уведомления в Redis""" + try: + key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid) + ttl = 24 * 3600 # 24 часа + return await cache.set(key, datetime.utcnow().isoformat(), expire=ttl) + except Exception as e: + logger.error(f"❌ Ошибка сохранения уведомления в Redis: {e}") + return False + + async def _get_notification_time_from_redis(self, user_uuid: str) -> Optional[datetime]: + """Получает время последнего уведомления из Redis""" + try: + key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid) + time_str = await cache.get(key) + if time_str: + return datetime.fromisoformat(time_str) + return None + except Exception as e: + logger.error(f"❌ Ошибка получения времени уведомления: {e}") + return None + + # ============== Фильтрация по нодам ============== + + def should_monitor_node(self, node_uuid: Optional[str]) -> bool: + """Проверяет, нужно ли мониторить пользователя с этой ноды""" + if not node_uuid: + return True # Если нода неизвестна, мониторим + + monitored = self.get_monitored_nodes() + ignored = self.get_ignored_nodes() + + # Если есть список мониторинга — только они + if monitored: + return node_uuid in monitored + + # Если есть список игнорирования — все кроме них + if ignored: + return node_uuid not in ignored + + # Иначе мониторим всех + return True + + # ============== Кулдаун уведомлений ============== + + async def should_send_notification(self, user_uuid: str) -> bool: + """Проверяет, прошёл ли кулдаун для уведомления (Redis + fallback на память)""" + # Пробуем Redis + last_notification = await self._get_notification_time_from_redis(user_uuid) + + # Fallback на память + if last_notification is None: + last_notification = self._memory_notification_cache.get(user_uuid) + + if not last_notification: + return True + + cooldown = self.get_notification_cooldown_seconds() + return (datetime.utcnow() - last_notification).total_seconds() > cooldown + + async def record_notification(self, user_uuid: str): + """Записывает время отправки уведомления (Redis + fallback на память)""" + # Сохраняем в Redis + saved = await self._save_notification_to_redis(user_uuid) + + # Fallback на память + if not saved: + self._memory_notification_cache[user_uuid] = datetime.utcnow() + + async def cleanup_notification_cache(self): + """Очищает старые записи из памяти (Redis очищается автоматически через TTL)""" + now = datetime.utcnow() + expired = [ + uuid for uuid, dt in self._memory_notification_cache.items() + if (now - dt) > timedelta(hours=24) + ] + for uuid in expired: + del self._memory_notification_cache[uuid] + if expired: + logger.debug(f"🧹 Очищено {len(expired)} записей из памяти уведомлений о трафике") + + # ============== Получение пользователей ============== + + async def get_all_users_with_traffic(self) -> List[Dict]: + """ + Получает всех пользователей с их трафиком через батчевые запросы + Возвращает список словарей с информацией о пользователях + """ + all_users = [] + batch_size = self.get_batch_size() + offset = 0 + + try: + async with self.remnawave_service.get_api_client() as api: + while True: + result = await api.get_all_users(start=offset, size=batch_size) + users = result.get('users', []) + + if not users: + break + + all_users.extend(users) + logger.debug(f"📊 Загружено {len(all_users)} пользователей...") + + if len(users) < batch_size: + break + + offset += batch_size + + logger.info(f"✅ Всего загружено {len(all_users)} пользователей из Remnawave") + return all_users except Exception as e: - logger.error(f"Ошибка при получении статистики трафика для {user_uuid}: {e}") - return { - 'total_gb': 0, - 'nodes': [], - 'date_range': { - 'start': None, - 'end': None - } - } + logger.error(f"❌ Ошибка при получении пользователей: {e}") + return [] - async def check_user_traffic_threshold( - self, - db: AsyncSession, - user_uuid: str, - user_telegram_id: int = None - ) -> Tuple[bool, Dict]: + # ============== Быстрая проверка ============== + + async def has_snapshot(self) -> bool: + """Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)""" + # Проверяем Redis + snapshot = await self._load_snapshot_from_redis() + if snapshot: + return True + + # Fallback на память + return bool(self._memory_snapshot) and self._memory_snapshot_time is not None + + async def get_snapshot_age_minutes(self) -> float: + """Возвращает возраст snapshot в минутах (Redis + fallback на память)""" + # Пробуем Redis + snapshot_time = await self._get_snapshot_time_from_redis() + + # Fallback на память + if snapshot_time is None: + snapshot_time = self._memory_snapshot_time + + if not snapshot_time: + return float('inf') + return (datetime.utcnow() - snapshot_time).total_seconds() / 60 + + async def _get_current_snapshot(self) -> Dict[str, float]: + """Получает текущий snapshot (Redis + fallback на память)""" + # Пробуем Redis + snapshot = await self._load_snapshot_from_redis() + if snapshot: + return snapshot + + # Fallback на память + return self._memory_snapshot.copy() + + async def _save_snapshot(self, snapshot: Dict[str, float]) -> bool: + """Сохраняет snapshot (Redis + fallback на память)""" + # Пробуем Redis + saved = await self._save_snapshot_to_redis(snapshot) + + if saved: + # Очищаем память если Redis доступен + self._memory_snapshot.clear() + self._memory_snapshot_time = None + return True + + # Fallback на память + self._memory_snapshot = snapshot.copy() + self._memory_snapshot_time = datetime.utcnow() + logger.warning("⚠️ Redis недоступен, snapshot сохранён в память") + return True + + async def create_initial_snapshot(self) -> int: """ - Проверяет, превышает ли трафик пользователя заданный порог - - Args: - db: Сессия базы данных - user_uuid: UUID пользователя в Remnawave - user_telegram_id: Telegram ID пользователя (для логирования) - - Returns: - Кортеж (превышен ли порог, информация о трафике) + Создаёт начальный snapshot при запуске бота. + Если в Redis уже есть snapshot — использует его (персистентность). + Возвращает количество пользователей в snapshot. """ - if not self.is_traffic_monitoring_enabled(): - return False, {} + # Проверяем есть ли snapshot в Redis + existing_snapshot = await self._load_snapshot_from_redis() + if existing_snapshot: + age = await self.get_snapshot_age_minutes() + logger.info( + f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " + f"возраст {age:.1f} мин" + ) + return len(existing_snapshot) - # Получаем статистику трафика - traffic_info = await self.get_user_daily_traffic(user_uuid) - total_gb = traffic_info.get('total_gb', 0) + logger.info("📸 Создание начального snapshot трафика...") + start_time = datetime.utcnow() - # Получаем порог для сравнения - threshold_gb = self.get_traffic_threshold_gb() + users = await self.get_all_users_with_traffic() + new_snapshot: Dict[str, float] = {} - # Проверяем, превышает ли трафик порог - is_exceeded = total_gb > threshold_gb + for user in users: + try: + if not user.uuid: + continue - # Логируем проверку - user_id_info = f"telegram_id={user_telegram_id}" if user_telegram_id else f"uuid={user_uuid}" - status = "ПРЕВЫШЕНИЕ" if is_exceeded else "норма" + user_traffic = user.user_traffic + if not user_traffic: + continue + + current_bytes = user_traffic.used_traffic_bytes or 0 + new_snapshot[user.uuid] = current_bytes + + except Exception as e: + logger.error(f"❌ Ошибка при создании snapshot для {user.uuid}: {e}") + + # Сохраняем в Redis (с fallback на память) + await self._save_snapshot(new_snapshot) + + elapsed = (datetime.utcnow() - start_time).total_seconds() + logger.info(f"✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей") + + return len(new_snapshot) + + async def run_fast_check(self, bot) -> List[TrafficViolation]: + """ + Быстрая проверка трафика с дельтой + + Логика: + 1. Первый запуск — сохраняем snapshot, не отправляем уведомления + 2. Следующие запуски — сравниваем с snapshot, ищем превышения дельты + 3. После проверки обновляем snapshot (в Redis с fallback на память) + """ + if not self.is_fast_check_enabled(): + return [] + + start_time = datetime.utcnow() + is_first_run = not await self.has_snapshot() + + if is_first_run: + logger.info("🚀 Первый запуск быстрой проверки — создаём snapshot...") + else: + age = await self.get_snapshot_age_minutes() + logger.info(f"🚀 Быстрая проверка трафика (snapshot {age:.1f} мин назад, порог {self.get_fast_check_threshold_gb()} ГБ)...") + + violations: List[TrafficViolation] = [] + threshold_bytes = self.get_fast_check_threshold_gb() * (1024 ** 3) + + users = await self.get_all_users_with_traffic() + new_snapshot: Dict[str, float] = {} + + # Загружаем предыдущий snapshot (из Redis или памяти) + previous_snapshot = await self._get_current_snapshot() + logger.debug(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей") + + checked_users = 0 + users_with_delta = 0 + + for user in users: + try: + if not user.uuid: + continue + + # Получаем трафик из user_traffic + user_traffic = user.user_traffic + if not user_traffic: + continue + + current_bytes = user_traffic.used_traffic_bytes or 0 + new_snapshot[user.uuid] = current_bytes + + # Первый запуск — только сохраняем, не проверяем + if is_first_run: + continue + + # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) + if user.uuid not in previous_snapshot: + continue + + # Получаем предыдущее значение + previous_bytes = previous_snapshot.get(user.uuid, 0) + + # Вычисляем дельту (может быть отрицательной при сбросе трафика) + delta_bytes = current_bytes - previous_bytes + if delta_bytes <= 0: + continue # Трафик сбросился или не изменился + + users_with_delta += 1 + delta_gb = delta_bytes / (1024 ** 3) + + # Проверяем превышение дельты + if delta_bytes < threshold_bytes: + continue + + logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ)") + + # Проверяем фильтр по нодам + last_node_uuid = user_traffic.last_connected_node_uuid + if not self.should_monitor_node(last_node_uuid): + continue + + # Создаём violation + delta_gb = round(delta_bytes / (1024 ** 3), 2) + violation = TrafficViolation( + user_uuid=user.uuid, + telegram_id=user.telegram_id, + full_name=user.username, + username=None, + used_traffic_gb=delta_gb, # Это дельта, не общий трафик! + threshold_gb=self.get_fast_check_threshold_gb(), + last_node_uuid=last_node_uuid, + last_node_name=None, + check_type="fast" + ) + violations.append(violation) + + except Exception as e: + logger.error(f"❌ Ошибка обработки пользователя {user.uuid}: {e}") + + # Обновляем snapshot (в Redis с fallback на память) + await self._save_snapshot(new_snapshot) + + elapsed = (datetime.utcnow() - start_time).total_seconds() + + if is_first_run: + logger.info( + f"✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей. " + f"Следующая проверка покажет превышения." + ) + else: + logger.info( + f"✅ Быстрая проверка завершена за {elapsed:.1f}с: " + f"{len(users)} пользователей, {users_with_delta} с дельтой >0, {len(violations)} превышений" + ) + # Отправляем уведомления только если это не первый запуск + await self._send_violation_notifications(violations, bot) + + return violations + + # ============== Суточная проверка ============== + + async def run_daily_check(self, bot) -> List[TrafficViolation]: + """ + Суточная проверка трафика за последние 24 часа + Использует bandwidth-stats API + """ + if not self.is_daily_check_enabled(): + return [] + + logger.info("🚀 Запуск суточной проверки трафика...") + start_time = datetime.utcnow() + + violations: List[TrafficViolation] = [] + threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3) + + # Получаем период за последние 24 часа + now = datetime.utcnow() + start_date = (now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S.000Z") + end_date = now.strftime("%Y-%m-%dT%H:%M:%S.999Z") + + users = await self.get_all_users_with_traffic() + semaphore = asyncio.Semaphore(self.get_concurrency()) + + async def check_user_daily_traffic(user) -> Optional[TrafficViolation]: + async with semaphore: + try: + if not user.uuid: + return None + + # Получаем статистику за период + async with self.remnawave_service.get_api_client() as api: + stats = await api.get_bandwidth_stats_user(user.uuid, start_date, end_date) + + if not stats: + return None + + # Суммируем трафик по нодам + total_bytes = 0 + if isinstance(stats, list): + for item in stats: + total_bytes += item.get('total', 0) + elif isinstance(stats, dict): + total_bytes = stats.get('total', 0) + + if total_bytes < threshold_bytes: + return None + + # Проверяем фильтр по нодам + user_traffic = user.user_traffic + last_node_uuid = user_traffic.last_connected_node_uuid if user_traffic else None + if not self.should_monitor_node(last_node_uuid): + return None + + used_gb = round(total_bytes / (1024 ** 3), 2) + return TrafficViolation( + user_uuid=user.uuid, + telegram_id=user.telegram_id, + full_name=user.username, + username=None, + used_traffic_gb=used_gb, + threshold_gb=self.get_daily_threshold_gb(), + last_node_uuid=last_node_uuid, + last_node_name=None, + check_type="daily" + ) + + except Exception as e: + logger.error(f"❌ Ошибка суточной проверки для {user.uuid}: {e}") + return None + + # Параллельная проверка + tasks = [check_user_daily_traffic(user) for user in users if user.uuid] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + if isinstance(result, TrafficViolation): + violations.append(result) + + elapsed = (datetime.utcnow() - start_time).total_seconds() logger.info( - f"📊 Проверка трафика для {user_id_info}: {total_gb} ГБ, " - f"порог: {threshold_gb} ГБ, статус: {status}" + f"✅ Суточная проверка завершена за {elapsed:.1f}с: " + f"{len(users)} пользователей, {len(violations)} превышений" ) - return is_exceeded, traffic_info + # Отправляем уведомления + await self._send_violation_notifications(violations, bot) + + return violations + + # ============== Уведомления ============== + + async def _send_violation_notifications(self, violations: List[TrafficViolation], bot): + """Отправляет уведомления о превышениях""" + if not violations or not bot: + return + + admin_service = AdminNotificationService(bot) + topic_id = settings.SUSPICIOUS_NOTIFICATIONS_TOPIC_ID + + # Ограничиваем количество уведомлений за раз (защита от flood) + max_notifications = 10 + if len(violations) > max_notifications: + logger.warning( + f"⚠️ Слишком много превышений ({len(violations)}), " + f"отправляем только первые {max_notifications}" + ) + violations = violations[:max_notifications] + + for i, violation in enumerate(violations): + try: + if not await self.should_send_notification(violation.user_uuid): + logger.debug(f"⏭️ Кулдаун для {violation.user_uuid}, пропускаем") + continue + + # Получаем информацию о пользователе из БД + user_info = "" + async with AsyncSessionLocal() as db: + db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid) + if db_user: + user_info = ( + f"👤 {db_user.full_name or 'Без имени'}\n" + f"🆔 Telegram ID: {db_user.telegram_id}\n" + ) + if db_user.username: + user_info += f"📱 Username: @{db_user.username}\n" + + if violation.check_type == "fast": + check_type_emoji = "⚡" + check_type_name = "Быстрая проверка" + traffic_label = "За интервал" + elif violation.check_type == "daily": + check_type_emoji = "📅" + check_type_name = "Суточная проверка" + traffic_label = "За 24 часа" + else: + check_type_emoji = "🔍" + check_type_name = "Ручная проверка" + traffic_label = "Использовано" + + message = ( + f"⚠️ Превышение трафика\n\n" + f"{user_info}" + f"🔑 UUID: {violation.user_uuid}\n\n" + f"{check_type_emoji} {check_type_name}\n" + f"📊 {traffic_label}: {violation.used_traffic_gb} ГБ\n" + f"📈 Порог: {violation.threshold_gb} ГБ\n" + f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n" + ) + + if violation.last_node_uuid: + message += f"\n🖥 Последняя нода: {violation.last_node_uuid}" + + message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC" + + await admin_service.send_suspicious_traffic_notification(message, bot, topic_id) + await self.record_notification(violation.user_uuid) + + logger.info(f"📨 Уведомление отправлено для {violation.user_uuid}") + + # Задержка между отправками (защита от flood) + if i < len(violations) - 1: + await asyncio.sleep(0.5) + + except Exception as e: + logger.error(f"❌ Ошибка отправки уведомления для {violation.user_uuid}: {e}") + + +class TrafficMonitoringSchedulerV2: + """ + Планировщик проверок трафика v2 + - Быстрая проверка каждые N минут + - Суточная проверка в заданное время + """ + + def __init__(self, service: TrafficMonitoringServiceV2): + self.service = service + self.bot = None + self._fast_check_task: Optional[asyncio.Task] = None + self._daily_check_task: Optional[asyncio.Task] = None + self._is_running = False + + def set_bot(self, bot): + """Устанавливает экземпляр бота""" + self.bot = bot + + async def start(self): + """Запускает планировщик""" + if self._is_running: + logger.warning("Планировщик мониторинга трафика уже запущен") + return + + if not self.bot: + logger.error("Бот не установлен для планировщика мониторинга") + return + + self._is_running = True + + # Создаём начальный snapshot при старте (без уведомлений!) + if self.service.is_fast_check_enabled(): + await self.service.create_initial_snapshot() + + # Запускаем быструю проверку + if self.service.is_fast_check_enabled(): + interval = self.service.get_fast_check_interval_seconds() + logger.info(f"🚀 Запуск быстрой проверки трафика каждые {interval // 60} мин") + self._fast_check_task = asyncio.create_task(self._run_fast_check_loop(interval)) + + # Запускаем суточную проверку + if self.service.is_daily_check_enabled(): + check_time = self.service.get_daily_check_time() + if check_time: + logger.info(f"🚀 Запуск суточной проверки трафика в {check_time.strftime('%H:%M')}") + self._daily_check_task = asyncio.create_task(self._run_daily_check_loop(check_time)) + + async def stop(self): + """Останавливает планировщик""" + self._is_running = False + + if self._fast_check_task: + self._fast_check_task.cancel() + try: + await self._fast_check_task + except asyncio.CancelledError: + pass + self._fast_check_task = None + + if self._daily_check_task: + self._daily_check_task.cancel() + try: + await self._daily_check_task + except asyncio.CancelledError: + pass + self._daily_check_task = None + + logger.info("ℹ️ Планировщик мониторинга трафика остановлен") + + async def _run_fast_check_loop(self, interval_seconds: int): + """Цикл быстрой проверки""" + # Сначала ждём интервал (snapshot уже создан в start()) + logger.info(f"⏳ Первая проверка через {interval_seconds // 60} минут...") + await asyncio.sleep(interval_seconds) + + while self._is_running: + try: + await self.service.cleanup_notification_cache() + await self.service.run_fast_check(self.bot) + await asyncio.sleep(interval_seconds) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ Ошибка в цикле быстрой проверки: {e}") + await asyncio.sleep(interval_seconds) + + async def _run_daily_check_loop(self, check_time: time): + """Цикл суточной проверки""" + while self._is_running: + try: + # Вычисляем время до следующей проверки + now = datetime.utcnow() + next_run = datetime.combine(now.date(), check_time) + if next_run <= now: + next_run += timedelta(days=1) + + delay = (next_run - now).total_seconds() + logger.debug(f"⏰ Следующая суточная проверка через {delay / 3600:.1f}ч") + + await asyncio.sleep(delay) + + if self._is_running: + await self.service.run_daily_check(self.bot) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ Ошибка в цикле суточной проверки: {e}") + await asyncio.sleep(3600) # Ждём час при ошибке + + async def run_fast_check_now(self) -> List[TrafficViolation]: + """Запускает быструю проверку немедленно""" + return await self.service.run_fast_check(self.bot) + + async def run_daily_check_now(self) -> List[TrafficViolation]: + """Запускает суточную проверку немедленно""" + return await self.service.run_daily_check(self.bot) + + +# ============== Обратная совместимость ============== + +class TrafficMonitoringService: + """Обёртка для обратной совместимости со старым API""" + + def __init__(self): + self._v2 = TrafficMonitoringServiceV2() + self.remnawave_service = self._v2.remnawave_service + + def is_traffic_monitoring_enabled(self) -> bool: + # Используем старый параметр или новые + return ( + settings.TRAFFIC_MONITORING_ENABLED or + settings.TRAFFIC_FAST_CHECK_ENABLED or + settings.TRAFFIC_DAILY_CHECK_ENABLED + ) + + def get_traffic_threshold_gb(self) -> float: + """Возвращает порог трафика""" + if settings.TRAFFIC_FAST_CHECK_ENABLED: + return settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB + return settings.TRAFFIC_THRESHOLD_GB_PER_DAY + + async def check_user_traffic_threshold( + self, + db: AsyncSession, + user_uuid: str, + user_telegram_id: int = None + ) -> tuple: + """Проверяет трафик одного пользователя (для обратной совместимости)""" + try: + threshold_gb = self.get_traffic_threshold_gb() + threshold_bytes = threshold_gb * (1024 ** 3) + + # Получаем пользователя из Remnawave + async with self.remnawave_service.get_api_client() as api: + user = await api.get_user_by_uuid(user_uuid) + + if not user or not user.user_traffic: + return False, {'total_gb': 0, 'nodes': []} + + used_bytes = user.user_traffic.used_traffic_bytes or 0 + total_gb = round(used_bytes / (1024 ** 3), 2) + + is_exceeded = used_bytes > threshold_bytes + + traffic_info = { + 'total_gb': total_gb, + 'nodes': [], + 'threshold_gb': threshold_gb + } + + return is_exceeded, traffic_info + + except Exception as e: + logger.error(f"Ошибка проверки трафика для {user_uuid}: {e}") + return False, {'total_gb': 0, 'nodes': []} async def process_suspicious_traffic( self, db: AsyncSession, user_uuid: str, - traffic_info: Dict, + traffic_info: dict, bot ): - """ - Обрабатывает подозрительный трафик - отправляет уведомление администраторам - """ - try: - # Получаем информацию о пользователе из базы данных - user = await get_user_by_remnawave_uuid(db, user_uuid) - if not user: - logger.warning(f"Пользователь с UUID {user_uuid} не найден в базе данных") - return - - # Формируем сообщение для администраторов - total_gb = traffic_info.get('total_gb', 0) - threshold_gb = self.get_traffic_threshold_gb() - - message = ( - f"⚠️ Подозрительная активность трафика\n\n" - f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n" - f"🔑 UUID: {user_uuid}\n" - f"📊 Трафик за сутки: {total_gb} ГБ\n" - f"📈 Порог: {threshold_gb} ГБ\n" - f"🚨 Превышение: {total_gb - threshold_gb:.2f} ГБ\n\n" - ) - - # Добавляем информацию по нодам, если есть - nodes = traffic_info.get('nodes', []) - if nodes: - message += "Разбивка по нодам:\n" - for node_info in nodes[:5]: # Показываем первые 5 нод - message += f" • {node_info.get('node', 'Unknown')}: {node_info.get('gb', 0)} ГБ\n" - if len(nodes) > 5: - message += f" • и ещё {len(nodes) - 5} нод(ы)\n" - - message += f"\n⏰ Время проверки: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S UTC')}" - - # Создаем AdminNotificationService с ботом - admin_notification_service = AdminNotificationService(bot) - - # Отправляем уведомление администраторам - topic_id = self.get_suspicious_notifications_topic_id() - - await admin_notification_service.send_suspicious_traffic_notification( - message, - bot, - topic_id - ) - - logger.info( - f"✅ Уведомление о подозрительном трафике отправлено для пользователя {user.telegram_id}" - ) - - except Exception as e: - logger.error(f"❌ Ошибка при обработке подозрительного трафика для {user_uuid}: {e}") + """Отправляет уведомление о подозрительном трафике""" + violation = TrafficViolation( + user_uuid=user_uuid, + telegram_id=None, + full_name=None, + username=None, + used_traffic_gb=traffic_info.get('total_gb', 0), + threshold_gb=traffic_info.get('threshold_gb', self.get_traffic_threshold_gb()), + last_node_uuid=None, + last_node_name=None, + check_type="manual" + ) + await self._v2._send_violation_notifications([violation], bot) async def check_all_users_traffic(self, db: AsyncSession, bot): - """ - Проверяет трафик всех пользователей с активной подпиской - """ - if not self.is_traffic_monitoring_enabled(): - logger.info("Мониторинг трафика отключен, пропускаем проверку всех пользователей") - return + """Старый метод — теперь вызывает быструю проверку""" + await self._v2.run_fast_check(bot) - try: - from app.database.crud.user import get_users_with_active_subscriptions - # Получаем всех пользователей с активной подпиской - users = await get_users_with_active_subscriptions(db) - - logger.info(f"Начинаем проверку трафика для {len(users)} пользователей") - - # Проверяем трафик для каждого пользователя - for user in users: - if user.remnawave_uuid: # Проверяем только пользователей с UUID - is_exceeded, traffic_info = await self.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - - if is_exceeded: - await self.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - bot - ) - - logger.info("Завершена проверка трафика всех пользователей") - - except Exception as e: - logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}") +# Глобальные экземпляры (создаём до класса-обёртки) +traffic_monitoring_service_v2 = TrafficMonitoringServiceV2() +traffic_monitoring_scheduler_v2 = TrafficMonitoringSchedulerV2(traffic_monitoring_service_v2) class TrafficMonitoringScheduler: - """ - Класс для планирования периодических проверок трафика - """ - def __init__(self, traffic_service: TrafficMonitoringService): - self.traffic_service = traffic_service - self.check_task = None - self.is_running = False + """Обёртка для обратной совместимости — использует глобальные v2 экземпляры""" + + def __init__(self, traffic_service: TrafficMonitoringService = None): + # Используем глобальные экземпляры! + self._v2_service = traffic_monitoring_service_v2 + self._v2_scheduler = traffic_monitoring_scheduler_v2 self.bot = None - # Кэш уведомлений: {user_uuid: дата_последнего_уведомления} - self._notification_cache: Dict[str, datetime] = {} def set_bot(self, bot): - """Устанавливает экземпляр бота для отправки уведомлений""" self.bot = bot + self._v2_scheduler.set_bot(bot) def is_enabled(self) -> bool: - """Проверяет, включен ли мониторинг трафика""" - return self.traffic_service.is_traffic_monitoring_enabled() + return self._v2_service.is_fast_check_enabled() or self._v2_service.is_daily_check_enabled() def get_interval_hours(self) -> int: - """Получает интервал проверки в часах""" - return self.traffic_service.get_monitoring_interval_hours() + """Для обратной совместимости — возвращает интервал быстрой проверки в часах""" + return max(1, self._v2_service.get_fast_check_interval_seconds() // 3600) + + def get_status_info(self) -> str: + """Возвращает информацию о статусе мониторинга""" + info = [] + if self._v2_service.is_fast_check_enabled(): + interval_min = self._v2_service.get_fast_check_interval_seconds() // 60 + threshold = self._v2_service.get_fast_check_threshold_gb() + info.append(f"Быстрая: каждые {interval_min} мин, порог {threshold} ГБ") + if self._v2_service.is_daily_check_enabled(): + check_time = self._v2_service.get_daily_check_time() + threshold = self._v2_service.get_daily_threshold_gb() + time_str = check_time.strftime('%H:%M') if check_time else "00:00" + info.append(f"Суточная: в {time_str}, порог {threshold} ГБ") + return "; ".join(info) if info else "Отключен" + + async def _should_send_notification(self, user_uuid: str) -> bool: + """Для обратной совместимости""" + return await self._v2_service.should_send_notification(user_uuid) + + async def _record_notification(self, user_uuid: str): + """Для обратной совместимости""" + await self._v2_service.record_notification(user_uuid) async def start_monitoring(self): - """ - Запускает периодическую проверку трафика - """ - if self.is_running: - logger.warning("Мониторинг трафика уже запущен") - return - - if not self.is_enabled(): - logger.info("Мониторинг трафика отключен в настройках") - return - - if not self.bot: - logger.error("Бот не установлен для мониторинга трафика") - return - - self.is_running = True - interval_hours = self.get_interval_hours() - interval_seconds = interval_hours * 3600 - - logger.info(f"🚀 Запуск мониторинга трафика с интервалом {interval_hours} ч") - - # Запускаем задачу с интервалом - self.check_task = asyncio.create_task(self._periodic_check(interval_seconds)) + await self._v2_scheduler.start() def stop_monitoring(self): - """ - Останавливает периодическую проверку трафика - """ - self.is_running = False - if self.check_task: - self.check_task.cancel() - logger.info("ℹ️ Мониторинг трафика остановлен") - - def _should_send_notification(self, user_uuid: str) -> bool: - """ - Проверяет, нужно ли отправлять уведомление для пользователя. - Защита от спама: одно уведомление в сутки на пользователя. - """ - now = datetime.utcnow() - last_notification = self._notification_cache.get(user_uuid) - - if last_notification is None: - return True - - # Если прошло больше 24 часов с последнего уведомления - return (now - last_notification) > timedelta(hours=24) - - def _record_notification(self, user_uuid: str): - """Записывает факт отправки уведомления""" - self._notification_cache[user_uuid] = datetime.utcnow() - - def _cleanup_notification_cache(self): - """Очищает старые записи из кэша (старше 48 часов)""" - now = datetime.utcnow() - expired = [ - uuid for uuid, dt in self._notification_cache.items() - if (now - dt) > timedelta(hours=48) - ] - for uuid in expired: - del self._notification_cache[uuid] - if expired: - logger.debug(f"🧹 Очищено {len(expired)} старых записей из кэша уведомлений о трафике") - - async def _periodic_check(self, interval_seconds: int): - """ - Выполняет периодическую проверку трафика - """ - while self.is_running: - try: - logger.info("📊 Запуск периодической проверки трафика") - - # Очищаем старый кэш - self._cleanup_notification_cache() - - # Получаем сессию БД внутри цикла - async for db in get_db(): - try: - await self._check_all_users_traffic(db) - finally: - break - - # Ждем указанный интервал перед следующей проверкой - await asyncio.sleep(interval_seconds) - - except asyncio.CancelledError: - logger.info("Задача периодической проверки трафика отменена") - break - except Exception as e: - logger.error(f"❌ Ошибка в периодической проверке трафика: {e}") - # Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова - await asyncio.sleep(interval_seconds) - - async def _check_all_users_traffic(self, db: AsyncSession): - """ - Проверяет трафик всех пользователей с активной подпиской - """ - try: - from app.database.crud.user import get_users_with_active_subscriptions - - # Получаем всех пользователей с активной подпиской - users = await get_users_with_active_subscriptions(db) - - checked_count = 0 - exceeded_count = 0 - - logger.info(f"📊 Начинаем проверку трафика для {len(users)} пользователей") - - # Проверяем трафик для каждого пользователя - for user in users: - if user.remnawave_uuid: - is_exceeded, traffic_info = await self.traffic_service.check_user_traffic_threshold( - db, - user.remnawave_uuid, - user.telegram_id - ) - checked_count += 1 - - if is_exceeded: - exceeded_count += 1 - # Проверяем, не отправляли ли уже уведомление - if self._should_send_notification(user.remnawave_uuid): - await self.traffic_service.process_suspicious_traffic( - db, - user.remnawave_uuid, - traffic_info, - self.bot - ) - self._record_notification(user.remnawave_uuid) - else: - logger.debug( - f"⏭️ Пропуск уведомления для {user.telegram_id} — уже отправляли сегодня" - ) - - logger.info( - f"✅ Проверка трафика завершена: проверено {checked_count}, превышений {exceeded_count}" - ) - - except Exception as e: - logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}") + asyncio.create_task(self._v2_scheduler.stop()) -# Глобальные экземпляры сервисов +# Обратная совместимость traffic_monitoring_service = TrafficMonitoringService() -traffic_monitoring_scheduler = TrafficMonitoringScheduler(traffic_monitoring_service) \ No newline at end of file +traffic_monitoring_scheduler = TrafficMonitoringScheduler() diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 7cba2ae0..fa4c3e54 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -316,9 +316,12 @@ class TributeService: has_saved_cart = False # Умная автоактивация если автопокупка не сработала + activation_notification_sent = False if not auto_purchase_success: try: - await auto_activate_subscription_after_topup(session, user) + _, activation_notification_sent = await auto_activate_subscription_after_topup( + session, user, bot=self.bot, topup_amount=amount_kopeks + ) except Exception as auto_activate_error: logger.error( "Ошибка умной автоактивации для пользователя %s: %s", @@ -327,7 +330,8 @@ class TributeService: exc_info=True, ) - if has_saved_cart and self.bot: + # Отправляем уведомление только если его ещё не отправили + if has_saved_cart and self.bot and not activation_notification_sent: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts diff --git a/main.py b/main.py index 0d622271..d20cbcd4 100644 --- a/main.py +++ b/main.py @@ -589,10 +589,9 @@ async def main(): traffic_monitoring_task = asyncio.create_task( traffic_monitoring_scheduler.start_monitoring() ) - interval_hours = traffic_monitoring_scheduler.get_interval_hours() - threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY - stage.log(f"Интервал проверки: {interval_hours} ч") - stage.log(f"Порог трафика: {threshold_gb} ГБ/сутки") + # Показываем информацию о новом мониторинге v2 + status_info = traffic_monitoring_scheduler.get_status_info() + stage.log(status_info) else: traffic_monitoring_task = None stage.skip("Мониторинг трафика отключен настройками") diff --git a/tests/services/test_traffic_monitoring_redis.py b/tests/services/test_traffic_monitoring_redis.py new file mode 100644 index 00000000..30993e31 --- /dev/null +++ b/tests/services/test_traffic_monitoring_redis.py @@ -0,0 +1,380 @@ +""" +Тесты для хранения snapshot трафика в Redis. +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from app.services.traffic_monitoring_service import ( + TrafficMonitoringServiceV2, + TRAFFIC_SNAPSHOT_KEY, + TRAFFIC_SNAPSHOT_TIME_KEY, + TRAFFIC_NOTIFICATION_CACHE_KEY, +) + + +@pytest.fixture +def service(): + """Создаёт экземпляр сервиса для тестов.""" + return TrafficMonitoringServiceV2() + + +@pytest.fixture +def mock_cache(): + """Мок для cache сервиса.""" + with patch('app.services.traffic_monitoring_service.cache') as mock: + mock.set = AsyncMock(return_value=True) + mock.get = AsyncMock(return_value=None) + yield mock + + +@pytest.fixture +def sample_snapshot(): + """Пример snapshot данных.""" + return { + "uuid-1": 1073741824.0, # 1 GB + "uuid-2": 2147483648.0, # 2 GB + "uuid-3": 5368709120.0, # 5 GB + } + + +# ============== Тесты сохранения snapshot в Redis ============== + +async def test_save_snapshot_to_redis_success(service, mock_cache, sample_snapshot): + """Тест успешного сохранения snapshot в Redis.""" + mock_cache.set = AsyncMock(return_value=True) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is True + assert mock_cache.set.call_count == 2 # snapshot + time + + # Проверяем что сохранён snapshot + first_call = mock_cache.set.call_args_list[0] + assert first_call[0][0] == TRAFFIC_SNAPSHOT_KEY + assert first_call[0][1] == sample_snapshot + + +async def test_save_snapshot_to_redis_failure(service, mock_cache, sample_snapshot): + """Тест неудачного сохранения snapshot в Redis.""" + mock_cache.set = AsyncMock(return_value=False) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is False + + +async def test_save_snapshot_to_redis_exception(service, mock_cache, sample_snapshot): + """Тест обработки исключения при сохранении.""" + mock_cache.set = AsyncMock(side_effect=Exception("Redis error")) + + result = await service._save_snapshot_to_redis(sample_snapshot) + + assert result is False + + +# ============== Тесты загрузки snapshot из Redis ============== + +async def test_load_snapshot_from_redis_success(service, mock_cache, sample_snapshot): + """Тест успешной загрузки snapshot из Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service._load_snapshot_from_redis() + + assert result == sample_snapshot + mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_KEY) + + +async def test_load_snapshot_from_redis_empty(service, mock_cache): + """Тест загрузки когда snapshot отсутствует.""" + mock_cache.get = AsyncMock(return_value=None) + + result = await service._load_snapshot_from_redis() + + assert result is None + + +async def test_load_snapshot_from_redis_invalid_data(service, mock_cache): + """Тест загрузки невалидных данных.""" + mock_cache.get = AsyncMock(return_value="not a dict") + + result = await service._load_snapshot_from_redis() + + assert result is None + + +async def test_load_snapshot_from_redis_exception(service, mock_cache): + """Тест обработки исключения при загрузке.""" + mock_cache.get = AsyncMock(side_effect=Exception("Redis error")) + + result = await service._load_snapshot_from_redis() + + assert result is None + + +# ============== Тесты времени snapshot ============== + +async def test_get_snapshot_time_from_redis_success(service, mock_cache): + """Тест получения времени snapshot.""" + test_time = datetime(2024, 1, 15, 12, 30, 0) + mock_cache.get = AsyncMock(return_value=test_time.isoformat()) + + result = await service._get_snapshot_time_from_redis() + + assert result == test_time + mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_TIME_KEY) + + +async def test_get_snapshot_time_from_redis_empty(service, mock_cache): + """Тест когда время отсутствует.""" + mock_cache.get = AsyncMock(return_value=None) + + result = await service._get_snapshot_time_from_redis() + + assert result is None + + +# ============== Тесты has_snapshot ============== + +async def test_has_snapshot_redis_exists(service, mock_cache, sample_snapshot): + """Тест has_snapshot когда snapshot есть в Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service.has_snapshot() + + assert result is True + + +async def test_has_snapshot_memory_fallback(service, mock_cache): + """Тест has_snapshot с fallback на память.""" + mock_cache.get = AsyncMock(return_value=None) + + # Устанавливаем данные в память + service._memory_snapshot = {"uuid-1": 1000.0} + service._memory_snapshot_time = datetime.utcnow() + + result = await service.has_snapshot() + + assert result is True + + +async def test_has_snapshot_none(service, mock_cache): + """Тест has_snapshot когда snapshot нет нигде.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot = {} + service._memory_snapshot_time = None + + result = await service.has_snapshot() + + assert result is False + + +# ============== Тесты get_snapshot_age_minutes ============== + +async def test_get_snapshot_age_minutes_from_redis(service, mock_cache): + """Тест возраста snapshot из Redis.""" + # Snapshot создан 30 минут назад + past_time = datetime.utcnow() - timedelta(minutes=30) + mock_cache.get = AsyncMock(return_value=past_time.isoformat()) + + result = await service.get_snapshot_age_minutes() + + assert 29 <= result <= 31 # Допуск на время выполнения + + +async def test_get_snapshot_age_minutes_memory_fallback(service, mock_cache): + """Тест возраста snapshot из памяти.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot_time = datetime.utcnow() - timedelta(minutes=15) + + result = await service.get_snapshot_age_minutes() + + assert 14 <= result <= 16 + + +async def test_get_snapshot_age_minutes_no_snapshot(service, mock_cache): + """Тест возраста когда snapshot нет.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot_time = None + + result = await service.get_snapshot_age_minutes() + + assert result == float('inf') + + +# ============== Тесты _save_snapshot (с fallback) ============== + +async def test_save_snapshot_redis_success(service, mock_cache, sample_snapshot): + """Тест сохранения snapshot в Redis успешно.""" + mock_cache.set = AsyncMock(return_value=True) + + # Заполняем память чтобы проверить что она очистится + service._memory_snapshot = {"old": 123.0} + service._memory_snapshot_time = datetime.utcnow() + + result = await service._save_snapshot(sample_snapshot) + + assert result is True + assert service._memory_snapshot == {} # Память очищена + assert service._memory_snapshot_time is None + + +async def test_save_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot): + """Тест fallback на память когда Redis недоступен.""" + mock_cache.set = AsyncMock(return_value=False) + + result = await service._save_snapshot(sample_snapshot) + + assert result is True + assert service._memory_snapshot == sample_snapshot + assert service._memory_snapshot_time is not None + + +# ============== Тесты _get_current_snapshot ============== + +async def test_get_current_snapshot_from_redis(service, mock_cache, sample_snapshot): + """Тест получения snapshot из Redis.""" + mock_cache.get = AsyncMock(return_value=sample_snapshot) + + result = await service._get_current_snapshot() + + assert result == sample_snapshot + + +async def test_get_current_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot): + """Тест fallback на память.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_snapshot = sample_snapshot + + result = await service._get_current_snapshot() + + assert result == sample_snapshot + + +# ============== Тесты уведомлений ============== + +async def test_save_notification_to_redis(service, mock_cache): + """Тест сохранения времени уведомления.""" + mock_cache.set = AsyncMock(return_value=True) + + result = await service._save_notification_to_redis("uuid-123") + + assert result is True + mock_cache.set.assert_called_once() + call_args = mock_cache.set.call_args + assert "traffic:notifications:uuid-123" in call_args[0][0] + + +async def test_get_notification_time_from_redis(service, mock_cache): + """Тест получения времени уведомления.""" + test_time = datetime(2024, 1, 15, 10, 0, 0) + mock_cache.get = AsyncMock(return_value=test_time.isoformat()) + + result = await service._get_notification_time_from_redis("uuid-123") + + assert result == test_time + + +async def test_should_send_notification_no_previous(service, mock_cache): + """Тест should_send_notification когда уведомлений не было.""" + mock_cache.get = AsyncMock(return_value=None) + service._memory_notification_cache = {} + + result = await service.should_send_notification("uuid-123") + + assert result is True + + +async def test_should_send_notification_cooldown_active(service, mock_cache): + """Тест should_send_notification когда кулдаун активен.""" + # Уведомление было 5 минут назад, кулдаун 60 минут + recent_time = datetime.utcnow() - timedelta(minutes=5) + mock_cache.get = AsyncMock(return_value=recent_time.isoformat()) + + result = await service.should_send_notification("uuid-123") + + assert result is False + + +async def test_should_send_notification_cooldown_expired(service, mock_cache): + """Тест should_send_notification когда кулдаун истёк.""" + # Уведомление было 120 минут назад, кулдаун 60 минут + old_time = datetime.utcnow() - timedelta(minutes=120) + mock_cache.get = AsyncMock(return_value=old_time.isoformat()) + + result = await service.should_send_notification("uuid-123") + + assert result is True + + +async def test_record_notification_redis(service, mock_cache): + """Тест record_notification сохраняет в Redis.""" + mock_cache.set = AsyncMock(return_value=True) + + await service.record_notification("uuid-123") + + mock_cache.set.assert_called_once() + + +async def test_record_notification_fallback_to_memory(service, mock_cache): + """Тест record_notification с fallback на память.""" + mock_cache.set = AsyncMock(return_value=False) + + await service.record_notification("uuid-123") + + assert "uuid-123" in service._memory_notification_cache + + +# ============== Тесты create_initial_snapshot ============== + +async def test_create_initial_snapshot_uses_existing_redis(service, mock_cache, sample_snapshot): + """Тест что create_initial_snapshot использует существующий snapshot из Redis.""" + mock_cache.get = AsyncMock(side_effect=[ + sample_snapshot, # _load_snapshot_from_redis + (datetime.utcnow() - timedelta(minutes=10)).isoformat(), # _get_snapshot_time_from_redis + ]) + + with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users: + result = await service.create_initial_snapshot() + + # Не должен вызывать API - используем существующий snapshot + mock_get_users.assert_not_called() + assert result == len(sample_snapshot) + + +async def test_create_initial_snapshot_creates_new(service, mock_cache): + """Тест создания нового snapshot когда в Redis пусто.""" + mock_cache.get = AsyncMock(return_value=None) + mock_cache.set = AsyncMock(return_value=True) + + # Мокаем пользователей из API + mock_user = MagicMock() + mock_user.uuid = "uuid-1" + mock_user.user_traffic = MagicMock() + mock_user.user_traffic.used_traffic_bytes = 1073741824 # 1 GB + + with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users: + mock_get_users.return_value = [mock_user] + + result = await service.create_initial_snapshot() + + mock_get_users.assert_called_once() + assert result == 1 + + +# ============== Тесты cleanup_notification_cache ============== + +async def test_cleanup_notification_cache_removes_old(service, mock_cache): + """Тест очистки старых записей из памяти.""" + old_time = datetime.utcnow() - timedelta(hours=25) + recent_time = datetime.utcnow() - timedelta(hours=1) + + service._memory_notification_cache = { + "uuid-old": old_time, + "uuid-recent": recent_time, + } + + await service.cleanup_notification_cache() + + assert "uuid-old" not in service._memory_notification_cache + assert "uuid-recent" in service._memory_notification_cache From ab492f3aefde4f7f6993d3878aa368f8d5ecd9ce Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 16 Jan 2026 12:18:04 +0300 Subject: [PATCH 02/15] =?UTF-8?q?=D0=9C=D0=BE=D0=BD=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + app/config.py | 23 +++++- app/middlewares/display_name_restriction.py | 21 +++++- app/services/traffic_monitoring_service.py | 83 +++++++++++++++++---- 4 files changed, 112 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index c5071757..e013568e 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,9 @@ SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для увед TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все) TRAFFIC_IGNORED_NODES= # Исключить эти ноды +# Исключить пользователей (UUID через запятую) +TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи + # Производительность TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API diff --git a/app/config.py b/app/config.py index 2489c969..c87757f5 100644 --- a/app/config.py +++ b/app/config.py @@ -256,6 +256,7 @@ class Settings(BaseSettings): # Фильтрация по серверам (UUID нод через запятую) TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все) TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды + TRAFFIC_EXCLUDED_USER_UUIDS: str = "" # Исключить пользователей (UUID через запятую) # Параллельность и кулдаун TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей @@ -854,13 +855,31 @@ class Settings(BaseSettings): """Возвращает список UUID нод для мониторинга (пусто = все)""" if not self.TRAFFIC_MONITORED_NODES: return [] - return [n.strip() for n in self.TRAFFIC_MONITORED_NODES.split(",") if n.strip()] + # Убираем комментарии (все после #) + value = self.TRAFFIC_MONITORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] def get_traffic_ignored_nodes(self) -> List[str]: """Возвращает список UUID нод для исключения из мониторинга""" if not self.TRAFFIC_IGNORED_NODES: return [] - return [n.strip() for n in self.TRAFFIC_IGNORED_NODES.split(",") if n.strip()] + # Убираем комментарии (все после #) + value = self.TRAFFIC_IGNORED_NODES.split("#")[0].strip() + if not value: + return [] + return [n.strip() for n in value.split(",") if n.strip()] + + def get_traffic_excluded_user_uuids(self) -> List[str]: + """Возвращает список UUID пользователей для исключения из мониторинга (например, тунельные/служебные)""" + if not self.TRAFFIC_EXCLUDED_USER_UUIDS: + return [] + # Убираем комментарии (все после #) + value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split("#")[0].strip() + if not value: + return [] + return [uuid.strip().lower() for uuid in value.split(",") if uuid.strip()] def get_traffic_daily_check_time(self) -> Optional[time]: """Возвращает время суточной проверки трафика""" diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 0060737e..7cebaae9 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -137,13 +137,28 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): if any(pattern.search(lower_value) for pattern in LINK_PATTERNS): return True - if DOMAIN_OBFUSCATION_PATTERN.search(lower_value): - return True + # Проверяем обфусцированные ссылки типа "t . m e" или "т м е" + # Но НЕ блокируем если это часть обычного слова/имени + domain_match = DOMAIN_OBFUSCATION_PATTERN.search(lower_value) + if domain_match: + # Проверяем контекст: если "tme" внутри слова (с буквами с обеих сторон) - пропускаем + start_pos = domain_match.start() + end_pos = domain_match.end() + + # Проверяем символ ДО и ПОСЛЕ совпадения + has_letter_before = start_pos > 0 and lower_value[start_pos - 1].isalpha() + has_letter_after = end_pos < len(lower_value) and lower_value[end_pos].isalpha() + + # Если с ОБЕИХ сторон буквы - скорее всего это просто имя/фамилия + if not (has_letter_before and has_letter_after): + return True normalized = self._normalize_text(lower_value) collapsed = COLLAPSE_PATTERN.sub("", normalized) - if "tme" in collapsed: + # Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах) + # Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг + if re.search(r"(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)", collapsed, re.IGNORECASE): return True banned_keywords = settings.get_display_name_banned_keywords() diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index 9b583128..d2b5ff25 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -97,6 +97,9 @@ class TrafficMonitoringServiceV2: def get_ignored_nodes(self) -> List[str]: return settings.get_traffic_ignored_nodes() + def get_excluded_user_uuids(self) -> List[str]: + return settings.get_traffic_excluded_user_uuids() + def get_daily_check_time(self) -> Optional[time]: return settings.get_traffic_daily_check_time() @@ -133,7 +136,8 @@ class TrafficMonitoringServiceV2: """Загружает snapshot трафика из Redis""" try: snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY) - if snapshot_data and isinstance(snapshot_data, dict): + # ВАЖНО: пустой словарь {} - это валидный snapshot! + if snapshot_data is not None and isinstance(snapshot_data, dict): # Конвертируем обратно в float result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()} logger.debug(f"📦 Snapshot загружен из Redis: {len(result)} пользователей") @@ -176,6 +180,23 @@ class TrafficMonitoringServiceV2: logger.error(f"❌ Ошибка получения времени уведомления: {e}") return None + # ============== Работа с нодами ============== + + async def _load_nodes_cache(self): + """Загружает названия нод в кеш""" + try: + nodes = await self.remnawave_service.get_all_nodes() + self._nodes_cache = {node['uuid']: node['name'] for node in nodes if node.get('uuid') and node.get('name')} + logger.debug(f"📋 Загружено {len(self._nodes_cache)} нод в кеш") + except Exception as e: + logger.error(f"❌ Ошибка загрузки нод в кеш: {e}") + + def get_node_name(self, node_uuid: Optional[str]) -> Optional[str]: + """Возвращает название ноды по UUID из кеша""" + if not node_uuid: + return None + return self._nodes_cache.get(node_uuid) + # ============== Фильтрация по нодам ============== def should_monitor_node(self, node_uuid: Optional[str]) -> bool: @@ -274,13 +295,13 @@ class TrafficMonitoringServiceV2: async def has_snapshot(self) -> bool: """Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)""" - # Проверяем Redis + # Проверяем Redis (пустой словарь {} - это тоже валидный snapshot!) snapshot = await self._load_snapshot_from_redis() - if snapshot: + if snapshot is not None: return True # Fallback на память - return bool(self._memory_snapshot) and self._memory_snapshot_time is not None + return self._memory_snapshot_time is not None async def get_snapshot_age_minutes(self) -> float: """Возвращает возраст snapshot в минутах (Redis + fallback на память)""" @@ -328,9 +349,9 @@ class TrafficMonitoringServiceV2: Если в Redis уже есть snapshot — использует его (персистентность). Возвращает количество пользователей в snapshot. """ - # Проверяем есть ли snapshot в Redis + # Проверяем есть ли snapshot в Redis (пустой {} тоже валидный snapshot!) existing_snapshot = await self._load_snapshot_from_redis() - if existing_snapshot: + if existing_snapshot is not None: age = await self.get_snapshot_age_minutes() logger.info( f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " @@ -382,6 +403,24 @@ class TrafficMonitoringServiceV2: start_time = datetime.utcnow() is_first_run = not await self.has_snapshot() + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + + # Логируем фильтры + monitored_nodes = self.get_monitored_nodes() + ignored_nodes = self.get_ignored_nodes() + excluded_user_uuids = self.get_excluded_user_uuids() + + if monitored_nodes: + logger.info(f"🔍 Мониторим только ноды: {monitored_nodes}") + elif ignored_nodes: + logger.info(f"🚫 Игнорируем ноды: {ignored_nodes}") + else: + logger.info(f"📊 Мониторим все ноды") + + if excluded_user_uuids: + logger.info(f"🚫 Исключены пользователи: {excluded_user_uuids}") + if is_first_run: logger.info("🚀 Первый запуск быстрой проверки — создаём snapshot...") else: @@ -396,7 +435,7 @@ class TrafficMonitoringServiceV2: # Загружаем предыдущий snapshot (из Redis или памяти) previous_snapshot = await self._get_current_snapshot() - logger.debug(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей") + logger.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})") checked_users = 0 users_with_delta = 0 @@ -420,6 +459,7 @@ class TrafficMonitoringServiceV2: # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) if user.uuid not in previous_snapshot: + logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем") continue # Получаем предыдущее значение @@ -437,15 +477,22 @@ class TrafficMonitoringServiceV2: if delta_bytes < threshold_bytes: continue - logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ)") + logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)") + + # Проверяем исключённых пользователей (служебные/тунельные) + if user.uuid.lower() in excluded_user_uuids: + logger.info(f"⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)") + continue # Проверяем фильтр по нодам last_node_uuid = user_traffic.last_connected_node_uuid if not self.should_monitor_node(last_node_uuid): + logger.warning(f"⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or 'неизвестна'} не в списке мониторинга") continue # Создаём violation delta_gb = round(delta_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) violation = TrafficViolation( user_uuid=user.uuid, telegram_id=user.telegram_id, @@ -454,7 +501,7 @@ class TrafficMonitoringServiceV2: used_traffic_gb=delta_gb, # Это дельта, не общий трафик! threshold_gb=self.get_fast_check_threshold_gb(), last_node_uuid=last_node_uuid, - last_node_name=None, + last_node_name=node_name, check_type="fast" ) violations.append(violation) @@ -464,6 +511,7 @@ class TrafficMonitoringServiceV2: # Обновляем snapshot (в Redis с fallback на память) await self._save_snapshot(new_snapshot) + logger.info(f"💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей") elapsed = (datetime.utcnow() - start_time).total_seconds() @@ -495,6 +543,9 @@ class TrafficMonitoringServiceV2: logger.info("🚀 Запуск суточной проверки трафика...") start_time = datetime.utcnow() + # Загружаем кеш нод для красивых названий в уведомлениях + await self._load_nodes_cache() + violations: List[TrafficViolation] = [] threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3) @@ -537,6 +588,7 @@ class TrafficMonitoringServiceV2: return None used_gb = round(total_bytes / (1024 ** 3), 2) + node_name = self.get_node_name(last_node_uuid) return TrafficViolation( user_uuid=user.uuid, telegram_id=user.telegram_id, @@ -545,7 +597,7 @@ class TrafficMonitoringServiceV2: used_traffic_gb=used_gb, threshold_gb=self.get_daily_threshold_gb(), last_node_uuid=last_node_uuid, - last_node_name=None, + last_node_name=node_name, check_type="daily" ) @@ -594,7 +646,7 @@ class TrafficMonitoringServiceV2: for i, violation in enumerate(violations): try: if not await self.should_send_notification(violation.user_uuid): - logger.debug(f"⏭️ Кулдаун для {violation.user_uuid}, пропускаем") + logger.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)") continue # Получаем информацию о пользователе из БД @@ -632,8 +684,13 @@ class TrafficMonitoringServiceV2: f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n" ) - if violation.last_node_uuid: - message += f"\n🖥 Последняя нода: {violation.last_node_uuid}" + # Показываем название ноды и UUID + if violation.last_node_name: + message += f"\n🖥 Сервер: {violation.last_node_name}" + if violation.last_node_uuid: + message += f"\n {violation.last_node_uuid}" + elif violation.last_node_uuid: + message += f"\n🖥 Сервер: {violation.last_node_uuid}" message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC" From 07417a487733424013dd08fcc178c5d6b7ae541d Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 16 Jan 2026 14:28:39 +0300 Subject: [PATCH 03/15] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/payment/yookassa.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 4f2efeb4..f996bf61 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -782,9 +782,8 @@ class YooKassaPaymentMixin: exc_info=True, # Добавляем полный стек вызовов для отладки ) - # Отправляем уведомление пользователю (если не включен режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP, - # т.к. в этом случае уведомление будет отправлено из auto_activate_subscription_after_topup) - if getattr(self, "bot", None) and not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + # Отправляем уведомление пользователю + if getattr(self, "bot", None): try: # Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой await self._send_payment_success_notification( @@ -834,14 +833,12 @@ class YooKassaPaymentMixin: has_saved_cart = False # Умная автоактивация если автопокупка не сработала - activation_notification_sent = False if not auto_purchase_success: try: - _, activation_notification_sent = await auto_activate_subscription_after_topup( + await auto_activate_subscription_after_topup( db, user, bot=getattr(self, "bot", None), - topup_amount=payment.amount_kopeks, ) except Exception as auto_activate_error: logger.error( @@ -851,8 +848,7 @@ class YooKassaPaymentMixin: exc_info=True, ) - # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent: + if has_saved_cart and getattr(self, "bot", None): # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts From b392a99f566b4eec3acc408ed2783f532f43c79e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 15:17:02 +0300 Subject: [PATCH 04/15] =?UTF-8?q?=20=D0=98=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=B1=D0=B0=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 + app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/admin_ban_system.py | 512 +++++++++++++++++++++++++ app/cabinet/schemas/ban_system.py | 232 +++++++++++ app/config.py | 24 ++ app/external/ban_system_api.py | 357 +++++++++++++++++ 6 files changed, 1138 insertions(+) create mode 100644 app/cabinet/routes/admin_ban_system.py create mode 100644 app/cabinet/schemas/ban_system.py create mode 100644 app/external/ban_system_api.py diff --git a/.env.example b/.env.example index 7171a0aa..394787ab 100644 --- a/.env.example +++ b/.env.example @@ -705,6 +705,17 @@ APP_CONFIG_PATH=app-config.json ENABLE_DEEP_LINKS=true APP_CONFIG_CACHE_TTL=3600 +# ===== BAN SYSTEM INTEGRATION (BedolagaBan) ===== +# Интеграция с системой мониторинга банов BedolagaBan +# Включить интеграцию с Ban системой +BAN_SYSTEM_ENABLED=false +# URL API сервера Ban системы (например: http://ban-server:8000) +BAN_SYSTEM_API_URL= +# API токен для авторизации в Ban системе +BAN_SYSTEM_API_TOKEN= +# Таймаут запросов к API (секунды) +BAN_SYSTEM_REQUEST_TIMEOUT=30 + # ===== СИСТЕМА БЕКАПОВ ===== BACKUP_AUTO_ENABLED=true BACKUP_INTERVAL_HOURS=24 diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 6f99721a..1d6eb9b0 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -22,6 +22,7 @@ from .admin_wheel import router as admin_wheel_router from .admin_tariffs import router as admin_tariffs_router from .admin_servers import router as admin_servers_router from .admin_stats import router as admin_stats_router +from .admin_ban_system import router as admin_ban_system_router from .media import router as media_router # Main cabinet router @@ -53,5 +54,6 @@ router.include_router(admin_wheel_router) router.include_router(admin_tariffs_router) router.include_router(admin_servers_router) router.include_router(admin_stats_router) +router.include_router(admin_ban_system_router) __all__ = ["router"] diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py new file mode 100644 index 00000000..501c8a03 --- /dev/null +++ b/app/cabinet/routes/admin_ban_system.py @@ -0,0 +1,512 @@ +"""Admin routes for Ban System monitoring in cabinet.""" + +import logging +from typing import Optional, List, Any + +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from app.config import settings +from app.database.models import User +from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError + +from ..dependencies import get_current_admin_user +from ..schemas.ban_system import ( + BanSystemStatusResponse, + BanSystemStatsResponse, + BanUsersListResponse, + BanUserListItem, + BanUserDetailResponse, + BanUserIPInfo, + BanUserRequestLog, + BanPunishmentsListResponse, + BanPunishmentItem, + BanHistoryResponse, + BanUserRequest, + UnbanResponse, + BanNodesListResponse, + BanNodeItem, + BanAgentsListResponse, + BanAgentItem, + BanAgentsSummary, + BanTrafficViolationsResponse, + BanTrafficViolationItem, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/ban-system", tags=["Cabinet Admin Ban System"]) + + +def _get_ban_api() -> BanSystemAPI: + """Get Ban System API instance.""" + if not settings.is_ban_system_enabled(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Ban System integration is disabled", + ) + + if not settings.is_ban_system_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Ban System is not configured", + ) + + return BanSystemAPI( + base_url=settings.get_ban_system_api_url(), + api_token=settings.get_ban_system_api_token(), + timeout=settings.get_ban_system_request_timeout(), + ) + + +async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any: + """Execute API request with error handling.""" + try: + async with api: + func = getattr(api, method) + return await func(*args, **kwargs) + except BanSystemAPIError as e: + logger.error(f"Ban System API error: {e}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Ban System API error: {e.message}", + ) + except Exception as e: + logger.error(f"Ban System unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal error: {str(e)}", + ) + + +# === Status === + +@router.get("/status", response_model=BanSystemStatusResponse) +async def get_ban_system_status( + admin: User = Depends(get_current_admin_user), +) -> BanSystemStatusResponse: + """Get Ban System integration status.""" + return BanSystemStatusResponse( + enabled=settings.is_ban_system_enabled(), + configured=settings.is_ban_system_configured(), + ) + + +# === Stats === + +@router.get("/stats", response_model=BanSystemStatsResponse) +async def get_stats( + admin: User = Depends(get_current_admin_user), +) -> BanSystemStatsResponse: + """Get overall Ban System statistics.""" + api = _get_ban_api() + data = await _api_request(api, "get_stats") + + return BanSystemStatsResponse( + total_users=data.get("total_users", 0), + active_users=data.get("active_users", 0), + users_over_limit=data.get("users_over_limit", 0), + total_requests=data.get("total_requests", 0), + total_punishments=data.get("total_punishments", 0), + active_punishments=data.get("active_punishments", 0), + nodes_online=data.get("nodes_online", 0), + nodes_total=data.get("nodes_total", 0), + agents_online=data.get("agents_online", 0), + agents_total=data.get("agents_total", 0), + panel_connected=data.get("panel_connected", False), + uptime_seconds=data.get("uptime_seconds"), + ) + + +# === Users === + +@router.get("/users", response_model=BanUsersListResponse) +async def get_users( + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + status: Optional[str] = Query(None, description="Filter: over_limit, with_limit, unlimited"), + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Get list of users from Ban System.""" + api = _get_ban_api() + data = await _api_request(api, "get_users", offset=offset, limit=limit, status=status) + + users = [] + for user_data in data.get("users", []): + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=user_data.get("is_over_limit", False), + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=data.get("total", len(users)), + offset=offset, + limit=limit, + ) + + +@router.get("/users/over-limit", response_model=BanUsersListResponse) +async def get_users_over_limit( + limit: int = Query(50, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Get users who exceeded their device limit.""" + api = _get_ban_api() + data = await _api_request(api, "get_users_over_limit", limit=limit) + + users = [] + for user_data in data.get("users", []): + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=True, + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=len(users), + offset=0, + limit=limit, + ) + + +@router.get("/users/search/{query}") +async def search_users( + query: str, + admin: User = Depends(get_current_admin_user), +) -> BanUsersListResponse: + """Search for users.""" + api = _get_ban_api() + data = await _api_request(api, "search_users", query=query) + + users = [] + users_data = data.get("users", []) if isinstance(data, dict) else data + for user_data in users_data: + users.append(BanUserListItem( + email=user_data.get("email", ""), + unique_ip_count=user_data.get("unique_ip_count", 0), + total_requests=user_data.get("total_requests", 0), + limit=user_data.get("limit"), + is_over_limit=user_data.get("is_over_limit", False), + blocked_count=user_data.get("blocked_count", 0), + )) + + return BanUsersListResponse( + users=users, + total=len(users), + offset=0, + limit=100, + ) + + +@router.get("/users/{email}", response_model=BanUserDetailResponse) +async def get_user_detail( + email: str, + admin: User = Depends(get_current_admin_user), +) -> BanUserDetailResponse: + """Get detailed user information.""" + api = _get_ban_api() + data = await _api_request(api, "get_user", email=email) + + ips = [] + for ip_data in data.get("ips", {}).values() if isinstance(data.get("ips"), dict) else data.get("ips", []): + ips.append(BanUserIPInfo( + ip=ip_data.get("ip", ""), + first_seen=ip_data.get("first_seen"), + last_seen=ip_data.get("last_seen"), + node=ip_data.get("node"), + request_count=ip_data.get("request_count", 0), + country_code=ip_data.get("country_code"), + country_name=ip_data.get("country_name"), + city=ip_data.get("city"), + )) + + recent_requests = [] + for req_data in data.get("recent_requests", []): + recent_requests.append(BanUserRequestLog( + timestamp=req_data.get("timestamp"), + source_ip=req_data.get("source_ip", ""), + destination=req_data.get("destination"), + dest_port=req_data.get("dest_port"), + protocol=req_data.get("protocol"), + action=req_data.get("action"), + node=req_data.get("node"), + )) + + return BanUserDetailResponse( + email=data.get("email", email), + unique_ip_count=data.get("unique_ip_count", 0), + total_requests=data.get("total_requests", 0), + limit=data.get("limit"), + is_over_limit=data.get("is_over_limit", False), + blocked_count=data.get("blocked_count", 0), + ips=ips, + recent_requests=recent_requests, + network_type=data.get("network_type"), + ) + + +# === Punishments === + +@router.get("/punishments", response_model=BanPunishmentsListResponse) +async def get_punishments( + admin: User = Depends(get_current_admin_user), +) -> BanPunishmentsListResponse: + """Get list of active punishments (bans).""" + api = _get_ban_api() + data = await _api_request(api, "get_punishments") + + punishments = [] + punishments_data = data if isinstance(data, list) else data.get("punishments", []) + for p in punishments_data: + punishments.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanPunishmentsListResponse( + punishments=punishments, + total=len(punishments), + ) + + +@router.post("/punishments/{user_id}/unban", response_model=UnbanResponse) +async def unban_user( + user_id: str, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Unban (enable) a user.""" + api = _get_ban_api() + try: + await _api_request(api, "enable_user", user_id=user_id) + logger.info(f"Admin {admin.id} unbanned user {user_id} in Ban System") + return UnbanResponse(success=True, message="User unbanned successfully") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.post("/ban", response_model=UnbanResponse) +async def ban_user( + request: BanUserRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Manually ban a user.""" + api = _get_ban_api() + try: + await _api_request( + api, + "ban_user", + username=request.username, + minutes=request.minutes, + reason=request.reason, + ) + logger.info(f"Admin {admin.id} banned user {request.username}: {request.reason}") + return UnbanResponse(success=True, message="User banned successfully") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.get("/history/{query}", response_model=BanHistoryResponse) +async def get_punishment_history( + query: str, + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanHistoryResponse: + """Get punishment history for a user.""" + api = _get_ban_api() + data = await _api_request(api, "get_punishment_history", query=query, limit=limit) + + items = [] + history_data = data if isinstance(data, list) else data.get("items", []) + for p in history_data: + items.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanHistoryResponse( + items=items, + total=len(items), + ) + + +# === Nodes === + +@router.get("/nodes", response_model=BanNodesListResponse) +async def get_nodes( + admin: User = Depends(get_current_admin_user), +) -> BanNodesListResponse: + """Get list of connected nodes.""" + api = _get_ban_api() + data = await _api_request(api, "get_nodes") + + nodes = [] + nodes_data = data if isinstance(data, list) else data.get("nodes", []) + online_count = 0 + for n in nodes_data: + is_connected = n.get("is_connected", False) + if is_connected: + online_count += 1 + nodes.append(BanNodeItem( + name=n.get("name", ""), + address=n.get("address"), + is_connected=is_connected, + last_seen=n.get("last_seen"), + users_count=n.get("users_count", 0), + agent_stats=n.get("agent_stats"), + )) + + return BanNodesListResponse( + nodes=nodes, + total=len(nodes), + online=online_count, + ) + + +# === Agents === + +@router.get("/agents", response_model=BanAgentsListResponse) +async def get_agents( + search: Optional[str] = Query(None), + health: Optional[str] = Query(None, description="healthy, warning, critical"), + agent_status: Optional[str] = Query(None, alias="status", description="online, offline"), + admin: User = Depends(get_current_admin_user), +) -> BanAgentsListResponse: + """Get list of monitoring agents.""" + api = _get_ban_api() + data = await _api_request( + api, + "get_agents", + search=search, + health=health, + status=agent_status, + ) + + agents = [] + agents_data = data.get("agents", []) if isinstance(data, dict) else data + online_count = 0 + for a in agents_data: + is_online = a.get("is_online", False) + if is_online: + online_count += 1 + agents.append(BanAgentItem( + node_name=a.get("node_name", ""), + sent_total=a.get("sent_total", 0), + dropped_total=a.get("dropped_total", 0), + batches_total=a.get("batches_total", 0), + reconnects=a.get("reconnects", 0), + failures=a.get("failures", 0), + queue_size=a.get("queue_size", 0), + queue_max=a.get("queue_max", 0), + dedup_checked=a.get("dedup_checked", 0), + dedup_skipped=a.get("dedup_skipped", 0), + filter_checked=a.get("filter_checked", 0), + filter_filtered=a.get("filter_filtered", 0), + health=a.get("health", "unknown"), + is_online=is_online, + last_report=a.get("last_report"), + )) + + summary = None + if isinstance(data, dict) and "summary" in data: + s = data["summary"] + summary = BanAgentsSummary( + total_agents=s.get("total_agents", len(agents)), + online_agents=s.get("online_agents", online_count), + total_sent=s.get("total_sent", 0), + total_dropped=s.get("total_dropped", 0), + avg_queue_size=s.get("avg_queue_size", 0.0), + healthy_count=s.get("healthy_count", 0), + warning_count=s.get("warning_count", 0), + critical_count=s.get("critical_count", 0), + ) + + return BanAgentsListResponse( + agents=agents, + summary=summary, + total=len(agents), + online=online_count, + ) + + +@router.get("/agents/summary", response_model=BanAgentsSummary) +async def get_agents_summary( + admin: User = Depends(get_current_admin_user), +) -> BanAgentsSummary: + """Get agents summary statistics.""" + api = _get_ban_api() + data = await _api_request(api, "get_agents_summary") + + return BanAgentsSummary( + total_agents=data.get("total_agents", 0), + online_agents=data.get("online_agents", 0), + total_sent=data.get("total_sent", 0), + total_dropped=data.get("total_dropped", 0), + avg_queue_size=data.get("avg_queue_size", 0.0), + healthy_count=data.get("healthy_count", 0), + warning_count=data.get("warning_count", 0), + critical_count=data.get("critical_count", 0), + ) + + +# === Traffic Violations === + +@router.get("/traffic/violations", response_model=BanTrafficViolationsResponse) +async def get_traffic_violations( + limit: int = Query(50, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanTrafficViolationsResponse: + """Get list of traffic limit violations.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic_violations", limit=limit) + + violations = [] + violations_data = data if isinstance(data, list) else data.get("violations", []) + for v in violations_data: + violations.append(BanTrafficViolationItem( + id=v.get("id"), + username=v.get("username", ""), + email=v.get("email"), + violation_type=v.get("violation_type", v.get("type", "")), + description=v.get("description"), + bytes_used=v.get("bytes_used", 0), + bytes_limit=v.get("bytes_limit", 0), + detected_at=v.get("detected_at"), + resolved=v.get("resolved", False), + )) + + return BanTrafficViolationsResponse( + violations=violations, + total=len(violations), + ) diff --git a/app/cabinet/schemas/ban_system.py b/app/cabinet/schemas/ban_system.py new file mode 100644 index 00000000..2e84cdbc --- /dev/null +++ b/app/cabinet/schemas/ban_system.py @@ -0,0 +1,232 @@ +"""Schemas for Ban System integration in cabinet.""" + +from datetime import datetime +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field + + +# === Status === + +class BanSystemStatusResponse(BaseModel): + """Ban System integration status.""" + enabled: bool + configured: bool + + +# === Stats === + +class BanSystemStatsResponse(BaseModel): + """Overall Ban System statistics.""" + total_users: int = 0 + active_users: int = 0 + users_over_limit: int = 0 + total_requests: int = 0 + total_punishments: int = 0 + active_punishments: int = 0 + nodes_online: int = 0 + nodes_total: int = 0 + agents_online: int = 0 + agents_total: int = 0 + panel_connected: bool = False + uptime_seconds: Optional[int] = None + + +# === Users === + +class BanUserIPInfo(BaseModel): + """User IP address information.""" + ip: str + first_seen: Optional[datetime] = None + last_seen: Optional[datetime] = None + node: Optional[str] = None + request_count: int = 0 + country_code: Optional[str] = None + country_name: Optional[str] = None + city: Optional[str] = None + + +class BanUserRequestLog(BaseModel): + """User request log entry.""" + timestamp: datetime + source_ip: str + destination: Optional[str] = None + dest_port: Optional[int] = None + protocol: Optional[str] = None + action: Optional[str] = None + node: Optional[str] = None + + +class BanUserListItem(BaseModel): + """User in the list.""" + email: str + unique_ip_count: int = 0 + total_requests: int = 0 + limit: Optional[int] = None + is_over_limit: bool = False + blocked_count: int = 0 + last_seen: Optional[datetime] = None + + +class BanUsersListResponse(BaseModel): + """Paginated list of users.""" + users: List[BanUserListItem] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 + + +class BanUserDetailResponse(BaseModel): + """Detailed user information.""" + email: str + unique_ip_count: int = 0 + total_requests: int = 0 + limit: Optional[int] = None + is_over_limit: bool = False + blocked_count: int = 0 + ips: List[BanUserIPInfo] = [] + recent_requests: List[BanUserRequestLog] = [] + network_type: Optional[str] = None # wifi, mobile, mixed + + +# === Punishments (Bans) === + +class BanPunishmentItem(BaseModel): + """Punishment/ban entry.""" + id: Optional[int] = None + user_id: str + uuid: Optional[str] = None + username: str + reason: Optional[str] = None + punished_at: datetime + enable_at: Optional[datetime] = None + ip_count: int = 0 + limit: int = 0 + enabled: bool = False + enabled_at: Optional[datetime] = None + node_name: Optional[str] = None + + +class BanPunishmentsListResponse(BaseModel): + """List of active punishments.""" + punishments: List[BanPunishmentItem] = [] + total: int = 0 + + +class BanHistoryResponse(BaseModel): + """Punishment history.""" + items: List[BanPunishmentItem] = [] + total: int = 0 + + +class BanUserRequest(BaseModel): + """Request to ban a user.""" + username: str = Field(..., min_length=1) + minutes: int = Field(default=30, ge=1) + reason: Optional[str] = Field(None, max_length=500) + + +class UnbanResponse(BaseModel): + """Unban response.""" + success: bool + message: str + + +# === Nodes === + +class BanNodeItem(BaseModel): + """Node information.""" + name: str + address: Optional[str] = None + is_connected: bool = False + last_seen: Optional[datetime] = None + users_count: int = 0 + agent_stats: Optional[Dict[str, Any]] = None + + +class BanNodesListResponse(BaseModel): + """List of nodes.""" + nodes: List[BanNodeItem] = [] + total: int = 0 + online: int = 0 + + +# === Agents === + +class BanAgentItem(BaseModel): + """Monitoring agent information.""" + node_name: str + sent_total: int = 0 + dropped_total: int = 0 + batches_total: int = 0 + reconnects: int = 0 + failures: int = 0 + queue_size: int = 0 + queue_max: int = 0 + dedup_checked: int = 0 + dedup_skipped: int = 0 + filter_checked: int = 0 + filter_filtered: int = 0 + health: str = "unknown" # healthy, warning, critical + is_online: bool = False + last_report: Optional[datetime] = None + + +class BanAgentsSummary(BaseModel): + """Agents summary statistics.""" + total_agents: int = 0 + online_agents: int = 0 + total_sent: int = 0 + total_dropped: int = 0 + avg_queue_size: float = 0.0 + healthy_count: int = 0 + warning_count: int = 0 + critical_count: int = 0 + + +class BanAgentsListResponse(BaseModel): + """List of agents.""" + agents: List[BanAgentItem] = [] + summary: Optional[BanAgentsSummary] = None + total: int = 0 + online: int = 0 + + +# === Traffic === + +class BanTrafficStats(BaseModel): + """Traffic statistics.""" + total_bytes: int = 0 + upload_bytes: int = 0 + download_bytes: int = 0 + total_users: int = 0 + violators_count: int = 0 + + +class BanTrafficUserItem(BaseModel): + """User traffic information.""" + username: str + email: Optional[str] = None + total_bytes: int = 0 + upload_bytes: int = 0 + download_bytes: int = 0 + limit_bytes: Optional[int] = None + is_over_limit: bool = False + + +class BanTrafficViolationItem(BaseModel): + """Traffic limit violation entry.""" + id: Optional[int] = None + username: str + email: Optional[str] = None + violation_type: str + description: Optional[str] = None + bytes_used: int = 0 + bytes_limit: int = 0 + detected_at: datetime + resolved: bool = False + + +class BanTrafficViolationsResponse(BaseModel): + """List of traffic violations.""" + violations: List[BanTrafficViolationItem] = [] + total: int = 0 diff --git a/app/config.py b/app/config.py index 598c4b22..6ade735e 100644 --- a/app/config.py +++ b/app/config.py @@ -656,6 +656,12 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = "VPN Service" SMTP_USE_TLS: bool = True + # Ban System Integration (BedolagaBan monitoring) + BAN_SYSTEM_ENABLED: bool = False + BAN_SYSTEM_API_URL: Optional[str] = None # e.g., http://ban-server:8000 + BAN_SYSTEM_API_TOKEN: Optional[str] = None + BAN_SYSTEM_REQUEST_TIMEOUT: int = 30 + @field_validator('MAIN_MENU_MODE', mode='before') @classmethod def normalize_main_menu_mode(cls, value: Optional[str]) -> str: @@ -2334,6 +2340,24 @@ class Settings(BaseSettings): return self.SMTP_FROM_EMAIL return self.SMTP_USER + # Ban System helpers + def is_ban_system_enabled(self) -> bool: + return bool(self.BAN_SYSTEM_ENABLED) + + def is_ban_system_configured(self) -> bool: + return bool(self.BAN_SYSTEM_API_URL and self.BAN_SYSTEM_API_TOKEN) + + def get_ban_system_api_url(self) -> Optional[str]: + if self.BAN_SYSTEM_API_URL: + return self.BAN_SYSTEM_API_URL.rstrip('/') + return None + + def get_ban_system_api_token(self) -> Optional[str]: + return self.BAN_SYSTEM_API_TOKEN + + def get_ban_system_request_timeout(self) -> int: + return max(1, self.BAN_SYSTEM_REQUEST_TIMEOUT) + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", diff --git a/app/external/ban_system_api.py b/app/external/ban_system_api.py new file mode 100644 index 00000000..6cda7f8f --- /dev/null +++ b/app/external/ban_system_api.py @@ -0,0 +1,357 @@ +""" +Ban System API Client. + +Client for interacting with the BedolagaBan monitoring system. +""" +import asyncio +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +import aiohttp + +logger = logging.getLogger(__name__) + + +class BanSystemAPIError(Exception): + """Ban System API error.""" + + def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[dict] = None): + self.message = message + self.status_code = status_code + self.response_data = response_data + super().__init__(self.message) + + +class BanSystemAPI: + """HTTP client for Ban System API.""" + + def __init__(self, base_url: str, api_token: str, timeout: int = 30): + self.base_url = base_url.rstrip('/') + self.api_token = api_token + self.timeout = aiohttp.ClientTimeout(total=timeout) + self.session: Optional[aiohttp.ClientSession] = None + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with authorization.""" + return { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + async def __aenter__(self): + """Async context manager entry.""" + self.session = aiohttp.ClientSession( + timeout=self.timeout, + headers=self._get_headers() + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + if self.session: + await self.session.close() + self.session = None + + async def _ensure_session(self): + """Ensure session is created.""" + if self.session is None: + self.session = aiohttp.ClientSession( + timeout=self.timeout, + headers=self._get_headers() + ) + + async def _request( + self, + method: str, + endpoint: str, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + ) -> Any: + """Execute HTTP request.""" + await self._ensure_session() + + url = f"{self.base_url}{endpoint}" + + try: + async with self.session.request( + method=method, + url=url, + params=params, + json=json_data, + ) as response: + response_text = await response.text() + + if response.status >= 400: + logger.error(f"Ban System API error: {response.status} - {response_text}") + raise BanSystemAPIError( + message=f"API error {response.status}: {response_text}", + status_code=response.status, + response_data={"error": response_text} + ) + + if response_text: + try: + return await response.json() + except Exception: + return {"raw": response_text} + return {} + + except aiohttp.ClientError as e: + logger.error(f"Ban System API connection error: {e}") + raise BanSystemAPIError( + message=f"Connection error: {str(e)}", + status_code=None, + response_data=None + ) + except asyncio.TimeoutError: + logger.error("Ban System API request timeout") + raise BanSystemAPIError( + message="Request timeout", + status_code=None, + response_data=None + ) + + async def close(self): + """Close the session.""" + if self.session: + await self.session.close() + self.session = None + + # === Stats === + + async def get_stats(self) -> Dict[str, Any]: + """ + Get overall system statistics. + + GET /api/stats + """ + return await self._request("GET", "/api/stats") + + async def get_stats_period(self, hours: int = 24) -> Dict[str, Any]: + """ + Get statistics for a specific period. + + GET /api/stats/period?hours={hours} + """ + return await self._request("GET", "/api/stats/period", params={"hours": hours}) + + # === Users === + + async def get_users( + self, + offset: int = 0, + limit: int = 50, + status: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Get list of users with pagination. + + GET /api/users + + Args: + offset: Pagination offset + limit: Number of users per page (max 100) + status: Filter by status (over_limit, with_limit, unlimited) + """ + params = {"offset": offset, "limit": min(limit, 100)} + if status: + params["status"] = status + return await self._request("GET", "/api/users", params=params) + + async def get_users_over_limit(self, limit: int = 50, window: bool = True) -> Dict[str, Any]: + """ + Get users who exceeded their device limit. + + GET /api/users/over-limit + """ + return await self._request( + "GET", + "/api/users/over-limit", + params={"limit": limit, "window": str(window).lower()} + ) + + async def search_users(self, query: str) -> Dict[str, Any]: + """ + Search for a user. + + GET /api/users/search/{query} + """ + return await self._request("GET", f"/api/users/search/{query}") + + async def get_user(self, email: str) -> Dict[str, Any]: + """ + Get detailed user information. + + GET /api/users/{email} + """ + return await self._request("GET", f"/api/users/{email}") + + async def get_user_network(self, email: str) -> Dict[str, Any]: + """ + Get user network information (WiFi/Mobile detection). + + GET /api/users/{email}/network + """ + return await self._request("GET", f"/api/users/{email}/network") + + # === Punishments (Bans) === + + async def get_punishments(self) -> List[Dict[str, Any]]: + """ + Get list of active punishments (bans). + + GET /api/punishments + """ + return await self._request("GET", "/api/punishments") + + async def enable_user(self, user_id: str) -> Dict[str, Any]: + """ + Enable (unban) a user. + + POST /api/punishments/{user_id}/enable + """ + return await self._request("POST", f"/api/punishments/{user_id}/enable") + + async def ban_user( + self, + username: str, + minutes: int = 30, + reason: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Manually ban a user. + + POST /api/ban + """ + params = {"username": username, "minutes": minutes} + if reason: + params["reason"] = reason + return await self._request("POST", "/api/ban", params=params) + + async def get_punishment_history(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get punishment history for a user. + + GET /api/history/{query} + """ + return await self._request( + "GET", + f"/api/history/{query}", + params={"limit": limit} + ) + + # === Nodes === + + async def get_nodes(self, include_agent_stats: bool = True) -> List[Dict[str, Any]]: + """ + Get list of connected nodes. + + GET /api/nodes + """ + return await self._request( + "GET", + "/api/nodes", + params={"include_agent_stats": str(include_agent_stats).lower()} + ) + + # === Agents === + + async def get_agents( + self, + search: Optional[str] = None, + health: Optional[str] = None, + status: Optional[str] = None, + sort_by: str = "name", + sort_order: str = "asc", + ) -> Dict[str, Any]: + """ + Get list of monitoring agents. + + GET /api/agents + + Args: + search: Search query + health: Filter by health (healthy, warning, critical) + status: Filter by status (online, offline) + sort_by: Sort by field (name, sent, dropped, health) + sort_order: Sort order (asc, desc) + """ + params = {"sort_by": sort_by, "sort_order": sort_order} + if search: + params["search"] = search + if health: + params["health"] = health + if status: + params["status"] = status + return await self._request("GET", "/api/agents", params=params) + + async def get_agents_summary(self) -> Dict[str, Any]: + """ + Get summary statistics for all agents. + + GET /api/agents/summary + """ + return await self._request("GET", "/api/agents/summary") + + async def get_agent_history( + self, + node_name: str, + hours: int = 24, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """ + Get agent statistics history. + + GET /api/agents/{node_name}/history + """ + return await self._request( + "GET", + f"/api/agents/{node_name}/history", + params={"hours": hours, "limit": limit} + ) + + # === Traffic === + + async def get_traffic(self) -> Dict[str, Any]: + """ + Get overall traffic statistics. + + GET /api/traffic + """ + return await self._request("GET", "/api/traffic") + + async def get_traffic_top(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get top users by traffic. + + GET /api/traffic/top + """ + return await self._request("GET", "/api/traffic/top", params={"limit": limit}) + + async def get_user_traffic(self, username: str) -> Dict[str, Any]: + """ + Get traffic information for a specific user. + + GET /api/traffic/user/{username} + """ + return await self._request("GET", f"/api/traffic/user/{username}") + + async def get_traffic_violations(self, limit: int = 50) -> List[Dict[str, Any]]: + """ + Get list of traffic limit violations. + + GET /api/traffic/violations + """ + return await self._request("GET", "/api/traffic/violations", params={"limit": limit}) + + # === Health === + + async def health_check(self) -> Dict[str, Any]: + """ + Check API health. + + GET /api/health + """ + return await self._request("GET", "/api/health") From 5a64dbf209a5a64ec3733594031cda4ac199d07d Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 16 Jan 2026 15:29:44 +0300 Subject: [PATCH 05/15] =?UTF-8?q?feat(payments):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20=D1=8F=D1=80=D0=BA=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D1=82=D0=B0=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализован режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP для яркого уведомления пользователей - При пополнении баланса отправляется внимание-привлекающее сообщение с восклицательными знаками - Динамические кнопки в зависимости от статуса подписки: * Активная платная подписка: "🔄 Продлить" + "📱 Изменить устройства" * Нет подписки/истекла/триал: "🔥 Активировать подписку" - Убраны дублирующие уведомления из yookassa.py (строка 851) - Убраны дублирующие уведомления из subscription_auto_purchase_service.py (строки 755, 918) - Режим включается через SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true в .env Файлы: - app/services/payment/common.py: добавлена логика яркого промпта - app/services/payment/yookassa.py: отключено старое уведомление для корзины - app/services/subscription_auto_purchase_service.py: отключены 2 блока старых уведомлений --- app/services/payment/common.py | 80 +++++++++++--- app/services/payment/yookassa.py | 101 +++++++++--------- .../subscription_auto_purchase_service.py | 26 ++++- 3 files changed, 142 insertions(+), 65 deletions(-) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 61422fe7..82831681 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -141,19 +141,75 @@ class PaymentCommonMixin: ) try: - keyboard = await self.build_topup_success_keyboard(user_snapshot) - payment_method = payment_method_title or "Банковская карта (YooKassa)" - message = ( - "✅ Платеж успешно завершен!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"💳 Способ: {payment_method}\n\n" - "Средства зачислены на ваш баланс!\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса." - ) + + # Проверяем, нужно ли показывать яркое предупреждение об активации + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + # Определяем статус подписки для выбора правильной кнопки + has_active_subscription = False + if user_snapshot: + try: + subscription = user_snapshot.subscription + has_active_subscription = bool( + subscription + and not getattr(subscription, "is_trial", False) + and getattr(subscription, "is_active", False) + ) + except Exception: + pass + + # Яркое сообщение с восклицательными знаками + message = ( + "✅ Платеж успешно завершен!\n\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + f"💳 Способ: {payment_method}\n\n" + "💎 Средства зачислены на ваш баланс!\n\n" + "‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n" + "⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n" + "👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇" + ) + + # Формируем клавиатуру с кнопками действий + keyboard_rows: list[list[InlineKeyboardButton]] = [] + + # Кнопка активации или продления в зависимости от статуса + if has_active_subscription: + # Активная платная подписка - показываем продление и изменение устройств + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="🔄 ПРОДЛИТЬ ПОДПИСКУ", + callback_data="subscription_extend", + ) + ]) + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="📱 Изменить количество устройств", + callback_data="subscription_change_devices", + ) + ]) + else: + # Нет подписки или истекла - показываем только активацию + keyboard_rows.append([ + build_miniapp_or_callback_button( + text="🔥 АКТИВИРОВАТЬ ПОДПИСКУ", + callback_data="menu_buy", + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + else: + # Стандартное сообщение с полной клавиатурой + keyboard = await self.build_topup_success_keyboard(user_snapshot) + message = ( + "✅ Платеж успешно завершен!\n\n" + f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" + f"💳 Способ: {payment_method}\n\n" + "Средства зачислены на ваш баланс!\n\n" + "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " + "Обязательно активируйте подписку отдельно!\n\n" + f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " + f"подписка будет приобретена автоматически после пополнения баланса." + ) await self.bot.send_message( chat_id=telegram_id, diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index f996bf61..34204e53 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -848,58 +848,61 @@ class YooKassaPaymentMixin: exc_info=True, ) - if has_saved_cart and getattr(self, "bot", None): - # Если у пользователя есть сохраненная корзина, - # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts - from aiogram import types + # Если включен яркий промпт активации, пропускаем старое уведомление + # т.к. оно будет отправлено через _send_payment_success_notification + if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление с кнопкой вернуться к оформлению + from app.localization.texts import get_texts + from aiogram import types - texts = get_texts(user.language) - cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( - total_amount=settings.format_price(payment.amount_kopeks) - ) + texts = get_texts(user.language) + cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( + total_amount=settings.format_price(payment.amount_kopeks) + ) - # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", - ) - ], - [ - types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", - ) - ], - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", - ) - ], - ] - ) + # Создаем клавиатуру с кнопками + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="return_to_saved_cart", + ) + ], + [ + types.InlineKeyboardButton( + text="💰 Мой баланс", + callback_data="menu_balance", + ) + ], + [ + types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu", + ) + ], + ] + ) - await self.bot.send_message( - chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", - reply_markup=keyboard, - ) - logger.info( - f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" - ) - else: - logger.info( - "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", - user.id, - ) + await self.bot.send_message( + chat_id=user.telegram_id, + text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" + f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " + f"Обязательно активируйте подписку отдельно!\n\n" + f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " + f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", + reply_markup=keyboard, + ) + logger.info( + f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" + ) + else: + logger.info( + "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", + user.id, + ) except Exception as e: logger.error( f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index e2a22243..a3aac239 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -754,9 +754,18 @@ async def auto_activate_subscription_after_topup( # Если автоактивация отключена - только отправляем уведомление if not settings.is_auto_activate_after_topup_enabled(): + # Если включен яркий промпт активации, НЕ отправляем уведомление здесь + # т.к. оно будет отправлено через _send_payment_success_notification + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + logger.info( + "⚠️ Пропущена отправка уведомления пользователю %s (SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true, уведомление будет из payment service)", + user.telegram_id, + ) + return (False, False) + + # Старая логика уведомлений для режима без яркого промпта notification_sent = False - # Отправляем уведомление если включен режим - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot: + if bot: try: texts = get_texts(getattr(user, "language", "ru")) has_active_subscription = ( @@ -912,9 +921,18 @@ async def auto_activate_subscription_after_topup( user.telegram_id, balance, ) + # Если включен яркий промпт активации, НЕ отправляем уведомление здесь + # т.к. оно будет отправлено через _send_payment_success_notification + if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: + logger.info( + "⚠️ Пропущена отправка уведомления пользователю %s (SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true, уведомление будет из payment service)", + user.telegram_id, + ) + return (False, False) + + # Старая логика уведомлений для режима без яркого промпта notification_sent = False - # Отправляем уведомление если включен режим - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot: + if bot: try: texts = get_texts(getattr(user, "language", "ru")) has_active_subscription = ( From 94cd06302a8271ca01655cd426d047ee85c2ccdd Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 16 Jan 2026 15:58:22 +0300 Subject: [PATCH 06/15] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=D1=8B=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subscription_auto_purchase_service.py | 187 ++---------------- app/services/system_settings_service.py | 70 +++++++ 2 files changed, 82 insertions(+), 175 deletions(-) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index a3aac239..57ae9316 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -752,95 +752,13 @@ async def auto_activate_subscription_after_topup( subscription = await get_subscription_by_user_id(db, user.id) - # Если автоактивация отключена - только отправляем уведомление + # Если автоактивация отключена - уведомление отправится из _send_payment_success_notification if not settings.is_auto_activate_after_topup_enabled(): - # Если включен яркий промпт активации, НЕ отправляем уведомление здесь - # т.к. оно будет отправлено через _send_payment_success_notification - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - logger.info( - "⚠️ Пропущена отправка уведомления пользователю %s (SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true, уведомление будет из payment service)", - user.telegram_id, - ) - return (False, False) - - # Старая логика уведомлений для режима без яркого промпта - notification_sent = False - if bot: - try: - texts = get_texts(getattr(user, "language", "ru")) - has_active_subscription = ( - subscription - and subscription.status in ("active", "ACTIVE") - ) - - # Формируем строку с суммой пополнения - topup_line = "" - if topup_amount: - topup_line = f"➕ Пополнено: {settings.format_price(topup_amount)}\n" - - # Определяем состояние подписки - is_trial = subscription and getattr(subscription, 'is_trial', False) - - if has_active_subscription and not is_trial: - # Активная платная подписка — 2 кнопки - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"{topup_line}" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="💎 Продлить подписку", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 Изменить устройства", - callback_data="subscription_change_devices", - )], - ] - ) - else: - # Триал или подписка закончилась — 1 кнопка - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"{topup_line}" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"{'━' * 20}\n\n" - f"🚨🚨🚨 ВНИМАНИЕ! 🚨🚨🚨\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"⚠️ Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Обязательно оформите подписку:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 КУПИТЬ ПОДПИСКУ", - callback_data="menu_buy", - )], - ] - ) - - await bot.send_message( - chat_id=user.telegram_id, - text=warning_message, - reply_markup=keyboard, - parse_mode="HTML", - ) - notification_sent = True - logger.info( - "⚠️ Отправлено уведомление о пополнении баланса пользователю %s (автоактивация выключена, подписка %s)", - user.telegram_id, - "активна" if has_active_subscription else "неактивна", - ) - except Exception as notify_error: - logger.warning( - "⚠️ Не удалось отправить уведомление пользователю %s: %s", - user.telegram_id, - notify_error, - ) - return (False, notification_sent) + logger.info( + "⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service", + user.telegram_id, + ) + return (False, False) # Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть) if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): @@ -921,93 +839,12 @@ async def auto_activate_subscription_after_topup( user.telegram_id, balance, ) - # Если включен яркий промпт активации, НЕ отправляем уведомление здесь - # т.к. оно будет отправлено через _send_payment_success_notification - if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - logger.info( - "⚠️ Пропущена отправка уведомления пользователю %s (SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true, уведомление будет из payment service)", - user.telegram_id, - ) - return (False, False) - - # Старая логика уведомлений для режима без яркого промпта - notification_sent = False - if bot: - try: - texts = get_texts(getattr(user, "language", "ru")) - has_active_subscription = ( - subscription - and subscription.status in ("active", "ACTIVE") - ) - - # Формируем строку с суммой пополнения - topup_line2 = "" - if topup_amount: - topup_line2 = f"➕ Пополнено: {settings.format_price(topup_amount)}\n" - - # Определяем состояние подписки - is_trial2 = subscription and getattr(subscription, 'is_trial', False) - - if has_active_subscription and not is_trial2: - # Активная платная подписка — 2 кнопки - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"{topup_line2}" - f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" - f"👇 Выберите действие:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="💎 Продлить подписку", - callback_data="subscription_extend", - )], - [InlineKeyboardButton( - text="📱 Изменить устройства", - callback_data="subscription_change_devices", - )], - ] - ) - else: - # Триал или подписка закончилась — 1 кнопка - warning_message = ( - f"✅ Баланс пополнен!\n\n" - f"{topup_line2}" - f"💳 Текущий баланс: {settings.format_price(balance)}\n\n" - f"{'━' * 20}\n\n" - f"🚨🚨🚨 ВНИМАНИЕ! 🚨🚨🚨\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"⚠️ Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Обязательно оформите подписку:" - ) - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton( - text="🚀 КУПИТЬ ПОДПИСКУ", - callback_data="menu_buy", - )], - ] - ) - - await bot.send_message( - chat_id=user.telegram_id, - text=warning_message, - reply_markup=keyboard, - parse_mode="HTML", - ) - notification_sent = True - logger.info( - "⚠️ Отправлено уведомление о пополнении баланса пользователю %s (недостаточно средств, подписка %s)", - user.telegram_id, - "активна" if has_active_subscription else "неактивна", - ) - except Exception as notify_error: - logger.warning( - "⚠️ Не удалось отправить уведомление пользователю %s: %s", - user.telegram_id, - notify_error, - ) - return (False, notification_sent) + # Уведомление отправится из _send_payment_success_notification + logger.info( + "⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service", + user.telegram_id, + ) + return (False, False) texts = get_texts(getattr(user, "language", "ru")) diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 31d39da3..1f1187b0 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -259,6 +259,7 @@ class BotConfigurationService: "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT", + "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": "PAYMENT", "SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION", "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION", @@ -271,6 +272,10 @@ class BotConfigurationService: "NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS", "MONITORING_LOGS_RETENTION_DAYS": "MONITORING", "MONITORING_INTERVAL": "MONITORING", + "TRAFFIC_MONITORING_ENABLED": "MONITORING", + "TRAFFIC_MONITORING_INTERVAL_HOURS": "MONITORING", + "TRAFFIC_MONITORED_NODES": "MONITORING", + "TRAFFIC_SNAPSHOT_TTL_HOURS": "MONITORING", "ENABLE_LOGO_MODE": "INTERFACE_BRANDING", "LOGO_FILE": "INTERFACE_BRANDING", "HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION", @@ -570,6 +575,19 @@ class BotConfigurationService: "Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена." ), }, + "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": { + "description": ( + "Включает режим яркого промпта активации подписки после пополнения баланса. " + "Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками " + "и кнопками для активации/продления подписки или изменения количества устройств." + ), + "format": "Булево значение.", + "example": "true", + "warning": ( + "При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. " + "Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств)." + ), + }, "SUPPORT_TICKET_SLA_MINUTES": { "description": "Лимит времени для ответа модераторов на тикет в минутах.", "format": "Целое число от 1 до 1440.", @@ -710,6 +728,58 @@ class BotConfigurationService: "warning": "Убедитесь, что конфигурация существует в панели и содержит нужные приложения.", "dependencies": "Настроенное подключение к RemnaWave API", }, + "TRAFFIC_MONITORING_ENABLED": { + "description": ( + "Включает автоматический мониторинг трафика пользователей. " + "Система отслеживает изменения трафика (дельту) и сохраняет snapshot в Redis. " + "При превышении порогов отправляются уведомления пользователям и админам." + ), + "format": "Булево значение.", + "example": "true", + "warning": ( + "Требует настроенного подключения к Redis. " + "При включении будет запущен фоновый мониторинг трафика по расписанию." + ), + "dependencies": "Redis, TRAFFIC_MONITORING_INTERVAL_HOURS, TRAFFIC_SNAPSHOT_TTL_HOURS", + }, + "TRAFFIC_MONITORING_INTERVAL_HOURS": { + "description": ( + "Интервал проверки трафика в часах. " + "Каждые N часов система проверяет трафик всех активных пользователей и сравнивает с предыдущим snapshot." + ), + "format": "Целое число часов (минимум 1).", + "example": "24", + "warning": ( + "Слишком маленький интервал может создать большую нагрузку на RemnaWave API. " + "Рекомендуется 24 часа для ежедневного мониторинга." + ), + "dependencies": "TRAFFIC_MONITORING_ENABLED", + }, + "TRAFFIC_MONITORED_NODES": { + "description": ( + "Список UUID нод для мониторинга трафика через запятую. " + "Если пусто - мониторятся все ноды. " + "Позволяет ограничить мониторинг только определенными серверами." + ), + "format": "UUID через запятую или пусто для всех нод.", + "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba, a1b2c3d4-5678-90ab-cdef-1234567890ab", + "warning": "UUID должны существовать в RemnaWave, иначе мониторинг не будет работать.", + "dependencies": "TRAFFIC_MONITORING_ENABLED", + }, + "TRAFFIC_SNAPSHOT_TTL_HOURS": { + "description": ( + "Время жизни (TTL) snapshot трафика в Redis в часах. " + "Snapshot используется для вычисления дельты (изменения трафика) между проверками. " + "После истечения TTL snapshot удаляется и создается новый." + ), + "format": "Целое число часов (минимум 1).", + "example": "24", + "warning": ( + "TTL должен быть >= интервала мониторинга. " + "Если TTL меньше интервала, snapshot будет удален до следующей проверки." + ), + "dependencies": "TRAFFIC_MONITORING_ENABLED, Redis", + }, } @classmethod From 0ea107676150c8adfd7615c2fce01a7e607535d5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 16 Jan 2026 17:36:28 +0300 Subject: [PATCH 07/15] Update subscription.py --- app/cabinet/routes/subscription.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 3a102fc1..ad8d1f44 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1669,10 +1669,22 @@ KNOWN_APP_URL_SCHEMES = { "clash": "clash://install-config?url=", "clash meta": "clash://install-config?url=", "clash verge": "clash://install-config?url=", + "clashx": "clash://install-config?url=", + "clashx meta": "clash://install-config?url=", + "flclash": "clash://install-config?url=", + "flclashx": "clash://install-config?url=", "hiddify": "hiddify://import/", + "hiddify next": "hiddify://import/", "nekoray": "sn://subscription?url=", "nekobox": "sn://subscription?url=", "karing": "karing://add/", + "sing-box": "sing-box://import-remote-profile?url=", + "singbox": "sing-box://import-remote-profile?url=", + "quantumult x": "quantumult-x://add-resource?remote-resource=", + "quantumultx": "quantumult-x://add-resource?remote-resource=", + "surge": "surge3://install-config?url=", + "loon": "loon://import?sub=", + "stash": "stash://install-config?url=", } @@ -1719,7 +1731,7 @@ def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]: "id": app.get("name", "").lower().replace(" ", "-"), "name": app.get("name", ""), "isFeatured": app.get("featured", False), - "urlScheme": app.get("urlScheme", ""), + "urlScheme": url_scheme, # Use resolved url_scheme (with fallback from app name) "isNeedBase64Encoding": app.get("isNeedBase64Encoding", False), "installationStep": installation_step, "addSubscriptionStep": subscription_step, From b9801614f3ff66fe454e00946b79d8aec314306d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 16 Jan 2026 17:51:28 +0300 Subject: [PATCH 08/15] Update subscription.py --- app/cabinet/routes/subscription.py | 46 +++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index ad8d1f44..aa069715 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1661,30 +1661,48 @@ def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = "" # Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme) KNOWN_APP_URL_SCHEMES = { + # iOS "happ": "happ://add/", "streisand": "streisand://import/", "shadowrocket": "sub://", - "v2rayn": "v2rayng://install-config?url=", - "v2rayng": "v2rayng://install-config?url=", - "clash": "clash://install-config?url=", - "clash meta": "clash://install-config?url=", - "clash verge": "clash://install-config?url=", - "clashx": "clash://install-config?url=", - "clashx meta": "clash://install-config?url=", - "flclash": "clash://install-config?url=", - "flclashx": "clash://install-config?url=", - "hiddify": "hiddify://import/", - "hiddify next": "hiddify://import/", - "nekoray": "sn://subscription?url=", - "nekobox": "sn://subscription?url=", - "karing": "karing://add/", + "shadow rocket": "sub://", + "karing": "karing://install-config?url=", + "foxray": "foxray://yiguo.dev/sub/add/?url=", + "fox ray": "foxray://yiguo.dev/sub/add/?url=", + "v2box": "v2box://install-sub?url=", "sing-box": "sing-box://import-remote-profile?url=", "singbox": "sing-box://import-remote-profile?url=", "quantumult x": "quantumult-x://add-resource?remote-resource=", "quantumultx": "quantumult-x://add-resource?remote-resource=", + "quantumult": "quantumult-x://add-resource?remote-resource=", "surge": "surge3://install-config?url=", "loon": "loon://import?sub=", "stash": "stash://install-config?url=", + # Android + "v2rayn": "v2rayng://install-sub?url=", + "v2rayng": "v2rayng://install-sub?url=", + "v2ray ng": "v2rayng://install-sub?url=", + "nekoray": "sn://subscription?url=", + "nekobox": "sn://subscription?url=", + "neko ray": "sn://subscription?url=", + "neko box": "sn://subscription?url=", + "surfboard": "surfboard://install-config?url=", + # PC (Windows/macOS/Linux) + "clash": "clash://install-config?url=", + "clash meta": "clash://install-config?url=", + "clash verge": "clash://install-config?url=", + "clash verge rev": "clash://install-config?url=", + "clashx": "clashx://install-config?url=", + "clashx meta": "clash://install-config?url=", + "clashx pro": "clash://install-config?url=", + "flclash": "clash://install-config?url=", + "flclashx": "clash://install-config?url=", + "koala clash": "clash://install-config?url=", + "koalaclash": "clash://install-config?url=", + "hiddify": "hiddify://install-config/?url=", + "hiddify next": "hiddify://install-config/?url=", + "mihomo party": "clash://install-config?url=", + "mihomo": "clash://install-config?url=", } From 5eec0aa1a062e1ca03796c87ffc07c48e4635eba Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 16 Jan 2026 18:13:25 +0300 Subject: [PATCH 09/15] Update balance.py --- app/cabinet/routes/balance.py | 51 ++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 41acebbe..8aa79807 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -119,15 +119,19 @@ async def get_payment_methods(): """Get available payment methods.""" methods = [] - # YooKassa + # YooKassa - with card and SBP options if settings.is_yookassa_enabled(): methods.append(PaymentMethodResponse( id="yookassa", - name="YooKassa (Bank Card)", - description="Pay with bank card via YooKassa", + name="YooKassa", + description="Pay via YooKassa", min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, is_available=True, + options=[ + {"id": "card", "name": "💳 Карта", "description": "Банковская карта"}, + {"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей (QR)"}, + ], )) # CryptoBot @@ -378,19 +382,34 @@ async def create_topup( try: if request.payment_method == "yookassa": yookassa_service = YooKassaService() - result = await yookassa_service.create_payment( - amount=amount_rubles, - currency="RUB", - description=f"Пополнение баланса на {amount_rubles:.2f} ₽", - metadata={ - "user_id": str(user.id), - "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", - "user_username": user.username or "", - "amount_kopeks": str(request.amount_kopeks), - "type": "balance_topup", - "source": "cabinet", - }, - ) + yookassa_metadata = { + "user_id": str(user.id), + "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", + "user_username": user.username or "", + "amount_kopeks": str(request.amount_kopeks), + "type": "balance_topup", + "source": "cabinet", + } + + # Use payment_option to select card or sbp (default: card) + option = (request.payment_option or "").strip().lower() + if option == "sbp": + # Create SBP payment with QR code + result = await yookassa_service.create_sbp_payment( + amount=amount_rubles, + currency="RUB", + description=f"Пополнение баланса на {amount_rubles:.2f} ₽", + metadata=yookassa_metadata, + ) + else: + # Default: card payment + result = await yookassa_service.create_payment( + amount=amount_rubles, + currency="RUB", + description=f"Пополнение баланса на {amount_rubles:.2f} ₽", + metadata=yookassa_metadata, + ) + if result and not result.get("error"): payment_url = result.get("confirmation_url") payment_id = result.get("id") From 29d0190a0555c4abe2b5b56c7f22236bf6801a8b Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 18:41:21 +0300 Subject: [PATCH 10/15] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cabinet/routes/admin_ban_system.py | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 501c8a03..53901570 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -101,19 +101,28 @@ async def get_stats( api = _get_ban_api() data = await _api_request(api, "get_stats") + # Extract punishment stats + punishment_stats = data.get("punishment_stats") or {} + + # Extract connected nodes info + connected_nodes = data.get("connected_nodes", []) + + # Extract panel status + panel_status = data.get("panel_status") or {} + return BanSystemStatsResponse( total_users=data.get("total_users", 0), - active_users=data.get("active_users", 0), + active_users=data.get("users_with_limit", 0), users_over_limit=data.get("users_over_limit", 0), total_requests=data.get("total_requests", 0), - total_punishments=data.get("total_punishments", 0), - active_punishments=data.get("active_punishments", 0), - nodes_online=data.get("nodes_online", 0), - nodes_total=data.get("nodes_total", 0), - agents_online=data.get("agents_online", 0), - agents_total=data.get("agents_total", 0), - panel_connected=data.get("panel_connected", False), - uptime_seconds=data.get("uptime_seconds"), + total_punishments=punishment_stats.get("total_punishments", 0), + active_punishments=punishment_stats.get("active_punishments", 0), + nodes_online=len(connected_nodes), + nodes_total=len(connected_nodes), + agents_online=0, # Will be fetched from agents endpoint if needed + agents_total=0, + panel_connected=data.get("panel_loaded", False), + uptime_seconds=panel_status.get("uptime_seconds"), ) From 7ef416eff2bae349372b054ad70ab23d7bc40cf8 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 18:51:19 +0300 Subject: [PATCH 11/15] Implement logging for Ban System API checks and add endpoint for raw stats retrieval - Added logging for Ban System API status checks, including whether the system is enabled and its configured URL. - Introduced a new endpoint `/stats/raw` to fetch raw statistics from the Ban System API for debugging purposes. - Enhanced logging to capture raw stats response for better monitoring. --- app/cabinet/routes/admin_ban_system.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 53901570..62e61cf0 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -39,6 +39,9 @@ router = APIRouter(prefix="/admin/ban-system", tags=["Cabinet Admin Ban System"] def _get_ban_api() -> BanSystemAPI: """Get Ban System API instance.""" + logger.info(f"Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}") + logger.info(f"Ban System URL: {settings.get_ban_system_api_url()}") + if not settings.is_ban_system_enabled(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -93,6 +96,16 @@ async def get_ban_system_status( # === Stats === +@router.get("/stats/raw") +async def get_stats_raw( + admin: User = Depends(get_current_admin_user), +) -> dict: + """Get raw stats from Ban System API for debugging.""" + api = _get_ban_api() + data = await _api_request(api, "get_stats") + return {"raw_response": data} + + @router.get("/stats", response_model=BanSystemStatsResponse) async def get_stats( admin: User = Depends(get_current_admin_user), @@ -101,6 +114,8 @@ async def get_stats( api = _get_ban_api() data = await _api_request(api, "get_stats") + logger.info(f"Ban System raw stats: {data}") + # Extract punishment stats punishment_stats = data.get("punishment_stats") or {} From e31c9118b36b7bf23fc881cba63979d5726e82be Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 19:21:31 +0300 Subject: [PATCH 12/15] =?UTF-8?q?=E2=97=8F=20=D0=93=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE!=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BD=D0=BE=D0=B4=20=D0=B8=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cabinet/routes/admin_ban_system.py | 87 ++++++++++++++++++-------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 62e61cf0..ef5c482b 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -399,15 +399,18 @@ async def get_nodes( nodes_data = data if isinstance(data, list) else data.get("nodes", []) online_count = 0 for n in nodes_data: - is_connected = n.get("is_connected", False) + # API returns is_online, not is_connected + is_connected = n.get("is_online", n.get("is_connected", False)) if is_connected: online_count += 1 nodes.append(BanNodeItem( name=n.get("name", ""), address=n.get("address"), is_connected=is_connected, - last_seen=n.get("last_seen"), - users_count=n.get("users_count", 0), + # API returns last_heartbeat, not last_seen + last_seen=n.get("last_heartbeat", n.get("last_seen")), + # API returns unique_users, not users_count + users_count=n.get("unique_users", n.get("users_count", 0)), agent_stats=n.get("agent_stats"), )) @@ -438,29 +441,63 @@ async def get_agents( ) agents = [] - agents_data = data.get("agents", []) if isinstance(data, dict) else data + agents_data = data.get("agents", {}) if isinstance(data, dict) else data online_count = 0 - for a in agents_data: - is_online = a.get("is_online", False) - if is_online: - online_count += 1 - agents.append(BanAgentItem( - node_name=a.get("node_name", ""), - sent_total=a.get("sent_total", 0), - dropped_total=a.get("dropped_total", 0), - batches_total=a.get("batches_total", 0), - reconnects=a.get("reconnects", 0), - failures=a.get("failures", 0), - queue_size=a.get("queue_size", 0), - queue_max=a.get("queue_max", 0), - dedup_checked=a.get("dedup_checked", 0), - dedup_skipped=a.get("dedup_skipped", 0), - filter_checked=a.get("filter_checked", 0), - filter_filtered=a.get("filter_filtered", 0), - health=a.get("health", "unknown"), - is_online=is_online, - last_report=a.get("last_report"), - )) + + # API returns agents as dict: {"node_name": {stats...}, ...} + if isinstance(agents_data, dict): + for node_name, agent_info in agents_data.items(): + # Extract metrics from nested structure + stats = agent_info.get("stats", {}) or {} + metrics = stats.get("metrics", {}) or {} + sent_info = metrics.get("sent", {}) or {} + queue_info = metrics.get("queue", {}) or {} + conn_info = metrics.get("connection", {}) or {} + + is_online = agent_info.get("is_online", False) + if is_online: + online_count += 1 + + agents.append(BanAgentItem( + node_name=node_name, + sent_total=sent_info.get("total", 0), + dropped_total=sent_info.get("dropped", 0), + batches_total=sent_info.get("batches", 0), + reconnects=conn_info.get("reconnects", 0), + failures=conn_info.get("failures", sent_info.get("failed", 0)), + queue_size=queue_info.get("current", 0), + queue_max=queue_info.get("high_watermark", 0), + dedup_checked=0, + dedup_skipped=0, + filter_checked=0, + filter_filtered=0, + health=agent_info.get("health", "unknown"), + is_online=is_online, + last_report=agent_info.get("updated_at"), + )) + else: + # Fallback for list format + for a in agents_data: + is_online = a.get("is_online", False) + if is_online: + online_count += 1 + agents.append(BanAgentItem( + node_name=a.get("node_name", ""), + sent_total=a.get("sent_total", 0), + dropped_total=a.get("dropped_total", 0), + batches_total=a.get("batches_total", 0), + reconnects=a.get("reconnects", 0), + failures=a.get("failures", 0), + queue_size=a.get("queue_size", 0), + queue_max=a.get("queue_max", 0), + dedup_checked=a.get("dedup_checked", 0), + dedup_skipped=a.get("dedup_skipped", 0), + filter_checked=a.get("filter_checked", 0), + filter_filtered=a.get("filter_filtered", 0), + health=a.get("health", "unknown"), + is_online=is_online, + last_report=a.get("last_report"), + )) summary = None if isinstance(data, dict) and "summary" in data: From 723a49f1f1ab37379443cc538daea099c6560ca5 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 19:25:20 +0300 Subject: [PATCH 13/15] Fix stats mapping: uptime from tcp_metrics, agents count from connected_nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/cabinet/routes/admin_ban_system.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index ef5c482b..592334da 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -111,6 +111,8 @@ async def get_stats( admin: User = Depends(get_current_admin_user), ) -> BanSystemStatsResponse: """Get overall Ban System statistics.""" + from datetime import datetime + api = _get_ban_api() data = await _api_request(api, "get_stats") @@ -122,8 +124,19 @@ async def get_stats( # Extract connected nodes info connected_nodes = data.get("connected_nodes", []) - # Extract panel status - panel_status = data.get("panel_status") or {} + # Count online nodes/agents + nodes_online = sum(1 for n in connected_nodes if n.get("is_online", False)) + + # Extract tcp_metrics for uptime + tcp_metrics = data.get("tcp_metrics") or {} + uptime_seconds = None + intake_started = tcp_metrics.get("intake_started_at") + if intake_started: + try: + start_time = datetime.fromisoformat(intake_started.replace("Z", "+00:00")) + uptime_seconds = int((datetime.now(start_time.tzinfo) - start_time).total_seconds()) + except Exception: + pass return BanSystemStatsResponse( total_users=data.get("total_users", 0), @@ -132,12 +145,12 @@ async def get_stats( total_requests=data.get("total_requests", 0), total_punishments=punishment_stats.get("total_punishments", 0), active_punishments=punishment_stats.get("active_punishments", 0), - nodes_online=len(connected_nodes), + nodes_online=nodes_online, nodes_total=len(connected_nodes), - agents_online=0, # Will be fetched from agents endpoint if needed - agents_total=0, + agents_online=nodes_online, # Agents = connected nodes with stats + agents_total=len(connected_nodes), panel_connected=data.get("panel_loaded", False), - uptime_seconds=panel_status.get("uptime_seconds"), + uptime_seconds=uptime_seconds, ) From b0f83f353450dc3367bb9c2c0b76861e474ee8f1 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 20:51:28 +0300 Subject: [PATCH 14/15] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=B2=20AdminBanSystem:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Traffic (Трафик) - статистика трафика, топ пользователей по трафику, последние нарушения 2. Reports (Отчёты) - отчёты за период (6h, 12h, 24h, 48h, 72h), статистика активных пользователей и IP, топ нарушителей 3. Settings (Настройки) - управление настройками системы банов, группировка по категориям, переключатели для bool, ввод для int 4. Health (Здоровье) - статус системы (healthy/degraded/unhealthy), аптайм, статус компонентов --- app/cabinet/routes/admin_ban_system.py | 351 +++++++++++++++++++++++++ app/cabinet/schemas/ban_system.py | 108 ++++++++ app/external/ban_system_api.py | 62 ++++- 3 files changed, 519 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 592334da..d06130fe 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -30,6 +30,18 @@ from ..schemas.ban_system import ( BanAgentsSummary, BanTrafficViolationsResponse, BanTrafficViolationItem, + BanTrafficResponse, + BanTrafficTopItem, + BanSettingsResponse, + BanSettingDefinition, + BanWhitelistRequest, + BanReportResponse, + BanReportTopViolator, + BanHealthResponse, + BanHealthComponent, + BanHealthDetailedResponse, + BanAgentHistoryResponse, + BanAgentHistoryItem, ) logger = logging.getLogger(__name__) @@ -584,3 +596,342 @@ async def get_traffic_violations( violations=violations, total=len(violations), ) + + +# === Full Traffic Stats === + +@router.get("/traffic", response_model=BanTrafficResponse) +async def get_traffic( + admin: User = Depends(get_current_admin_user), +) -> BanTrafficResponse: + """Get full traffic statistics including top users.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic") + + top_users = [] + for u in data.get("top_users", []): + top_users.append(BanTrafficTopItem( + username=u.get("username", ""), + bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), + bytes_limit=u.get("bytes_limit"), + over_limit=u.get("over_limit", False), + )) + + violations = [] + for v in data.get("recent_violations", []): + violations.append(BanTrafficViolationItem( + id=v.get("id"), + username=v.get("username", ""), + email=v.get("email"), + violation_type=v.get("violation_type", v.get("type", "")), + description=v.get("description"), + bytes_used=v.get("bytes_used", 0), + bytes_limit=v.get("bytes_limit", 0), + detected_at=v.get("detected_at"), + resolved=v.get("resolved", False), + )) + + return BanTrafficResponse( + enabled=data.get("enabled", False), + stats=data.get("stats"), + top_users=top_users, + recent_violations=violations, + ) + + +@router.get("/traffic/top") +async def get_traffic_top( + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> List[BanTrafficTopItem]: + """Get top users by traffic.""" + api = _get_ban_api() + data = await _api_request(api, "get_traffic_top", limit=limit) + + top_users = [] + users_data = data if isinstance(data, list) else data.get("users", []) + for u in users_data: + top_users.append(BanTrafficTopItem( + username=u.get("username", ""), + bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), + bytes_limit=u.get("bytes_limit"), + over_limit=u.get("over_limit", False), + )) + + return top_users + + +# === Settings === + +@router.get("/settings", response_model=BanSettingsResponse) +async def get_settings( + admin: User = Depends(get_current_admin_user), +) -> BanSettingsResponse: + """Get all Ban System settings.""" + api = _get_ban_api() + data = await _api_request(api, "get_settings") + + settings_list = [] + settings_data = data.get("settings", {}) if isinstance(data, dict) else {} + + for key, info in settings_data.items(): + settings_list.append(BanSettingDefinition( + key=key, + value=info.get("value"), + type=info.get("type", "str"), + min_value=info.get("min"), + max_value=info.get("max"), + editable=info.get("editable", True), + description=info.get("description"), + category=info.get("category"), + )) + + return BanSettingsResponse(settings=settings_list) + + +@router.get("/settings/{key}") +async def get_setting( + key: str, + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Get a specific setting.""" + api = _get_ban_api() + data = await _api_request(api, "get_setting", key=key) + + return BanSettingDefinition( + key=key, + value=data.get("value"), + type=data.get("type", "str"), + min_value=data.get("min"), + max_value=data.get("max"), + editable=data.get("editable", True), + description=data.get("description"), + category=data.get("category"), + ) + + +@router.post("/settings/{key}") +async def set_setting( + key: str, + value: str = Query(...), + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Set a setting value.""" + api = _get_ban_api() + data = await _api_request(api, "set_setting", key=key, value=value) + + logger.info(f"Admin {admin.id} changed Ban System setting {key} to {value}") + + return BanSettingDefinition( + key=key, + value=data.get("value"), + type=data.get("type", "str"), + min_value=data.get("min"), + max_value=data.get("max"), + editable=data.get("editable", True), + description=data.get("description"), + category=data.get("category"), + ) + + +@router.post("/settings/{key}/toggle") +async def toggle_setting( + key: str, + admin: User = Depends(get_current_admin_user), +) -> BanSettingDefinition: + """Toggle a boolean setting.""" + api = _get_ban_api() + data = await _api_request(api, "toggle_setting", key=key) + + logger.info(f"Admin {admin.id} toggled Ban System setting {key}") + + return BanSettingDefinition( + key=key, + value=data.get("value"), + type=data.get("type", "bool"), + min_value=data.get("min"), + max_value=data.get("max"), + editable=data.get("editable", True), + description=data.get("description"), + category=data.get("category"), + ) + + +# === Whitelist === + +@router.post("/settings/whitelist/add", response_model=UnbanResponse) +async def whitelist_add( + request: BanWhitelistRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Add user to whitelist.""" + api = _get_ban_api() + try: + await _api_request(api, "whitelist_add", username=request.username) + logger.info(f"Admin {admin.id} added {request.username} to Ban System whitelist") + return UnbanResponse(success=True, message=f"User {request.username} added to whitelist") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +@router.post("/settings/whitelist/remove", response_model=UnbanResponse) +async def whitelist_remove( + request: BanWhitelistRequest, + admin: User = Depends(get_current_admin_user), +) -> UnbanResponse: + """Remove user from whitelist.""" + api = _get_ban_api() + try: + await _api_request(api, "whitelist_remove", username=request.username) + logger.info(f"Admin {admin.id} removed {request.username} from Ban System whitelist") + return UnbanResponse(success=True, message=f"User {request.username} removed from whitelist") + except HTTPException: + raise + except Exception as e: + return UnbanResponse(success=False, message=str(e)) + + +# === Reports === + +@router.get("/report", response_model=BanReportResponse) +async def get_report( + hours: int = Query(24, ge=1, le=168), + admin: User = Depends(get_current_admin_user), +) -> BanReportResponse: + """Get period report.""" + api = _get_ban_api() + data = await _api_request(api, "get_stats_period", hours=hours) + + top_violators = [] + punishment_stats = data.get("punishment_stats", {}) or {} + for v in punishment_stats.get("top_violators", []): + top_violators.append(BanReportTopViolator( + username=v.get("username", ""), + count=v.get("count", 0), + )) + + return BanReportResponse( + period_hours=hours, + current_users=data.get("current_users", 0), + current_ips=data.get("current_ips", 0), + punishment_stats=punishment_stats, + top_violators=top_violators, + ) + + +# === Health === + +@router.get("/health", response_model=BanHealthResponse) +async def get_health( + admin: User = Depends(get_current_admin_user), +) -> BanHealthResponse: + """Get Ban System health status.""" + api = _get_ban_api() + data = await _api_request(api, "health_check") + + components = [] + for name, info in data.get("components", {}).items(): + if isinstance(info, dict): + components.append(BanHealthComponent( + name=name, + status=info.get("status", "unknown"), + message=info.get("message"), + details=info.get("details"), + )) + else: + components.append(BanHealthComponent( + name=name, + status=str(info) if info else "unknown", + )) + + return BanHealthResponse( + status=data.get("status", "unknown"), + uptime=data.get("uptime"), + components=components, + ) + + +@router.get("/health/detailed", response_model=BanHealthDetailedResponse) +async def get_health_detailed( + admin: User = Depends(get_current_admin_user), +) -> BanHealthDetailedResponse: + """Get detailed health information.""" + api = _get_ban_api() + data = await _api_request(api, "health_detailed") + + return BanHealthDetailedResponse( + status=data.get("status", "unknown"), + uptime=data.get("uptime"), + components=data.get("components", {}), + ) + + +# === Agent History === + +@router.get("/agents/{node_name}/history", response_model=BanAgentHistoryResponse) +async def get_agent_history( + node_name: str, + hours: int = Query(24, ge=1, le=168), + admin: User = Depends(get_current_admin_user), +) -> BanAgentHistoryResponse: + """Get agent statistics history.""" + api = _get_ban_api() + data = await _api_request(api, "get_agent_history", node_name=node_name, hours=hours) + + history = [] + for item in data.get("history", []): + history.append(BanAgentHistoryItem( + timestamp=item.get("timestamp"), + sent_total=item.get("sent_total", 0), + dropped_total=item.get("dropped_total", 0), + queue_size=item.get("queue_size", 0), + batches_total=item.get("batches_total", 0), + )) + + return BanAgentHistoryResponse( + node=data.get("node", node_name), + hours=data.get("hours", hours), + records=data.get("records", len(history)), + delta=data.get("delta"), + first=data.get("first"), + last=data.get("last"), + history=history, + ) + + +# === User Punishment History === + +@router.get("/users/{email}/history", response_model=BanHistoryResponse) +async def get_user_punishment_history( + email: str, + limit: int = Query(20, ge=1, le=100), + admin: User = Depends(get_current_admin_user), +) -> BanHistoryResponse: + """Get punishment history for a specific user.""" + api = _get_ban_api() + data = await _api_request(api, "get_punishment_history", query=email, limit=limit) + + items = [] + history_data = data if isinstance(data, list) else data.get("items", []) + for p in history_data: + items.append(BanPunishmentItem( + id=p.get("id"), + user_id=p.get("user_id", ""), + uuid=p.get("uuid"), + username=p.get("username", ""), + reason=p.get("reason"), + punished_at=p.get("punished_at"), + enable_at=p.get("enable_at"), + ip_count=p.get("ip_count", 0), + limit=p.get("limit", 0), + enabled=p.get("enabled", False), + enabled_at=p.get("enabled_at"), + node_name=p.get("node_name"), + )) + + return BanHistoryResponse( + items=items, + total=len(items), + ) diff --git a/app/cabinet/schemas/ban_system.py b/app/cabinet/schemas/ban_system.py index 2e84cdbc..defdb1f9 100644 --- a/app/cabinet/schemas/ban_system.py +++ b/app/cabinet/schemas/ban_system.py @@ -230,3 +230,111 @@ class BanTrafficViolationsResponse(BaseModel): """List of traffic violations.""" violations: List[BanTrafficViolationItem] = [] total: int = 0 + + +class BanTrafficTopItem(BaseModel): + """Top user by traffic.""" + username: str + bytes_total: int = 0 + bytes_limit: Optional[int] = None + over_limit: bool = False + + +class BanTrafficResponse(BaseModel): + """Full traffic statistics response.""" + enabled: bool = False + stats: Optional[Dict[str, Any]] = None + top_users: List[BanTrafficTopItem] = [] + recent_violations: List[BanTrafficViolationItem] = [] + + +# === Settings === + +class BanSettingDefinition(BaseModel): + """Setting definition with value.""" + key: str + value: Any + type: str # bool, int, str, list + min_value: Optional[int] = None + max_value: Optional[int] = None + editable: bool = True + description: Optional[str] = None + category: Optional[str] = None + + +class BanSettingsResponse(BaseModel): + """All settings response.""" + settings: List[BanSettingDefinition] = [] + + +class BanSettingUpdateRequest(BaseModel): + """Request to update a setting.""" + value: Any + + +class BanWhitelistRequest(BaseModel): + """Request to add/remove from whitelist.""" + username: str = Field(..., min_length=1) + + +# === Reports === + +class BanReportTopViolator(BaseModel): + """Top violator in report.""" + username: str + count: int = 0 + + +class BanReportResponse(BaseModel): + """Period report response.""" + period_hours: int = 24 + current_users: int = 0 + current_ips: int = 0 + punishment_stats: Optional[Dict[str, Any]] = None + top_violators: List[BanReportTopViolator] = [] + + +# === Health === + +class BanHealthComponent(BaseModel): + """Health component status.""" + name: str + status: str # healthy, degraded, unhealthy + message: Optional[str] = None + details: Optional[Dict[str, Any]] = None + + +class BanHealthResponse(BaseModel): + """Health status response.""" + status: str # healthy, degraded, unhealthy + uptime: Optional[int] = None + components: List[BanHealthComponent] = [] + + +class BanHealthDetailedResponse(BaseModel): + """Detailed health response.""" + status: str + uptime: Optional[int] = None + components: Dict[str, Any] = {} + + +# === Agent History === + +class BanAgentHistoryItem(BaseModel): + """Agent history item.""" + timestamp: datetime + sent_total: int = 0 + dropped_total: int = 0 + queue_size: int = 0 + batches_total: int = 0 + + +class BanAgentHistoryResponse(BaseModel): + """Agent history response.""" + node: str + hours: int = 24 + records: int = 0 + delta: Optional[Dict[str, Any]] = None + first: Optional[Dict[str, Any]] = None + last: Optional[Dict[str, Any]] = None + history: List[BanAgentHistoryItem] = [] diff --git a/app/external/ban_system_api.py b/app/external/ban_system_api.py index 6cda7f8f..c8789ab6 100644 --- a/app/external/ban_system_api.py +++ b/app/external/ban_system_api.py @@ -352,6 +352,64 @@ class BanSystemAPI: """ Check API health. - GET /api/health + GET /health """ - return await self._request("GET", "/api/health") + return await self._request("GET", "/health") + + async def health_detailed(self) -> Dict[str, Any]: + """ + Get detailed health information. + + GET /health/detailed + """ + return await self._request("GET", "/health/detailed") + + # === Settings === + + async def get_settings(self) -> Dict[str, Any]: + """ + Get all settings with their definitions. + + GET /api/settings + """ + return await self._request("GET", "/api/settings") + + async def get_setting(self, key: str) -> Dict[str, Any]: + """ + Get a specific setting value. + + GET /api/settings/{key} + """ + return await self._request("GET", f"/api/settings/{key}") + + async def set_setting(self, key: str, value: Any) -> Dict[str, Any]: + """ + Set a setting value. + + POST /api/settings/{key}?value={value} + """ + return await self._request("POST", f"/api/settings/{key}", params={"value": value}) + + async def toggle_setting(self, key: str) -> Dict[str, Any]: + """ + Toggle a boolean setting. + + POST /api/settings/{key}/toggle + """ + return await self._request("POST", f"/api/settings/{key}/toggle") + + async def whitelist_add(self, username: str) -> Dict[str, Any]: + """ + Add user to whitelist. + + POST /api/settings/whitelist/add?username={username} + """ + return await self._request("POST", "/api/settings/whitelist/add", params={"username": username}) + + async def whitelist_remove(self, username: str) -> Dict[str, Any]: + """ + Remove user from whitelist. + + POST /api/settings/whitelist/remove?username={username} + """ + return await self._request("POST", "/api/settings/whitelist/remove", params={"username": username}) From 1a990bd77682c1d4ab1f3728a8931e026354c9a3 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Fri, 16 Jan 2026 21:04:48 +0300 Subject: [PATCH 15/15] Refactor settings handling in AdminBanSystem to improve response parsing - Introduced a new helper function `_parse_setting_response` to streamline the parsing of settings responses from the API. - Updated the `get_settings`, `get_setting`, `set_setting`, and `toggle_setting` endpoints to utilize the new parsing function, enhancing code readability and maintainability. - Improved handling of settings data formats, allowing for both detailed metadata and simple values. --- app/cabinet/routes/admin_ban_system.py | 118 ++++++++++++++++--------- 1 file changed, 78 insertions(+), 40 deletions(-) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index d06130fe..ae40416d 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -663,6 +663,44 @@ async def get_traffic_top( # === Settings === +def _parse_setting_response(key: str, data: Any, default_type: str = "str") -> BanSettingDefinition: + """Parse setting response from API.""" + if isinstance(data, dict) and "value" in data: + return BanSettingDefinition( + key=key, + value=data.get("value"), + type=data.get("type", default_type), + min_value=data.get("min"), + max_value=data.get("max"), + editable=data.get("editable", True), + description=data.get("description"), + category=data.get("category"), + ) + else: + # Простое значение или dict без "value" + value = data.get("value", data) if isinstance(data, dict) else data + value_type = default_type + if isinstance(value, bool): + value_type = "bool" + elif isinstance(value, int): + value_type = "int" + elif isinstance(value, float): + value_type = "float" + elif isinstance(value, list): + value_type = "list" + + return BanSettingDefinition( + key=key, + value=value, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + ) + + @router.get("/settings", response_model=BanSettingsResponse) async def get_settings( admin: User = Depends(get_current_admin_user), @@ -675,16 +713,43 @@ async def get_settings( settings_data = data.get("settings", {}) if isinstance(data, dict) else {} for key, info in settings_data.items(): - settings_list.append(BanSettingDefinition( - key=key, - value=info.get("value"), - type=info.get("type", "str"), - min_value=info.get("min"), - max_value=info.get("max"), - editable=info.get("editable", True), - description=info.get("description"), - category=info.get("category"), - )) + # API может возвращать настройки в двух форматах: + # 1. {"key": {"value": ..., "type": ...}} - с метаданными + # 2. {"key": value} - просто значение + if isinstance(info, dict) and "value" in info: + # Формат с метаданными + settings_list.append(BanSettingDefinition( + key=key, + value=info.get("value"), + type=info.get("type", "str"), + min_value=info.get("min"), + max_value=info.get("max"), + editable=info.get("editable", True), + description=info.get("description"), + category=info.get("category"), + )) + else: + # Простой формат - определяем тип по значению + value_type = "str" + if isinstance(info, bool): + value_type = "bool" + elif isinstance(info, int): + value_type = "int" + elif isinstance(info, float): + value_type = "float" + elif isinstance(info, list): + value_type = "list" + + settings_list.append(BanSettingDefinition( + key=key, + value=info, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + )) return BanSettingsResponse(settings=settings_list) @@ -698,16 +763,7 @@ async def get_setting( api = _get_ban_api() data = await _api_request(api, "get_setting", key=key) - return BanSettingDefinition( - key=key, - value=data.get("value"), - type=data.get("type", "str"), - min_value=data.get("min"), - max_value=data.get("max"), - editable=data.get("editable", True), - description=data.get("description"), - category=data.get("category"), - ) + return _parse_setting_response(key, data) @router.post("/settings/{key}") @@ -722,16 +778,7 @@ async def set_setting( logger.info(f"Admin {admin.id} changed Ban System setting {key} to {value}") - return BanSettingDefinition( - key=key, - value=data.get("value"), - type=data.get("type", "str"), - min_value=data.get("min"), - max_value=data.get("max"), - editable=data.get("editable", True), - description=data.get("description"), - category=data.get("category"), - ) + return _parse_setting_response(key, data) @router.post("/settings/{key}/toggle") @@ -745,16 +792,7 @@ async def toggle_setting( logger.info(f"Admin {admin.id} toggled Ban System setting {key}") - return BanSettingDefinition( - key=key, - value=data.get("value"), - type=data.get("type", "bool"), - min_value=data.get("min"), - max_value=data.get("max"), - editable=data.get("editable", True), - description=data.get("description"), - category=data.get("category"), - ) + return _parse_setting_response(key, data, default_type="bool") # === Whitelist ===