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

2341 lines
111 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 logging
import os
import re
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
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,
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,
)
logger = logging.getLogger(__name__)
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
tz_name = os.getenv("TZ", "UTC")
try:
self._panel_timezone = ZoneInfo(tz_name)
except Exception:
logger.warning(
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
tz_name,
)
self._panel_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 не настроен"
)
@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_in_panel_timezone(self) -> datetime:
"""Возвращает текущее время без часового пояса в зоне панели."""
return datetime.now(self._panel_timezone).replace(tzinfo=None)
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
return self._now_in_panel_timezone() + 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)
localized_naive = localized.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
return localized_naive
except Exception as e:
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
return self._now_in_panel_timezone() + timedelta(days=30)
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)
# Используем извлеченное имя или дефолтное значение
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
else:
full_first_name = f"User {telegram_id}"
full_last_name = None
username = panel_user.get("username") or username_from_desc or f"user_{telegram_id}"
try:
db_user = await create_user_no_commit(
db=db,
telegram_id=telegram_id,
username=username,
first_name=full_first_name,
last_name=full_last_name,
language="ru",
)
return db_user, True
except IntegrityError as create_error:
logger.info(
"♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.",
telegram_id,
)
try:
await db.rollback()
except Exception:
# create_user уже выполняет 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)
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": system_stats.get('uptime', 0)
},
"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
}
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:
result.append({
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.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}
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
for i, panel_user in enumerate(unique_panel_users):
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
# Обновляем имя пользователя из описания панели
description = panel_user.get("description") or ""
first_name_from_desc, last_name_from_desc, username_from_desc = self._extract_user_data_from_description(description)
# Используем извлеченное имя или текущее
if first_name_from_desc:
full_first_name = first_name_from_desc
else:
full_first_name = db_user.first_name or f"User {telegram_id}"
if last_name_from_desc:
full_last_name = last_name_from_desc
else:
full_last_name = db_user.last_name
extracted_username = username_from_desc or panel_user.get("username")
if extracted_username:
username = extracted_username
else:
username = db_user.username or f"user_{telegram_id}"
updated_fields = []
if db_user.first_name != full_first_name:
db_user.first_name = full_first_name
updated_fields.append("first_name")
if db_user.last_name != full_last_name:
db_user.last_name = full_last_name
updated_fields.append("last_name")
if db_user.username != username:
db_user.username = username
updated_fields.append("username")
# Если пользователь был обновлен, сохраняем изменения
if updated_fields:
logger.info(f"🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}")
await db.flush() # Сохраняем изменения без коммита
if not db_user.remnawave_uuid:
await update_user(db, db_user, remnawave_uuid=panel_user.get('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}")
# Обновляем имя пользователя из описания панели
description = panel_user.get("description") or ""
first_name_from_desc, last_name_from_desc, username_from_desc = self._extract_user_data_from_description(description)
# Используем извлеченное имя или текущее
if first_name_from_desc:
full_first_name = first_name_from_desc
else:
full_first_name = db_user.first_name or f"User {telegram_id}"
if last_name_from_desc:
full_last_name = last_name_from_desc
else:
full_last_name = db_user.last_name
extracted_username = username_from_desc or panel_user.get("username")
if extracted_username:
username = extracted_username
else:
username = db_user.username or f"user_{telegram_id}"
updated_fields = []
if db_user.first_name != full_first_name:
db_user.first_name = full_first_name
updated_fields.append("first_name")
if db_user.last_name != full_last_name:
db_user.last_name = full_last_name
updated_fields.append("last_name")
if db_user.username != username:
db_user.username = username
updated_fields.append("username")
# Если пользователь был обновлен, сохраняем изменения
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)
if not db_user.remnawave_uuid:
await update_user(db, db_user, remnawave_uuid=panel_user.get('uuid'))
stats["updated"] += 1
logger.debug(f"✅ Обновлён пользователь {telegram_id}")
except Exception as user_error:
logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}")
stats["errors"] += 1
try:
await db.rollback() # Выполняем rollback при ошибке
except:
pass
continue
# Коммитим изменения каждые N пользователей для ускорения
if (i + 1) % batch_size == 0:
try:
await db.commit()
logger.debug(f"📦 Коммит изменений после обработки {i+1} пользователей")
except Exception as commit_error:
logger.error(f"❌ Ошибка коммита после обработки {i+1} пользователей: {commit_error}")
await db.rollback()
stats["errors"] += batch_size # Учитываем ошибки за всю группу
# Коммитим оставшиеся изменения
try:
await db.commit()
except Exception as final_commit_error:
logger.error(f"❌ Ошибка финального коммита: {final_commit_error}")
await db.rollback()
if sync_type == "all":
logger.info("🗑️ Деактивация подписок пользователей, отсутствующих в панели...")
batch_size = 50
processed_count = 0
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:
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 = ""
db_user.remnawave_uuid = None
stats["deleted"] += 1
logger.info(f"✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)")
processed_count += 1
# Коммитим изменения каждые N пользователей
if processed_count % batch_size == 0:
try:
await db.commit()
logger.debug(f"📦 Коммит изменений после деактивации {processed_count} подписок")
except Exception as commit_error:
logger.error(f"❌ Ошибка коммита после деактивации {processed_count} подписок: {commit_error}")
await db.rollback()
stats["errors"] += batch_size
break # Прерываем цикл при ошибке коммита
except Exception as delete_error:
logger.error(f"❌ Ошибка деактивации подписки {telegram_id}: {delete_error}")
stats["errors"] += 1
try:
await db.rollback()
except:
pass
else:
# Увеличиваем счетчик для отслеживания прогресса
processed_count += 1
# Коммитим оставшиеся изменения
try:
await db.commit()
except Exception as final_commit_error:
logger.error(f"❌ Ошибка финального коммита при деактивации: {final_commit_error}")
await db.rollback()
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_in_panel_timezone()
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 = panel_user.get('usedTrafficBytes', 0)
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_in_panel_timezone() + 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_in_panel_timezone()
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 = panel_user.get('usedTrafficBytes', 0)
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}")
if not subscription.remnawave_short_uuid:
subscription.remnawave_short_uuid = panel_user.get('shortUuid')
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}
users = await get_users_list(db, offset=0, limit=10000)
async with self.get_api_client() as api:
for user in users:
if not user.subscription:
continue
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if user.remnawave_uuid:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
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:
update_kwargs['hwid_device_limit'] = hwid_limit
await api.update_user(**update_kwargs)
stats["updated"] += 1
else:
username = f"user_{user.telegram_id}"
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
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
new_user = await api.create_user(**create_kwargs)
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
# Убираем немедленный коммит для пакетной обработки
# await db.commit()
stats["created"] += 1
except Exception as e:
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
stats["errors"] += 1
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:
return {
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.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_in_panel_timezone()
if user.subscription:
user.subscription.status = SubscriptionStatus.DISABLED.value
user.subscription.is_trial = True
user.subscription.end_date = self._now_in_panel_timezone()
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_in_panel_timezone()
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_in_panel_timezone()
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]:
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"
return {
"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
}
except Exception as e:
logger.error(f"Ошибка проверки здоровья панели: {e}")
return {
"status": "offline",
"api_available": False,
"api_error": str(e),
"response_time": 0,
"nodes_online": 0,
"total_nodes": 0,
"nodes_health": "unknown",
"last_check": datetime.utcnow(),
"api_url": settings.REMNAWAVE_API_URL
}