Files
remnawave-bedolaga-telegram…/app/services/remnawave_service.py

2612 lines
121 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}