import asyncio import logging import re from contextlib import AsyncExitStack, asynccontextmanager from datetime import datetime, timedelta from dataclasses import asdict, is_dataclass from typing import Any, Dict, List, Optional, Tuple from zoneinfo import ZoneInfo from app.config import settings from app.external.remnawave_api import ( RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad, RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError ) from sqlalchemy import and_, cast, delete, func, select, update, String from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.user import ( create_user_no_commit, get_users_list, get_user_by_telegram_id, update_user, ) from app.database.crud.subscription import ( get_subscription_by_user_id, update_subscription_usage, decrement_subscription_server_counts, ) from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.models import ( User, Subscription, SubscriptionServer, Transaction, ReferralEarning, PromoCodeUse, SubscriptionStatus, ServerSquad, ) from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int: """Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" # Новый формат: userTraffic.usedTrafficBytes user_traffic = panel_user.get('userTraffic') if user_traffic and isinstance(user_traffic, dict): return user_traffic.get('usedTrafficBytes', 0) # Старый формат: usedTrafficBytes напрямую return panel_user.get('usedTrafficBytes', 0) def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int: """Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" # Новый формат: userTraffic.lifetimeUsedTrafficBytes user_traffic = panel_user.get('userTraffic') if user_traffic and isinstance(user_traffic, dict): return user_traffic.get('lifetimeUsedTrafficBytes', 0) # Старый формат: lifetimeUsedTrafficBytes напрямую return panel_user.get('lifetimeUsedTrafficBytes', 0) _UUID_MAP_MISSING = object() class _UUIDMapMutation: """Tracks in-memory UUID map/user changes so they can be rolled back.""" __slots__ = ("uuid_map", "_map_original", "_user_original") def __init__(self, uuid_map: Dict[str, "User"]): self.uuid_map = uuid_map self._map_original: Dict[str, Any] = {} self._user_original: Dict["User", Tuple[Optional[str], Optional[datetime]]] = {} def _capture_user_state(self, user: Optional["User"]) -> None: if not user or user in self._user_original: return self._user_original[user] = ( getattr(user, "remnawave_uuid", None), getattr(user, "updated_at", None), ) def _capture_map_entry(self, key: Optional[str]) -> None: if key is None or key in self._map_original: return self._map_original[key] = self.uuid_map.get(key, _UUID_MAP_MISSING) def set_user_uuid(self, user: Optional["User"], value: Optional[str]) -> None: if not user: return self._capture_user_state(user) user.remnawave_uuid = value def set_user_updated_at(self, user: Optional["User"], value: datetime) -> None: if not user: return self._capture_user_state(user) user.updated_at = value def remove_map_entry(self, key: Optional[str]) -> None: if key is None: return self._capture_map_entry(key) self.uuid_map.pop(key, None) def set_map_entry(self, key: Optional[str], value: Optional["User"]) -> None: if key is None: return self._capture_map_entry(key) if value is None: self.uuid_map.pop(key, None) else: self.uuid_map[key] = value def has_changes(self) -> bool: return bool(self._map_original or self._user_original) def rollback(self) -> None: for user, (uuid_value, updated_at) in self._user_original.items(): user.remnawave_uuid = uuid_value user.updated_at = updated_at for key, original in self._map_original.items(): if original is _UUID_MAP_MISSING: self.uuid_map.pop(key, None) else: self.uuid_map[key] = original class RemnaWaveConfigurationError(Exception): """Raised when RemnaWave API configuration is missing.""" class RemnaWaveService: def __init__(self): auth_params = settings.get_remnawave_auth_params() base_url = (auth_params.get("base_url") or "").strip() api_key = (auth_params.get("api_key") or "").strip() self._config_error: Optional[str] = None self._panel_timezone = get_local_timezone() self._utc_timezone = ZoneInfo("UTC") if not base_url: self._config_error = "REMNAWAVE_API_URL не настроен" elif not api_key: self._config_error = "REMNAWAVE_API_KEY не настроен" self.api: Optional[RemnaWaveAPI] if self._config_error: self.api = None else: self.api = RemnaWaveAPI( base_url=base_url, api_key=api_key, secret_key=auth_params.get("secret_key"), username=auth_params.get("username"), password=auth_params.get("password") ) @property def is_configured(self) -> bool: return self._config_error is None @property def configuration_error(self) -> Optional[str]: return self._config_error def _ensure_configured(self) -> None: if not self.is_configured or self.api is None: raise RemnaWaveConfigurationError( self._config_error or "RemnaWave API не настроен" ) def _ensure_user_remnawave_uuid( self, user: "User", panel_uuid: Optional[str], uuid_map: Dict[str, "User"], ) -> Tuple[bool, Optional[_UUIDMapMutation]]: """Обновляет UUID пользователя, если он изменился в панели.""" if not panel_uuid: return False, None current_uuid = getattr(user, "remnawave_uuid", None) if current_uuid == panel_uuid: return False, None mutation = _UUIDMapMutation(uuid_map) conflicting_user = uuid_map.get(panel_uuid) if conflicting_user and conflicting_user is not user: logger.warning( "♻️ Обнаружен конфликт UUID %s между пользователями %s и %s. Сбрасываем у старой записи.", panel_uuid, getattr(conflicting_user, "telegram_id", "?"), getattr(user, "telegram_id", "?"), ) mutation.set_user_uuid(conflicting_user, None) mutation.set_user_updated_at(conflicting_user, datetime.utcnow()) mutation.remove_map_entry(panel_uuid) if current_uuid: mutation.remove_map_entry(current_uuid) mutation.set_user_uuid(user, panel_uuid) mutation.set_user_updated_at(user, datetime.utcnow()) mutation.set_map_entry(panel_uuid, user) logger.info( "🔁 Обновлен RemnaWave UUID пользователя %s: %s → %s", getattr(user, "telegram_id", "?"), current_uuid, panel_uuid, ) if mutation.has_changes(): return True, mutation return True, None @asynccontextmanager async def get_api_client(self): self._ensure_configured() assert self.api is not None async with self.api as api: yield api def _now_utc(self) -> datetime: """Возвращает текущее время в UTC без привязки к часовому поясу.""" return datetime.now(self._utc_timezone).replace(tzinfo=None) def _parse_remnawave_date(self, date_str: str) -> datetime: if not date_str: return self._now_utc() + timedelta(days=30) try: cleaned_date = date_str.strip() if cleaned_date.endswith('Z'): cleaned_date = cleaned_date[:-1] + '+00:00' if '+00:00+00:00' in cleaned_date: cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00') cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date) parsed_date = datetime.fromisoformat(cleaned_date) if parsed_date.tzinfo is not None: localized = parsed_date.astimezone(self._panel_timezone) else: localized = parsed_date.replace(tzinfo=self._panel_timezone) utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None) logger.debug( f"Успешно распарсена дата: {date_str} -> {utc_normalized} (нормализовано в UTC)" ) return utc_normalized except Exception as e: logger.warning( f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату." ) return self._now_utc() + timedelta(days=30) def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime: """Гарантирует, что дата окончания не в прошлом для панели.""" now = self._now_utc() minimum_expire = now + timedelta(minutes=1) if not expire_at: return minimum_expire normalized_expire = expire_at if normalized_expire.tzinfo is not None: normalized_expire = normalized_expire.replace(tzinfo=None) if normalized_expire < minimum_expire: logger.debug( "⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели", normalized_expire, minimum_expire, ) return minimum_expire return normalized_expire def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime: """Парсит дату окончания подписки пользователя панели для сравнения.""" expire_at_value = panel_user.get('expireAt') if expire_at_value is None: return datetime.min.replace(tzinfo=None) expire_at_str = str(expire_at_value).strip() if not expire_at_str: return datetime.min.replace(tzinfo=None) return self._parse_remnawave_date(expire_at_str) def _is_preferred_panel_user( self, *, candidate: Dict[str, Any], current: Dict[str, Any], ) -> bool: """Определяет, является ли новая запись предпочтительной для Telegram ID.""" candidate_expire = self._safe_panel_expire_date(candidate) current_expire = self._safe_panel_expire_date(current) if candidate_expire > current_expire: return True if candidate_expire < current_expire: return False candidate_status = (candidate.get('status') or '').upper() current_status = (current.get('status') or '').upper() active_statuses = {'ACTIVE', 'TRIAL'} if candidate_status in active_statuses and current_status not in active_statuses: return True return False def _deduplicate_panel_users_by_telegram_id( self, panel_users: List[Dict[str, Any]], ) -> Dict[Any, Dict[str, Any]]: """Возвращает уникальных пользователей панели по Telegram ID.""" unique_users: Dict[Any, Dict[str, Any]] = {} for panel_user in panel_users: telegram_id = panel_user.get('telegramId') if telegram_id is None: continue existing_user = unique_users.get(telegram_id) if existing_user is None or self._is_preferred_panel_user( candidate=panel_user, current=existing_user, ): unique_users[telegram_id] = panel_user return unique_users def _extract_user_data_from_description(self, description: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: """ Извлекает имя, фамилию и username из описания пользователя в панели Remnawave. Args: description: Описание пользователя из панели Returns: Tuple[first_name, last_name, username] - извлеченные данные """ logger.debug(f"📥 Парсинг описания пользователя: '{description}'") if not description: logger.debug("❌ Пустое описание пользователя") return None, None, None # Ищем строки в формате "Bot user: ..." import re # Паттерн для извлечения данных из "Bot user: Name @username" или "Bot user: Name" # Также поддерживаем просто "Name @username" без префикса bot_user_patterns = [ r"Bot user:\s*(.+)", # С префиксом r"^([\w\s]+(?:@[\w_]+)?)$", # Без префикса ] user_info = None for pattern in bot_user_patterns: match = re.search(pattern, description) if match: user_info = match.group(1).strip() logger.debug(f"🔍 Найдена информация о пользователе: '{user_info}'") break if not user_info: logger.debug("❌ Не удалось найти информацию о пользователе в описании") return None, None, None # Паттерн для извлечения username (@username в конце) username_pattern = r"\s+(@[\w_]+)$" username_match = re.search(username_pattern, user_info) if username_match: username_with_at = username_match.group(1) username = username_with_at[1:] if username_with_at.startswith('@') else username_with_at # Убираем символ @ # Убираем username из основной информации name_part = user_info[:username_match.start()].strip() logger.debug(f"📱 Найден username: '{username_with_at}' (обработанный: '{username}'), остаток: '{name_part}'") else: username = None name_part = user_info logger.debug(f"📱 Username не найден, имя: '{name_part}'") # Разделяем имя и фамилию if name_part and not name_part.startswith("@"): # Если есть имя (не начинается с @), используем его name_parts = name_part.split() logger.debug(f"🔤 Части имени: {name_parts}") if len(name_parts) >= 2: # Первое слово - имя, остальные - фамилия first_name = name_parts[0] last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else None logger.debug(f"👤 Имя: '{first_name}', Фамилия: '{last_name}'") elif len(name_parts) == 1 and not name_parts[0].startswith("@"): # Только имя first_name = name_parts[0] last_name = None logger.debug(f"👤 Только имя: '{first_name}'") else: first_name = None last_name = None logger.debug("👤 Имя не определено") else: first_name = None last_name = None logger.debug("👤 Имя не определено (начинается с @)") logger.debug(f"✅ Результат парсинга: first_name='{first_name}', last_name='{last_name}', username='{username}'") return first_name, last_name, username async def _get_or_create_bot_user_from_panel( self, db: AsyncSession, panel_user: Dict[str, Any], ) -> Tuple[Optional[User], bool]: """Возвращает пользователя бота, создавая его при необходимости. При конфликте уникальности telegram_id повторно загружает пользователя из базы данных и сообщает, что запись не была создана заново. """ telegram_id = panel_user.get("telegramId") if telegram_id is None: return None, False # Извлекаем настоящее имя пользователя из описания description = panel_user.get("description") or "" first_name_from_desc, last_name_from_desc, username_from_desc = self._extract_user_data_from_description(description) # Используем извлеченное имя или дефолтное значение fallback_first_name = f"User {telegram_id}" full_first_name = fallback_first_name full_last_name = None if first_name_from_desc and last_name_from_desc: full_first_name = first_name_from_desc full_last_name = last_name_from_desc elif first_name_from_desc: full_first_name = first_name_from_desc full_last_name = last_name_from_desc username = username_from_desc or panel_user.get("username") try: create_kwargs = dict( db=db, telegram_id=telegram_id, username=username, first_name=full_first_name, last_name=full_last_name, language="ru", ) db_user = await create_user_no_commit(**create_kwargs) return db_user, True except IntegrityError as create_error: logger.info( "♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.", telegram_id, ) try: await db.rollback() except Exception: # create_user_no_commit уже выполняет rollback при необходимости pass try: existing_user = await get_user_by_telegram_id(db, telegram_id) if existing_user is None: logger.error("❌ Не удалось найти существующего пользователя с telegram_id %s", telegram_id) return None, False logger.debug( "Используется существующий пользователь %s после конфликта уникальности: %s", telegram_id, create_error, ) return existing_user, False except Exception as load_error: logger.error("❌ Ошибка загрузки существующего пользователя %s: %s", telegram_id, load_error) return None, False except Exception as general_error: logger.error("❌ Общая ошибка создания/загрузки пользователя %s: %s", telegram_id, general_error) try: await db.rollback() except: pass return None, False async def get_system_statistics(self) -> Dict[str, Any]: try: async with self.get_api_client() as api: logger.info("Получение системной статистики RemnaWave...") try: system_stats = await api.get_system_stats() logger.info(f"Системная статистика получена") except Exception as e: logger.error(f"Ошибка получения системной статистики: {e}") system_stats = {} try: bandwidth_stats = await api.get_bandwidth_stats() logger.info(f"Статистика трафика получена") except Exception as e: logger.error(f"Ошибка получения статистики трафика: {e}") bandwidth_stats = {} try: realtime_usage = await api.get_nodes_realtime_usage() logger.info(f"Реалтайм статистика получена") except Exception as e: logger.error(f"Ошибка получения реалтайм статистики: {e}") realtime_usage = [] try: nodes_stats = await api.get_nodes_statistics() except Exception as e: logger.error(f"Ошибка получения статистики нод: {e}") nodes_stats = {} total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) total_realtime_traffic = total_download + total_upload total_user_traffic = int(system_stats.get('users', {}).get('totalTrafficBytes', '0')) nodes_weekly_data = [] if nodes_stats.get('lastSevenDays'): nodes_by_name = {} for day_data in nodes_stats['lastSevenDays']: node_name = day_data['nodeName'] if node_name not in nodes_by_name: nodes_by_name[node_name] = { 'name': node_name, 'total_bytes': 0, 'days_data': [] } daily_bytes = int(day_data['totalBytes']) nodes_by_name[node_name]['total_bytes'] += daily_bytes nodes_by_name[node_name]['days_data'].append({ 'date': day_data['date'], 'bytes': daily_bytes }) nodes_weekly_data = list(nodes_by_name.values()) nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True) uptime_seconds = 0 uptime_value = system_stats.get('uptime') try: uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0 except (TypeError, ValueError): logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0") result = { "system": { "users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0), "total_users": system_stats.get('users', {}).get('totalUsers', 0), "active_connections": system_stats.get('onlineStats', {}).get('onlineNow', 0), "nodes_online": system_stats.get('nodes', {}).get('totalOnline', 0), "users_last_day": system_stats.get('onlineStats', {}).get('lastDay', 0), "users_last_week": system_stats.get('onlineStats', {}).get('lastWeek', 0), "users_never_online": system_stats.get('onlineStats', {}).get('neverOnline', 0), "total_user_traffic": total_user_traffic }, "users_by_status": system_stats.get('users', {}).get('statusCounts', {}), "server_info": { "cpu_cores": system_stats.get('cpu', {}).get('cores', 0), "cpu_physical_cores": system_stats.get('cpu', {}).get('physicalCores', 0), "memory_total": system_stats.get('memory', {}).get('total', 0), "memory_used": system_stats.get('memory', {}).get('used', 0), "memory_free": system_stats.get('memory', {}).get('free', 0), "memory_available": system_stats.get('memory', {}).get('available', 0), "uptime_seconds": uptime_seconds }, "bandwidth": { "realtime_download": total_download, "realtime_upload": total_upload, "realtime_total": total_realtime_traffic }, "traffic_periods": { "last_2_days": { "current": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLastTwoDays', {}).get('current', '0 B') ), "previous": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLastTwoDays', {}).get('previous', '0 B') ), "difference": bandwidth_stats.get('bandwidthLastTwoDays', {}).get('difference', '0 B') }, "last_7_days": { "current": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLastSevenDays', {}).get('current', '0 B') ), "previous": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLastSevenDays', {}).get('previous', '0 B') ), "difference": bandwidth_stats.get('bandwidthLastSevenDays', {}).get('difference', '0 B') }, "last_30_days": { "current": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLast30Days', {}).get('current', '0 B') ), "previous": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthLast30Days', {}).get('previous', '0 B') ), "difference": bandwidth_stats.get('bandwidthLast30Days', {}).get('difference', '0 B') }, "current_month": { "current": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthCalendarMonth', {}).get('current', '0 B') ), "previous": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthCalendarMonth', {}).get('previous', '0 B') ), "difference": bandwidth_stats.get('bandwidthCalendarMonth', {}).get('difference', '0 B') }, "current_year": { "current": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthCurrentYear', {}).get('current', '0 B') ), "previous": self._parse_bandwidth_string( bandwidth_stats.get('bandwidthCurrentYear', {}).get('previous', '0 B') ), "difference": bandwidth_stats.get('bandwidthCurrentYear', {}).get('difference', '0 B') } }, "nodes_realtime": realtime_usage, "nodes_weekly": nodes_weekly_data, "last_updated": datetime.now() } logger.info(f"Статистика сформирована: пользователи={result['system']['total_users']}, общий трафик={total_user_traffic}") return result except RemnaWaveAPIError as e: logger.error(f"Ошибка Remnawave API при получении статистики: {e}") return {"error": str(e)} except Exception as e: logger.error(f"Общая ошибка получения системной статистики: {e}") return {"error": f"Внутренняя ошибка сервера: {str(e)}"} def _parse_bandwidth_string(self, bandwidth_str: str) -> int: try: if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0': return 0 bandwidth_str = bandwidth_str.replace(' ', '').upper() units = { 'B': 1, 'KB': 1024, 'MB': 1024 ** 2, 'GB': 1024 ** 3, 'TB': 1024 ** 4, 'KIB': 1024, 'MIB': 1024 ** 2, 'GIB': 1024 ** 3, 'TIB': 1024 ** 4, 'KBPS': 1024, 'MBPS': 1024 ** 2, 'GBPS': 1024 ** 3 } match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str) if match: value_str = match.group(1).replace(',', '.') value = float(value_str) unit = match.group(2) if unit in units: result = int(value * units[unit]) logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт") return result else: logger.warning(f"Неизвестная единица измерения: {unit}") logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'") return 0 except Exception as e: logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") return 0 async def get_all_nodes(self) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: nodes = await api.get_all_nodes() result = [] for node in nodes: result.append({ 'uuid': node.uuid, 'name': node.name, 'address': node.address, 'country_code': node.country_code, 'is_connected': node.is_connected, 'is_disabled': node.is_disabled, 'is_node_online': node.is_node_online, 'is_xray_running': node.is_xray_running, 'users_online': node.users_online, 'traffic_used_bytes': node.traffic_used_bytes, 'traffic_limit_bytes': node.traffic_limit_bytes }) logger.info(f"✅ Получено {len(result)} нод из Remnawave") return result except Exception as e: logger.error(f"Ошибка получения нод из Remnawave: {e}") return [] async def test_connection(self) -> bool: try: async with self.get_api_client() as api: stats = await api.get_system_stats() logger.info("✅ Соединение с Remnawave API работает") return True except Exception as e: logger.error(f"❌ Ошибка соединения с Remnawave API: {e}") return False async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]: try: async with self.get_api_client() as api: node = await api.get_node_by_uuid(node_uuid) if not node: return None return { "uuid": node.uuid, "name": node.name, "address": node.address, "country_code": node.country_code, "is_connected": node.is_connected, "is_disabled": node.is_disabled, "is_node_online": node.is_node_online, "is_xray_running": node.is_xray_running, "users_online": node.users_online or 0, "traffic_used_bytes": node.traffic_used_bytes or 0, "traffic_limit_bytes": node.traffic_limit_bytes or 0, "last_status_change": node.last_status_change, "last_status_message": node.last_status_message, "xray_uptime": node.xray_uptime, "is_traffic_tracking_active": node.is_traffic_tracking_active, "traffic_reset_day": node.traffic_reset_day, "notify_percent": node.notify_percent, "consumption_multiplier": node.consumption_multiplier, "cpu_count": node.cpu_count, "cpu_model": node.cpu_model, "total_ram": node.total_ram, "created_at": node.created_at, "updated_at": node.updated_at, "provider_uuid": node.provider_uuid, } except Exception as e: logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}") return None async def manage_node(self, node_uuid: str, action: str) -> bool: try: async with self.get_api_client() as api: if action == "enable": await api.enable_node(node_uuid) elif action == "disable": await api.disable_node(node_uuid) elif action == "restart": await api.restart_node(node_uuid) else: return False logger.info(f"✅ Действие {action} выполнено для ноды {node_uuid}") return True except Exception as e: logger.error(f"Ошибка управления нодой {node_uuid}: {e}") return False async def restart_all_nodes(self) -> bool: try: async with self.get_api_client() as api: result = await api.restart_all_nodes() if result: logger.info("✅ Команда перезагрузки всех нод отправлена") return result except Exception as e: logger.error(f"Ошибка перезагрузки всех нод: {e}") return False async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool: try: async with self.get_api_client() as api: data = { 'uuid': squad_uuid, 'inbounds': inbound_uuids } response = await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: logger.error(f"Error updating squad inbounds: {e}") return False async def get_all_squads(self) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: squads = await api.get_internal_squads() result = [] for squad in squads: inbounds = [ asdict(inbound) if is_dataclass(inbound) else inbound for inbound in squad.inbounds or [] ] result.append({ 'uuid': squad.uuid, 'name': squad.name, 'members_count': squad.members_count, 'inbounds_count': squad.inbounds_count, 'inbounds': inbounds, }) logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") return result except Exception as e: logger.error(f"Ошибка получения сквадов из Remnawave: {e}") return [] async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]: try: async with self.get_api_client() as api: squad = await api.create_internal_squad(name, inbounds) logger.info(f"✅ Создан новый сквад: {name}") return squad.uuid except Exception as e: logger.error(f"Ошибка создания сквада {name}: {e}") return None async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool: try: async with self.get_api_client() as api: await api.update_internal_squad(uuid, name, inbounds) logger.info(f"✅ Обновлен сквад {uuid}") return True except Exception as e: logger.error(f"Ошибка обновления сквада {uuid}: {e}") return False async def delete_squad(self, uuid: str) -> bool: try: async with self.get_api_client() as api: result = await api.delete_internal_squad(uuid) if result: logger.info(f"✅ Удален сквад {uuid}") return result except Exception as e: logger.error(f"Ошибка удаления сквада {uuid}: {e}") return False async def migrate_squad_users( self, db: AsyncSession, source_uuid: str, target_uuid: str, ) -> Dict[str, Any]: """Переносит активных подписок с одного сквада на другой.""" if source_uuid == target_uuid: return { "success": False, "error": "same_squad", "message": "Источник и назначение совпадают", } source_uuid = source_uuid.strip() target_uuid = target_uuid.strip() source_server = await get_server_squad_by_uuid(db, source_uuid) target_server = await get_server_squad_by_uuid(db, target_uuid) if not source_server or not target_server: return { "success": False, "error": "not_found", "message": "Сквады не найдены", } subscription_query = ( select(Subscription) .options(selectinload(Subscription.user)) .where( Subscription.status.in_( [ SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value, ] ), cast(Subscription.connected_squads, String).like( f'%"{source_uuid}"%' ), ) ) result = await db.execute(subscription_query) subscriptions = result.scalars().unique().all() total_candidates = len(subscriptions) if not subscriptions: logger.info( "🚚 Переезд сквада %s → %s: подходящих подписок не найдено", source_uuid, target_uuid, ) return { "success": True, "total": 0, "updated": 0, "panel_updated": 0, "panel_failed": 0, } exit_stack = AsyncExitStack() panel_updated = 0 panel_failed = 0 updated_subscriptions = 0 source_decrement = 0 target_increment = 0 try: needs_panel_update = any( subscription.user and subscription.user.remnawave_uuid for subscription in subscriptions ) api = None if needs_panel_update: api = await exit_stack.enter_async_context(self.get_api_client()) for subscription in subscriptions: current_squads = list(subscription.connected_squads or []) if source_uuid not in current_squads: continue had_target_before = target_uuid in current_squads new_squads = [ squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid ] if not had_target_before: new_squads.append(target_uuid) if subscription.user and subscription.user.remnawave_uuid: if api is None: panel_failed += 1 logger.error( "❌ RemnaWave API недоступен для обновления пользователя %s", subscription.user.telegram_id, ) continue try: await api.update_user( uuid=subscription.user.remnawave_uuid, active_internal_squads=new_squads, ) panel_updated += 1 except Exception as error: panel_failed += 1 logger.error( "❌ Ошибка обновления сквадов пользователя %s: %s", subscription.user.telegram_id, error, ) continue subscription.connected_squads = new_squads subscription.updated_at = datetime.utcnow() source_decrement += 1 if not had_target_before: target_increment += 1 updated_subscriptions += 1 link_result = await db.execute( select(SubscriptionServer) .where( and_( SubscriptionServer.subscription_id == subscription.id, SubscriptionServer.server_squad_id == source_server.id, ) ) .limit(1) ) link = link_result.scalars().first() if link: if had_target_before: await db.execute( delete(SubscriptionServer).where( and_( SubscriptionServer.subscription_id == subscription.id, SubscriptionServer.server_squad_id == source_server.id, ) ) ) else: link.server_squad_id = target_server.id elif not had_target_before: db.add( SubscriptionServer( subscription_id=subscription.id, server_squad_id=target_server.id, paid_price_kopeks=0, ) ) if updated_subscriptions: if source_decrement: await db.execute( update(ServerSquad) .where(ServerSquad.id == source_server.id) .values( current_users=func.greatest( ServerSquad.current_users - source_decrement, 0, ) ) ) if target_increment: await db.execute( update(ServerSquad) .where(ServerSquad.id == target_server.id) .values( current_users=ServerSquad.current_users + target_increment ) ) await db.commit() else: await db.rollback() logger.info( "🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)", source_uuid, target_uuid, updated_subscriptions, panel_failed, ) return { "success": True, "total": total_candidates, "updated": updated_subscriptions, "panel_updated": panel_updated, "panel_failed": panel_failed, "source_removed": source_decrement, "target_added": target_increment, } except RemnaWaveConfigurationError: await db.rollback() raise except Exception as error: await db.rollback() logger.error( "❌ Ошибка переезда сквада %s → %s: %s", source_uuid, target_uuid, error, ) return { "success": False, "error": "unexpected", "message": str(error), } finally: await exit_stack.aclose() async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]: try: stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0} logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}") async with self.get_api_client() as api: panel_users = [] start = 0 size = 100 while True: logger.info(f"📥 Загружаем пользователей: start={start}, size={size}") response = await api.get_all_users(start=start, size=size) users_batch = response['users'] total_users = response['total'] logger.info(f"📊 Получено {len(users_batch)} пользователей из {total_users}") for user_obj in users_batch: user_dict = { 'uuid': user_obj.uuid, 'shortUuid': user_obj.short_uuid, 'username': user_obj.username, 'status': user_obj.status.value, 'telegramId': user_obj.telegram_id, 'expireAt': user_obj.expire_at.isoformat() + 'Z', 'trafficLimitBytes': user_obj.traffic_limit_bytes, 'usedTrafficBytes': user_obj.used_traffic_bytes, 'hwidDeviceLimit': user_obj.hwid_device_limit, 'subscriptionUrl': user_obj.subscription_url, 'subscriptionCryptoLink': user_obj.happ_crypto_link, 'activeInternalSquads': user_obj.active_internal_squads } panel_users.append(user_dict) if len(users_batch) < size: break start += size if start > total_users: break logger.info(f"✅ Всего загружено пользователей из панели: {len(panel_users)}") # Загрузка пользователей с их подписками за один запрос (bulk loading) from sqlalchemy.orm import selectinload from app.database.models import User, Subscription from sqlalchemy import select # Получаем всех пользователей с их подписками за один запрос bot_users_result = await db.execute( select(User) .options(selectinload(User.subscription)) ) bot_users = bot_users_result.scalars().all() bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users} bot_users_by_uuid = { user.remnawave_uuid: user for user in bot_users if getattr(user, "remnawave_uuid", None) } logger.info(f"📊 Пользователей в боте: {len(bot_users)}") panel_users_with_tg = [ user for user in panel_users if user.get('telegramId') is not None ] logger.info(f"📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}") unique_panel_users_map = self._deduplicate_panel_users_by_telegram_id(panel_users_with_tg) unique_panel_users = list(unique_panel_users_map.values()) duplicates_count = len(panel_users_with_tg) - len(unique_panel_users) if duplicates_count: logger.info( "♻️ Обнаружено %s дубликатов пользователей по Telegram ID. Используем самые свежие записи.", duplicates_count, ) panel_telegram_ids = set(unique_panel_users_map.keys()) # Для ускорения - подготовим данные о подписках # Соберем все существующие подписки за один запрос existing_subscriptions_result = await db.execute( select(Subscription) .join(User) .options(selectinload(Subscription.user)) ) existing_subscriptions = existing_subscriptions_result.scalars().all() # Создадим словарь для быстрого доступа к подпискам subscriptions_by_user_id = {sub.user_id: sub for sub in existing_subscriptions} # Для оптимизации коммитим изменения каждые N пользователей batch_size = 50 pending_uuid_mutations: List[_UUIDMapMutation] = [] for i, panel_user in enumerate(unique_panel_users): uuid_mutation: Optional[_UUIDMapMutation] = None try: telegram_id = panel_user.get('telegramId') if not telegram_id: continue if (i + 1) % 10 == 0: logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(unique_panel_users)}: {telegram_id}") db_user = bot_users_by_telegram_id.get(telegram_id) if not db_user: if sync_type in ["new_only", "all"]: logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}") db_user, is_created = await self._get_or_create_bot_user_from_panel(db, panel_user) if not db_user: logger.error( "❌ Не удалось создать или получить пользователя для telegram_id %s", telegram_id, ) stats["errors"] += 1 continue bot_users_by_telegram_id[telegram_id] = db_user # При синхронизации не обновляем имя и username пользователя # только сохраняем изменения, если были обновлены другие поля (подписка и т.д.) updated_fields = [] # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения if updated_fields: logger.info(f"🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}") await db.flush() # Сохраняем изменения без коммита _, uuid_mutation = self._ensure_user_remnawave_uuid( db_user, panel_user.get('uuid'), bot_users_by_uuid, ) if is_created: await self._create_subscription_from_panel_data(db, db_user, panel_user) stats["created"] += 1 logger.info(f"✅ Создан пользователь {telegram_id} с подпиской") else: # Обновляем данные существующего пользователя # Но теперь мы уже загрузили подписку с пользователем, нет необходимости перезагружать await self._update_subscription_from_panel_data(db, db_user, panel_user) stats["updated"] += 1 logger.info( f"♻️ Обновлена подписка существующего пользователя {telegram_id}" ) else: if sync_type in ["update_only", "all"]: logger.debug(f"🔄 Обновление пользователя {telegram_id}") # При синхронизации не обновляем имя и username пользователя # только сохраняем изменения, если были обновлены другие поля (подписка и т.д.) updated_fields = [] # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения if updated_fields: logger.info(f"🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}") await db.flush() # Сохраняем изменения без коммита # Проверяем, есть ли у пользователя подписка, загруженная с пользователем if hasattr(db_user, 'subscription') and db_user.subscription: # Используем уже загруженную подписку await self._update_subscription_from_panel_data(db, db_user, panel_user) else: # Если подписки нет, создаем новую await self._create_subscription_from_panel_data(db, db_user, panel_user) _, uuid_mutation = self._ensure_user_remnawave_uuid( db_user, panel_user.get('uuid'), bot_users_by_uuid, ) stats["updated"] += 1 logger.debug(f"✅ Обновлён пользователь {telegram_id}") except Exception as user_error: logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}") stats["errors"] += 1 if uuid_mutation: uuid_mutation.rollback() if pending_uuid_mutations: for mutation in reversed(pending_uuid_mutations): mutation.rollback() pending_uuid_mutations.clear() try: await db.rollback() # Выполняем rollback при ошибке except: pass continue else: if uuid_mutation and uuid_mutation.has_changes(): pending_uuid_mutations.append(uuid_mutation) # Коммитим изменения каждые N пользователей для ускорения if (i + 1) % batch_size == 0: try: await db.commit() logger.debug(f"📦 Коммит изменений после обработки {i+1} пользователей") pending_uuid_mutations.clear() except Exception as commit_error: logger.error(f"❌ Ошибка коммита после обработки {i+1} пользователей: {commit_error}") await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() pending_uuid_mutations.clear() stats["errors"] += batch_size # Учитываем ошибки за всю группу # Коммитим оставшиеся изменения try: await db.commit() pending_uuid_mutations.clear() except Exception as final_commit_error: logger.error(f"❌ Ошибка финального коммита: {final_commit_error}") await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() pending_uuid_mutations.clear() if sync_type == "all": logger.info("🗑️ Деактивация подписок пользователей, отсутствующих в панели...") batch_size = 50 processed_count = 0 cleanup_uuid_mutations: List[_UUIDMapMutation] = [] for telegram_id, db_user in bot_users_by_telegram_id.items(): if telegram_id not in panel_telegram_ids and hasattr(db_user, 'subscription') and db_user.subscription: cleanup_mutation: Optional[_UUIDMapMutation] = None try: logger.info(f"🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)") subscription = db_user.subscription if db_user.remnawave_uuid: try: async with self.get_api_client() as api: devices_reset = await api.reset_user_devices(db_user.remnawave_uuid) if devices_reset: logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}") except Exception as hwid_error: logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}") try: from sqlalchemy import delete from app.database.models import SubscriptionServer await decrement_subscription_server_counts(db, subscription) await db.execute( delete(SubscriptionServer).where( SubscriptionServer.subscription_id == subscription.id ) ) logger.info(f"🗑️ Удалены серверы подписки для {telegram_id}") except Exception as servers_error: logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}") from app.database.models import SubscriptionStatus subscription.status = SubscriptionStatus.DISABLED.value subscription.is_trial = True subscription.end_date = datetime.utcnow() subscription.traffic_limit_gb = 0 subscription.traffic_used_gb = 0.0 subscription.device_limit = 1 subscription.connected_squads = [] subscription.autopay_enabled = False subscription.remnawave_short_uuid = None subscription.subscription_url = "" subscription.subscription_crypto_link = "" old_uuid = getattr(db_user, "remnawave_uuid", None) cleanup_mutation = _UUIDMapMutation(bot_users_by_uuid) if old_uuid: cleanup_mutation.remove_map_entry(old_uuid) cleanup_mutation.set_user_uuid(db_user, None) cleanup_mutation.set_user_updated_at(db_user, datetime.utcnow()) stats["deleted"] += 1 logger.info(f"✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)") processed_count += 1 except Exception as delete_error: logger.error(f"❌ Ошибка деактивации подписки {telegram_id}: {delete_error}") stats["errors"] += 1 if cleanup_mutation: cleanup_mutation.rollback() if cleanup_uuid_mutations: for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() cleanup_uuid_mutations.clear() try: await db.rollback() except: pass else: if cleanup_mutation and cleanup_mutation.has_changes(): cleanup_uuid_mutations.append(cleanup_mutation) # Коммитим изменения каждые N пользователей if processed_count % batch_size == 0: try: await db.commit() logger.debug(f"📦 Коммит изменений после деактивации {processed_count} подписок") cleanup_uuid_mutations.clear() except Exception as commit_error: logger.error(f"❌ Ошибка коммита после деактивации {processed_count} подписок: {commit_error}") await db.rollback() for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() cleanup_uuid_mutations.clear() stats["errors"] += batch_size break # Прерываем цикл при ошибке коммита else: # Увеличиваем счетчик для отслеживания прогресса processed_count += 1 # Коммитим оставшиеся изменения try: await db.commit() cleanup_uuid_mutations.clear() except Exception as final_commit_error: logger.error(f"❌ Ошибка финального коммита при деактивации: {final_commit_error}") await db.rollback() for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() cleanup_uuid_mutations.clear() logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, деактивировано {stats['deleted']}, ошибок {stats['errors']}") return stats except Exception as e: logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}") return {"created": 0, "updated": 0, "errors": 1, "deleted": 0} async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): try: from app.database.crud.subscription import create_subscription_no_commit from app.database.models import SubscriptionStatus expire_at_str = panel_user.get('expireAt', '') expire_at = self._parse_remnawave_date(expire_at_str) panel_status = panel_user.get('status', 'ACTIVE') current_time = self._now_utc() if panel_status == 'ACTIVE' and expire_at > current_time: status = SubscriptionStatus.ACTIVE elif expire_at <= current_time: status = SubscriptionStatus.EXPIRED else: status = SubscriptionStatus.DISABLED traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): for squad in active_squads: if isinstance(squad, dict) and 'uuid' in squad: squad_uuids.append(squad['uuid']) elif isinstance(squad, str): squad_uuids.append(squad) subscription_data = { 'user_id': user.id, 'status': status.value, 'is_trial': False, 'end_date': expire_at, 'traffic_limit_gb': traffic_limit_gb, 'traffic_used_gb': traffic_used_gb, 'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1, 'connected_squads': squad_uuids, 'remnawave_short_uuid': panel_user.get('shortUuid'), 'subscription_url': panel_user.get('subscriptionUrl', ''), 'subscription_crypto_link': ( panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') ) } subscription = await create_subscription_no_commit(db, **subscription_data) logger.info(f"✅ Подготовлена подписка для пользователя {user.telegram_id} до {expire_at}") except Exception as e: logger.error(f"❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}") try: from app.database.crud.subscription import create_subscription_no_commit from app.database.models import SubscriptionStatus basic_subscription = await create_subscription_no_commit( db=db, user_id=user.id, status=SubscriptionStatus.ACTIVE.value, is_trial=False, end_date=self._now_utc() + timedelta(days=30), traffic_limit_gb=0, traffic_used_gb=0.0, device_limit=1, connected_squads=[], remnawave_short_uuid=panel_user.get('shortUuid'), subscription_url=panel_user.get('subscriptionUrl', ''), subscription_crypto_link=( panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') ) ) logger.info(f"✅ Подготовлена базовая подписка для пользователя {user.telegram_id}") except Exception as basic_error: logger.error(f"❌ Ошибка создания базовой подписки: {basic_error}") async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): try: from app.database.crud.subscription import get_subscription_by_user_id from app.database.models import SubscriptionStatus # Сначала пытаемся использовать уже загруженную подписку, если она есть subscription = None try: # Проверяем, что подписка уже загружена (была загружена через selectinload) if hasattr(user, 'subscription') and user.subscription: subscription = user.subscription else: # В противном случае, получаем подписку через CRUD метод subscription = await get_subscription_by_user_id(db, user.id) except: # Если не удалось получить подписку через ленивую загрузку subscription = await get_subscription_by_user_id(db, user.id) if not subscription: await self._create_subscription_from_panel_data(db, user, panel_user) return panel_status = panel_user.get('status', 'ACTIVE') expire_at_str = panel_user.get('expireAt', '') if expire_at_str: expire_at = self._parse_remnawave_date(expire_at_str) if abs((subscription.end_date - expire_at).total_seconds()) > 60: subscription.end_date = expire_at logger.debug(f"Обновлена дата окончания подписки до {expire_at}") current_time = self._now_utc() if panel_status == 'ACTIVE' and subscription.end_date > current_time: new_status = SubscriptionStatus.ACTIVE.value elif subscription.end_date <= current_time: new_status = SubscriptionStatus.EXPIRED.value elif panel_status == 'DISABLED': new_status = SubscriptionStatus.DISABLED.value else: new_status = subscription.status if subscription.status != new_status: subscription.status = new_status logger.debug(f"Обновлен статус подписки: {new_status}") used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01: subscription.traffic_used_gb = traffic_used_gb logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB") traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 if subscription.traffic_limit_gb != traffic_limit_gb: subscription.traffic_limit_gb = traffic_limit_gb logger.debug(f"Обновлен лимит трафика: {traffic_limit_gb} GB") device_limit = panel_user.get('hwidDeviceLimit', 1) or 1 if subscription.device_limit != device_limit: subscription.device_limit = device_limit logger.debug(f"Обновлен лимит устройств: {device_limit}") new_short_uuid = panel_user.get('shortUuid') if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid: old_short_uuid = subscription.remnawave_short_uuid subscription.remnawave_short_uuid = new_short_uuid logger.debug( "Обновлен short UUID подписки пользователя %s: %s → %s", getattr(user, "telegram_id", "?"), old_short_uuid, new_short_uuid, ) panel_url = panel_user.get('subscriptionUrl', '') if not subscription.subscription_url or subscription.subscription_url != panel_url: subscription.subscription_url = panel_url panel_crypto_link = ( panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') ) if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: subscription.subscription_crypto_link = panel_crypto_link active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): for squad in active_squads: if isinstance(squad, dict) and 'uuid' in squad: squad_uuids.append(squad['uuid']) elif isinstance(squad, str): squad_uuids.append(squad) current_squads = set(subscription.connected_squads or []) new_squads = set(squad_uuids) if current_squads != new_squads: subscription.connected_squads = squad_uuids logger.debug(f"Обновлены подключенные сквады: {squad_uuids}") # Коммитим изменения позже, в основном цикле, чтобы уменьшить количество транзакций logger.debug(f"✅ Обновлена подписка для пользователя {user.telegram_id}") except Exception as e: logger.error(f"❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}") # Не делаем rollback, так как это может повлиять на другие операции # Ошибку прокидываем выше для корректной обработки в основном цикле raise async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]: try: stats = {"created": 0, "updated": 0, "errors": 0} batch_size = 100 offset = 0 async with self.get_api_client() as api: while True: users = await get_users_list(db, offset=offset, limit=batch_size) if not users: break for user in users: if not user.subscription: continue try: subscription = user.subscription hwid_limit = resolve_hwid_device_limit_for_payload(subscription) expire_at = self._safe_expire_at_for_panel(subscription.end_date) status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED username = settings.format_remnawave_username( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id, ) create_kwargs = dict( username=username, expire_at=expire_at, status=status, traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, telegram_id=user.telegram_id, description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: create_kwargs['hwid_device_limit'] = hwid_limit if user.remnawave_uuid: update_kwargs = dict( uuid=user.remnawave_uuid, status=status, expire_at=expire_at, traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: update_kwargs['hwid_device_limit'] = hwid_limit try: await api.update_user(**update_kwargs) stats["updated"] += 1 except RemnaWaveAPIError as api_error: if api_error.status_code == 404: logger.warning( "⚠️ Не найден пользователь %s в панели, создаем заново", user.remnawave_uuid, ) new_user = await api.create_user(**create_kwargs) user.remnawave_uuid = new_user.uuid subscription.remnawave_short_uuid = new_user.short_uuid stats["created"] += 1 else: raise else: new_user = await api.create_user(**create_kwargs) user.remnawave_uuid = new_user.uuid subscription.remnawave_short_uuid = new_user.short_uuid stats["created"] += 1 except Exception as e: logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}") stats["errors"] += 1 try: await db.commit() except Exception as commit_error: logger.error( "Ошибка фиксации транзакции при синхронизации в панель: %s", commit_error, ) await db.rollback() stats["errors"] += len(users) if len(users) < batch_size: break offset += batch_size logger.info( f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}" ) return stats except Exception as e: logger.error(f"Ошибка синхронизации пользователей в панель: {e}") return {"created": 0, "updated": 0, "errors": 1} async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]: try: async with self.get_api_client() as api: users = await api.get_user_by_telegram_id(telegram_id) if not users: return None user = users[0] return { "used_traffic_bytes": user.used_traffic_bytes, "used_traffic_gb": user.used_traffic_bytes / (1024**3), "lifetime_used_traffic_bytes": user.lifetime_used_traffic_bytes, "lifetime_used_traffic_gb": user.lifetime_used_traffic_bytes / (1024**3), "traffic_limit_bytes": user.traffic_limit_bytes, "traffic_limit_gb": user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, "subscription_url": user.subscription_url } except Exception as e: logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}") return None async def test_api_connection(self) -> Dict[str, Any]: if not self.is_configured: return { "status": "not_configured", "message": self.configuration_error or "RemnaWave API не настроен", "api_url": settings.REMNAWAVE_API_URL, } try: async with self.get_api_client() as api: system_stats = await api.get_system_stats() return { "status": "connected", "message": "Подключение успешно", "api_url": settings.REMNAWAVE_API_URL, "system_info": system_stats } except RemnaWaveAPIError as e: return { "status": "error", "message": f"Ошибка API: {e.message}", "status_code": e.status_code, "api_url": settings.REMNAWAVE_API_URL } except RemnaWaveConfigurationError as e: return { "status": "not_configured", "message": str(e), "api_url": settings.REMNAWAVE_API_URL, } except Exception as e: return { "status": "error", "message": f"Ошибка подключения: {str(e)}", "api_url": settings.REMNAWAVE_API_URL } async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: usage_data = await api.get_nodes_realtime_usage() return usage_data except Exception as e: logger.error(f"Ошибка получения актуального использования нод: {e}") return [] async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]: try: async with self.get_api_client() as api: squad = await api.get_internal_squad_by_uuid(squad_uuid) if squad: inbounds = [ asdict(inbound) if is_dataclass(inbound) else inbound for inbound in squad.inbounds or [] ] return { 'uuid': squad.uuid, 'name': squad.name, 'members_count': squad.members_count, 'inbounds_count': squad.inbounds_count, 'inbounds': inbounds } return None except Exception as e: logger.error(f"Error getting squad details: {e}") return None async def add_all_users_to_squad(self, squad_uuid: str) -> bool: try: async with self.get_api_client() as api: response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users') return response.get('response', {}).get('eventSent', False) except Exception as e: logger.error(f"Error adding users to squad: {e}") return False async def remove_all_users_from_squad(self, squad_uuid: str) -> bool: try: async with self.get_api_client() as api: response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users') return response.get('response', {}).get('eventSent', False) except Exception as e: logger.error(f"Error removing users from squad: {e}") return False async def get_all_inbounds(self) -> List[Dict]: try: async with self.get_api_client() as api: response = await api._make_request('GET', '/api/config-profiles/inbounds') inbounds_data = response.get('response', {}).get('inbounds', []) return [ { 'uuid': inbound['uuid'], 'tag': inbound['tag'], 'type': inbound['type'], 'network': inbound.get('network'), 'security': inbound.get('security'), 'port': inbound.get('port') } for inbound in inbounds_data ] except Exception as e: logger.error(f"Error getting all inbounds: {e}") return [] async def rename_squad(self, squad_uuid: str, new_name: str) -> bool: try: async with self.get_api_client() as api: data = { 'uuid': squad_uuid, 'name': new_name } response = await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: logger.error(f"Error renaming squad: {e}") return False async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: start_str = start_date.isoformat() + "Z" end_str = end_date.isoformat() + "Z" params = { 'start': start_str, 'end': end_str } usage_data = await api._make_request( 'GET', f'/api/nodes/usage/{node_uuid}/users/range', params=params ) return usage_data.get('response', []) except Exception as e: logger.error(f"Ошибка получения статистики использования ноды {node_uuid}: {e}") return [] async def get_node_statistics(self, node_uuid: str) -> Optional[Dict[str, Any]]: try: node = await self.get_node_details(node_uuid) if not node: return None realtime_stats = await self.get_nodes_realtime_usage() node_realtime = None for stats in realtime_stats: if stats.get('nodeUuid') == node_uuid: node_realtime = stats break end_date = datetime.now() start_date = end_date - timedelta(days=7) usage_history = await self.get_node_user_usage_by_range( node_uuid, start_date, end_date ) return { 'node': node, 'realtime': node_realtime, 'usage_history': usage_history, 'last_updated': datetime.now().isoformat() } except Exception as e: logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") async def validate_user_data_before_sync(self, panel_user) -> bool: try: if not panel_user.telegram_id: logger.debug(f"Нет telegram_id для пользователя {panel_user.uuid}") return False if not panel_user.uuid: logger.debug(f"Нет UUID для пользователя {panel_user.telegram_id}") return False if panel_user.telegram_id <= 0: logger.debug(f"Некорректный telegram_id: {panel_user.telegram_id}") return False return True except Exception as e: logger.error(f"Ошибка валидации данных пользователя: {e}") return False async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: try: logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}") if user.remnawave_uuid: try: async with self.get_api_client() as api: devices_reset = await api.reset_user_devices(user.remnawave_uuid) if devices_reset: logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}") except Exception as hwid_error: logger.warning(f"⚠️ Ошибка сброса HWID устройств: {hwid_error}") try: from sqlalchemy import delete from app.database.models import ( SubscriptionServer, Transaction, ReferralEarning, PromoCodeUse, SubscriptionStatus ) if user.subscription: await decrement_subscription_server_counts(db, user.subscription) await db.execute( delete(SubscriptionServer).where( SubscriptionServer.subscription_id == user.subscription.id ) ) logger.info(f"🗑️ Удалены серверы подписки для {user.telegram_id}") await db.execute( delete(Transaction).where(Transaction.user_id == user.id) ) logger.info(f"🗑️ Удалены транзакции для {user.telegram_id}") await db.execute( delete(ReferralEarning).where(ReferralEarning.user_id == user.id) ) await db.execute( delete(ReferralEarning).where(ReferralEarning.referral_id == user.id) ) logger.info(f"🗑️ Удалены реферальные доходы для {user.telegram_id}") await db.execute( delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id) ) logger.info(f"🗑️ Удалены использования промокодов для {user.telegram_id}") except Exception as records_error: logger.error(f"❌ Ошибка удаления связанных записей: {records_error}") try: user.balance_kopeks = 0 user.remnawave_uuid = None user.has_had_paid_subscription = False user.used_promocodes = 0 user.updated_at = self._now_utc() if user.subscription: user.subscription.status = SubscriptionStatus.DISABLED.value user.subscription.is_trial = True user.subscription.end_date = self._now_utc() user.subscription.traffic_limit_gb = 0 user.subscription.traffic_used_gb = 0.0 user.subscription.device_limit = 1 user.subscription.connected_squads = [] user.subscription.autopay_enabled = False user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE user.subscription.remnawave_short_uuid = None user.subscription.subscription_url = "" user.subscription.subscription_crypto_link = "" user.subscription.updated_at = self._now_utc() await db.commit() logger.info(f"✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user.telegram_id}") return True except Exception as cleanup_error: logger.error(f"❌ Ошибка финальной очистки пользователя: {cleanup_error}") await db.rollback() return False except Exception as e: logger.error(f"❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}") await db.rollback() return False async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]: try: stats = {"deactivated": 0, "errors": 0, "checked": 0} logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...") async with self.get_api_client() as api: panel_users_data = await api._make_request('GET', '/api/users') panel_users = panel_users_data['response']['users'] panel_telegram_ids = set() for panel_user in panel_users: telegram_id = panel_user.get('telegramId') if telegram_id: panel_telegram_ids.add(telegram_id) logger.info(f"📊 Найдено {len(panel_telegram_ids)} пользователей в панели") from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus page = 1 limit = 100 while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) if not subscriptions: break for subscription in subscriptions: try: stats["checked"] += 1 user = subscription.user if subscription.status == SubscriptionStatus.DISABLED.value: continue if user.telegram_id not in panel_telegram_ids: logger.info(f"🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)") cleanup_success = await self.force_cleanup_user_data(db, user) if cleanup_success: stats["deactivated"] += 1 else: stats["errors"] += 1 except Exception as sub_error: logger.error(f"❌ Ошибка обработки подписки {subscription.id}: {sub_error}") stats["errors"] += 1 page += 1 if len(subscriptions) < limit: break logger.info(f"🧹 Усиленная очистка завершена: проверено {stats['checked']}, деактивировано {stats['deactivated']}, ошибок {stats['errors']}") return stats except Exception as e: logger.error(f"❌ Критическая ошибка усиленной очистки подписок: {e}") return {"deactivated": 0, "errors": 1, "checked": 0} async def sync_subscription_statuses(self, db: AsyncSession) -> Dict[str, int]: try: stats = {"updated": 0, "errors": 0, "checked": 0} logger.info("🔄 Начинаем синхронизацию статусов подписок...") async with self.get_api_client() as api: panel_users_data = await api._make_request('GET', '/api/users') panel_users = panel_users_data['response']['users'] panel_users_dict = {} for panel_user in panel_users: telegram_id = panel_user.get('telegramId') if telegram_id: panel_users_dict[telegram_id] = panel_user logger.info(f"📊 Найдено {len(panel_users_dict)} пользователей в панели для синхронизации") from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus page = 1 limit = 100 while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) if not subscriptions: break for subscription in subscriptions: try: stats["checked"] += 1 user = subscription.user panel_user = panel_users_dict.get(user.telegram_id) if panel_user: await self._update_subscription_from_panel_data(db, user, panel_user) stats["updated"] += 1 else: if subscription.status != SubscriptionStatus.DISABLED.value: logger.info(f"🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)") from app.database.crud.subscription import deactivate_subscription await deactivate_subscription(db, subscription) stats["updated"] += 1 except Exception as sub_error: logger.error(f"❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}") stats["errors"] += 1 page += 1 if len(subscriptions) < limit: break logger.info(f"🔄 Синхронизация статусов завершена: проверено {stats['checked']}, обновлено {stats['updated']}, ошибок {stats['errors']}") return stats except Exception as e: logger.error(f"❌ Критическая ошибка синхронизации статусов: {e}") return {"updated": 0, "errors": 1, "checked": 0} async def validate_and_fix_subscriptions(self, db: AsyncSession) -> Dict[str, int]: try: stats = {"fixed": 0, "errors": 0, "checked": 0, "issues_found": 0} logger.info("🔍 Начинаем валидацию подписок...") from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus page = 1 limit = 100 while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) if not subscriptions: break for subscription in subscriptions: try: stats["checked"] += 1 user = subscription.user issues_fixed = 0 current_time = self._now_utc() if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value: logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}") subscription.status = SubscriptionStatus.EXPIRED.value issues_fixed += 1 if not subscription.remnawave_short_uuid and user.remnawave_uuid: try: async with self.get_api_client() as api: rw_user = await api.get_user_by_uuid(user.remnawave_uuid) if rw_user: subscription.remnawave_short_uuid = rw_user.short_uuid subscription.subscription_url = rw_user.subscription_url subscription.subscription_crypto_link = rw_user.happ_crypto_link logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}") issues_fixed += 1 except Exception as rw_error: logger.warning(f"⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}") if subscription.traffic_limit_gb < 0: subscription.traffic_limit_gb = 0 logger.info(f"🔧 Исправлен некорректный лимит трафика для {user.telegram_id}") issues_fixed += 1 if subscription.traffic_used_gb < 0: subscription.traffic_used_gb = 0.0 logger.info(f"🔧 Исправлено некорректное использование трафика для {user.telegram_id}") issues_fixed += 1 if subscription.device_limit <= 0: subscription.device_limit = 1 logger.info(f"🔧 Исправлен лимит устройств для {user.telegram_id}") issues_fixed += 1 if subscription.connected_squads is None: subscription.connected_squads = [] logger.info(f"🔧 Инициализирован список сквадов для {user.telegram_id}") issues_fixed += 1 if issues_fixed > 0: stats["issues_found"] += issues_fixed stats["fixed"] += 1 await db.commit() except Exception as sub_error: logger.error(f"❌ Ошибка валидации подписки {subscription.id}: {sub_error}") stats["errors"] += 1 await db.rollback() page += 1 if len(subscriptions) < limit: break logger.info(f"🔍 Валидация завершена: проверено {stats['checked']}, исправлено подписок {stats['fixed']}, найдено проблем {stats['issues_found']}, ошибок {stats['errors']}") return stats except Exception as e: logger.error(f"❌ Критическая ошибка валидации: {e}") return {"fixed": 0, "errors": 1, "checked": 0, "issues_found": 0} async def get_sync_recommendations(self, db: AsyncSession) -> Dict[str, Any]: try: recommendations = { "should_sync": False, "sync_type": "none", "reasons": [], "priority": "low", "estimated_time": "1-2 минуты" } from app.database.crud.user import get_users_list bot_users = await get_users_list(db, offset=0, limit=10000) users_without_uuid = sum(1 for user in bot_users if not user.remnawave_uuid and user.subscription) from app.database.crud.subscription import get_expired_subscriptions expired_subscriptions = await get_expired_subscriptions(db) active_expired = sum(1 for sub in expired_subscriptions if sub.status == "active") if users_without_uuid > 10: recommendations["should_sync"] = True recommendations["sync_type"] = "all" recommendations["priority"] = "high" recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с Remnawave") recommendations["estimated_time"] = "3-5 минут" if active_expired > 5: recommendations["should_sync"] = True if recommendations["sync_type"] == "none": recommendations["sync_type"] = "update_only" recommendations["priority"] = "medium" if recommendations["priority"] == "low" else recommendations["priority"] recommendations["reasons"].append(f"Найдено {active_expired} активных подписок с истекшим сроком") if not recommendations["should_sync"]: recommendations["sync_type"] = "update_only" recommendations["reasons"].append("Рекомендуется регулярная синхронизация данных") recommendations["estimated_time"] = "1-2 минуты" return recommendations except Exception as e: logger.error(f"❌ Ошибка получения рекомендаций: {e}") return { "should_sync": True, "sync_type": "all", "reasons": ["Ошибка анализа - рекомендуется полная синхронизация"], "priority": "medium", "estimated_time": "3-5 минут" } async def monitor_panel_status(self, bot) -> Dict[str, Any]: try: from app.utils.cache import cache previous_status = await cache.get("remnawave_panel_status") or "unknown" status_result = await self.check_panel_health() current_status = status_result.get("status", "offline") if current_status != previous_status and previous_status != "unknown": await self._send_status_change_notification( bot, previous_status, current_status, status_result ) await cache.set("remnawave_panel_status", current_status, expire=300) return status_result except Exception as e: logger.error(f"Ошибка мониторинга статуса панели Remnawave: {e}") return {"status": "error", "error": str(e)} async def _send_status_change_notification( self, bot, old_status: str, new_status: str, status_data: Dict[str, Any] ): try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(bot) details = { "api_url": status_data.get("api_url"), "response_time": status_data.get("response_time"), "last_check": status_data.get("last_check"), "users_online": status_data.get("users_online"), "nodes_online": status_data.get("nodes_online"), "total_nodes": status_data.get("total_nodes"), "old_status": old_status } if new_status == "offline": details["error"] = status_data.get("api_error") elif new_status == "degraded": issues = [] if status_data.get("response_time", 0) > 10: issues.append(f"Медленный отклик API ({status_data.get('response_time')}с)") if status_data.get("nodes_health") == "unhealthy": issues.append(f"Проблемы с нодами ({status_data.get('nodes_online')}/{status_data.get('total_nodes')} онлайн)") details["issues"] = issues await notification_service.send_remnawave_panel_status_notification( new_status, details ) logger.info(f"Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}") except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении статуса: {e}") async def send_manual_status_notification(self, bot, status: str, message: str = ""): try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(bot) details = { "api_url": settings.REMNAWAVE_API_URL, "last_check": datetime.utcnow(), "manual_message": message } if status == "maintenance": details["maintenance_reason"] = message or "Плановое обслуживание" await notification_service.send_remnawave_panel_status_notification(status, details) logger.info(f"Отправлено ручное уведомление о статусе панели: {status}") return True except Exception as e: logger.error(f"Ошибка отправки ручного уведомления: {e}") return False async def get_panel_status_summary(self) -> Dict[str, Any]: try: status_data = await self.check_panel_health() status_descriptions = { "online": "🟢 Панель работает нормально", "offline": "🔴 Панель недоступна", "degraded": "🟡 Панель работает со сбоями", "maintenance": "🔧 Панель на обслуживании" } status = status_data.get("status", "offline") summary = { "status": status, "description": status_descriptions.get(status, "❓ Статус неизвестен"), "response_time": status_data.get("response_time", 0), "api_available": status_data.get("api_available", False), "nodes_status": f"{status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)} нод онлайн", "users_online": status_data.get("users_online", 0), "last_check": status_data.get("last_check"), "has_issues": status in ["offline", "degraded"] } if status == "offline": summary["recommendation"] = "Проверьте подключение к серверу и работоспособность панели" elif status == "degraded": summary["recommendation"] = "Рекомендуется проверить состояние нод и производительность сервера" else: summary["recommendation"] = "Все системы работают нормально" return summary except Exception as e: logger.error(f"Ошибка получения сводки статуса панели: {e}") return { "status": "error", "description": "❌ Ошибка проверки статуса", "response_time": 0, "api_available": False, "nodes_status": "неизвестно", "users_online": 0, "last_check": datetime.utcnow(), "has_issues": True, "recommendation": "Обратитесь к системному администратору", "error": str(e) } async def check_panel_health(self) -> Dict[str, Any]: attempts = settings.get_maintenance_retry_attempts() attempts = max(1, attempts) last_result: Optional[Dict[str, Any]] = None last_error: Optional[Exception] = None for attempt in range(1, attempts + 1): try: start_time = datetime.utcnow() async with self.get_api_client() as api: try: system_stats = await api.get_system_stats() api_available = True api_error = None except Exception as e: api_available = False api_error = str(e) system_stats = {} try: nodes = await api.get_all_nodes() nodes_online = sum( 1 for node in nodes if node.is_connected and node.is_node_online ) total_nodes = len(nodes) nodes_health = "healthy" if nodes_online > 0 else "unhealthy" except Exception: nodes_online = 0 total_nodes = 0 nodes_health = "unknown" end_time = datetime.utcnow() response_time = (end_time - start_time).total_seconds() if not api_available: status = "offline" elif response_time > 10: status = "degraded" elif nodes_health == "unhealthy": status = "degraded" else: status = "online" result = { "status": status, "api_available": api_available, "api_error": api_error, "response_time": round(response_time, 2), "nodes_online": nodes_online, "total_nodes": total_nodes, "nodes_health": nodes_health, "users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0), "total_users": system_stats.get('users', {}).get('totalUsers', 0), "last_check": end_time, "api_url": settings.REMNAWAVE_API_URL, "attempts_used": attempt, } if result["api_available"]: if attempt > 1: logger.info("Панель Remnawave ответила с %s попытки", attempt) return result last_result = result if attempt < attempts: logger.warning( "Панель Remnawave недоступна (попытка %s/%s): %s", attempt, attempts, result.get("api_error") or "неизвестная ошибка", ) await asyncio.sleep(1) except Exception as error: last_error = error if attempt < attempts: logger.warning( "Ошибка проверки здоровья панели (попытка %s/%s): %s", attempt, attempts, error, ) await asyncio.sleep(1) continue logger.error(f"Ошибка проверки здоровья панели: {error}") if last_result is not None: return last_result error_message = str(last_error) if last_error else "Неизвестная ошибка" return { "status": "offline", "api_available": False, "api_error": error_message, "response_time": 0, "nodes_online": 0, "total_nodes": 0, "nodes_health": "unknown", "last_check": datetime.utcnow(), "api_url": settings.REMNAWAVE_API_URL, "attempts_used": attempts, }