""" Сервис для мониторинга трафика пользователей v2 Быстрая проверка текущего трафика + суточная проверка """ import asyncio 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 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" @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._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_fast_check_enabled(self) -> bool: # Поддержка старого параметра TRAFFIC_MONITORING_ENABLED return settings.TRAFFIC_FAST_CHECK_ENABLED or settings.TRAFFIC_MONITORING_ENABLED def is_daily_check_enabled(self) -> bool: return settings.TRAFFIC_DAILY_CHECK_ENABLED 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 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 def get_daily_threshold_gb(self) -> float: return settings.TRAFFIC_DAILY_THRESHOLD_GB 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_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() 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: # Сохраняем snapshot как JSON snapshot_data = {uuid: bytes_val for uuid, bytes_val in snapshot.items()} ttl = self.get_snapshot_ttl_seconds() 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"⚠️ Не удалось сохранить 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) # ВАЖНО: пустой словарь {} - это валидный 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)} пользователей") 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 # ============== Работа с нодами ============== 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: """Проверяет, нужно ли мониторить пользователя с этой ноды""" 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"❌ Ошибка при получении пользователей: {e}") return [] # ============== Быстрая проверка ============== async def has_snapshot(self) -> bool: """Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)""" # Проверяем Redis (пустой словарь {} - это тоже валидный snapshot!) snapshot = await self._load_snapshot_from_redis() if snapshot is not None: return True # Fallback на память return 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: """ Создаёт начальный snapshot при запуске бота. Если в Redis уже есть snapshot — использует его (персистентность). Возвращает количество пользователей в snapshot. """ # Проверяем есть ли snapshot в Redis (пустой {} тоже валидный snapshot!) existing_snapshot = await self._load_snapshot_from_redis() if existing_snapshot is not None: age = await self.get_snapshot_age_minutes() logger.info( f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " f"возраст {age:.1f} мин" ) return len(existing_snapshot) logger.info("📸 Создание начального snapshot трафика...") start_time = datetime.utcnow() users = await self.get_all_users_with_traffic() new_snapshot: Dict[str, float] = {} for user in users: try: if not user.uuid: continue 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() # Загружаем кеш нод для красивых названий в уведомлениях 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: 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.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})") 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: logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем 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()} ГБ, 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, 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=node_name, 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) logger.info(f"💾 Новый snapshot сохранён: {len(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() # Загружаем кеш нод для красивых названий в уведомлениях await self._load_nodes_cache() 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) node_name = self.get_node_name(last_node_uuid) 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=node_name, 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"✅ Суточная проверка завершена за {elapsed:.1f}с: " f"{len(users)} пользователей, {len(violations)} превышений" ) # Отправляем уведомления 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.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)") 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" ) # Показываем название ноды и 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" 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, bot ): """Отправляет уведомление о подозрительном трафике""" 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): """Старый метод — теперь вызывает быструю проверку""" await self._v2.run_fast_check(bot) # Глобальные экземпляры (создаём до класса-обёртки) traffic_monitoring_service_v2 = TrafficMonitoringServiceV2() traffic_monitoring_scheduler_v2 = TrafficMonitoringSchedulerV2(traffic_monitoring_service_v2) class TrafficMonitoringScheduler: """Обёртка для обратной совместимости — использует глобальные 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 def set_bot(self, bot): self.bot = bot self._v2_scheduler.set_bot(bot) def is_enabled(self) -> bool: return self._v2_service.is_fast_check_enabled() or self._v2_service.is_daily_check_enabled() def get_interval_hours(self) -> int: """Для обратной совместимости — возвращает интервал быстрой проверки в часах""" 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): await self._v2_scheduler.start() def stop_monitoring(self): asyncio.create_task(self._v2_scheduler.stop()) # Обратная совместимость traffic_monitoring_service = TrafficMonitoringService() traffic_monitoring_scheduler = TrafficMonitoringScheduler()