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

1104 lines
49 KiB
Python

import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.user import get_user_by_id
from app.database.models import Subscription, SubscriptionStatus, User
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = structlog.get_logger(__name__)
def get_traffic_reset_strategy(tariff=None):
"""Получает стратегию сброса трафика.
Args:
tariff: Объект тарифа. Если у тарифа задан traffic_reset_mode,
используется он, иначе глобальная настройка из конфига.
Returns:
TrafficLimitStrategy: Стратегия сброса трафика для RemnaWave API.
"""
from app.config import settings
strategy_mapping = {
'NO_RESET': 'NO_RESET',
'DAY': 'DAY',
'WEEK': 'WEEK',
'MONTH': 'MONTH',
'MONTH_ROLLING': 'MONTH_ROLLING',
}
# Проверяем настройку тарифа
if tariff is not None:
tariff_mode = getattr(tariff, 'traffic_reset_mode', None)
if tariff_mode is not None:
mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET')
logger.info(
'🔄 Стратегия сброса трафика из тарифа',
value=getattr(tariff, 'name', 'N/A'),
tariff_mode=tariff_mode,
mapped_strategy=mapped_strategy,
)
return getattr(TrafficLimitStrategy, mapped_strategy)
# Используем глобальную настройку
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info('🔄 Стратегия сброса трафика из конфига', strategy=strategy, mapped_strategy=mapped_strategy)
return getattr(TrafficLimitStrategy, mapped_strategy)
@dataclass
class PropagateSquadsResult:
"""Результат применения скводов тарифа к подпискам."""
total: int = 0
synced: int = 0
failed_ids: list[int] = field(default_factory=list)
class SubscriptionService:
def __init__(self):
self._config_error: str | None = None
self.api: RemnaWaveAPI | None = None
self._last_config_signature: tuple[str, ...] | None = None
self._refresh_configuration()
def _refresh_configuration(self) -> None:
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()
secret_key = (auth_params.get('secret_key') or '').strip() or None
username = (auth_params.get('username') or '').strip() or None
password = (auth_params.get('password') or '').strip() or None
caddy_token = (auth_params.get('caddy_token') or '').strip() or None
auth_type = (auth_params.get('auth_type') or 'api_key').strip()
config_signature = (
base_url,
api_key,
secret_key or '',
username or '',
password or '',
caddy_token or '',
auth_type,
)
if config_signature == self._last_config_signature:
return
if not base_url:
self._config_error = 'REMNAWAVE_API_URL не настроен'
self.api = None
elif not api_key:
self._config_error = 'REMNAWAVE_API_KEY не настроен'
self.api = None
else:
self._config_error = None
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
caddy_token=caddy_token,
auth_type=auth_type,
)
if self._config_error:
logger.warning(
'RemnaWave API недоступен: . Подписочный сервис будет работать в оффлайн-режиме.',
config_error=self._config_error,
)
self._last_config_signature = config_signature
@staticmethod
def _resolve_user_tag(subscription: Subscription) -> str | None:
if getattr(subscription, 'is_trial', False):
return settings.get_trial_user_tag()
return settings.get_paid_subscription_user_tag()
@property
def is_configured(self) -> bool:
return self._config_error is None
@property
def configuration_error(self) -> str | None:
return self._config_error
def _ensure_configured(self) -> None:
self._refresh_configuration()
if not self.api or not self.is_configured:
raise RemnaWaveAPIError(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
async def create_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: str | None = None,
) -> RemnaWaveUser | None:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error('Пользователь не найден', user_id=subscription.user_id)
return None
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
if not validation_success:
logger.error('Ошибка валидации подписки для пользователя', _format_user_log=self._format_user_log(user))
return None
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass # tariff может быть None или уже загружен
user_tag = self._resolve_user_tag(subscription)
# Определяем внешний сквад из тарифа
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
# Multi-tariff mode: each subscription has its own Remnawave user
if settings.is_multi_tariff_enabled():
updated_user = await self._create_or_update_remnawave_user_multi(
api,
user,
subscription,
user_tag=user_tag,
hwid_limit=hwid_limit,
ext_squad_uuid=ext_squad_uuid,
reset_traffic=reset_traffic,
reset_reason=reset_reason,
)
else:
updated_user = await self._create_or_update_remnawave_user_single(
api,
user,
subscription,
user_tag=user_tag,
hwid_limit=hwid_limit,
ext_squad_uuid=ext_squad_uuid,
reset_traffic=reset_traffic,
reset_reason=reset_reason,
)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
subscription.remnawave_uuid = updated_user.uuid
# Legacy field — keep in sync for single-mode backward compat
if not settings.is_multi_tariff_enabled():
user.remnawave_uuid = updated_user.uuid
await db.commit()
logger.info('✅ Создан/обновлен RemnaWave пользователь для подписки', subscription_id=subscription.id)
logger.info('🔗 Ссылка на подписку', subscription_url=updated_user.subscription_url)
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info('📊 Стратегия сброса трафика', strategy_name=strategy_name)
return updated_user
except RemnaWaveAPIError as e:
logger.error('Ошибка RemnaWave API', error=e)
return None
except Exception as e:
logger.error('Ошибка создания RemnaWave пользователя', error=e)
return None
async def _create_or_update_remnawave_user_multi(
self,
api: RemnaWaveAPI,
user: User,
subscription: Subscription,
*,
user_tag: str | None,
hwid_limit: int | None,
ext_squad_uuid: str | None,
reset_traffic: bool,
reset_reason: str | None,
) -> RemnaWaveUser:
"""Multi-tariff mode: each subscription gets its own Remnawave user."""
description = settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
common_kwargs = dict(
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
email=user.email,
description=description,
)
if subscription.connected_squads:
common_kwargs['active_internal_squads'] = subscription.connected_squads
if user_tag is not None:
common_kwargs['tag'] = user_tag
if hwid_limit is not None:
common_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
common_kwargs['external_squad_uuid'] = ext_squad_uuid
# If this subscription already has a Remnawave user — update it
if subscription.remnawave_uuid:
try:
existing = await api.get_user_by_uuid(subscription.remnawave_uuid)
if existing:
try:
await api.reset_user_devices(existing.uuid)
except Exception as hwid_error:
logger.warning('⚠️ Не удалось сбросить HWID', hwid_error=hwid_error)
updated = await api.update_user(uuid=existing.uuid, **common_kwargs)
if reset_traffic:
await self._reset_user_traffic(api, updated.uuid, user, reset_reason)
return updated
except Exception:
logger.warning(
'⚠️ Не удалось найти Remnawave юзера по UUID подписки, создаём нового',
subscription_id=subscription.id,
remnawave_uuid=subscription.remnawave_uuid,
)
# New subscription — create a NEW Remnawave user
base_username = settings.format_remnawave_username(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
# Use permanent short_id from subscription (generated at creation time)
username = f'{base_username}_{subscription.remnawave_short_id}'
updated_user = await api.create_user(username=username, **common_kwargs)
if reset_traffic:
await self._reset_user_traffic(api, updated_user.uuid, user, reset_reason)
return updated_user
async def _create_or_update_remnawave_user_single(
self,
api: RemnaWaveAPI,
user: User,
subscription: Subscription,
*,
user_tag: str | None,
hwid_limit: int | None,
ext_squad_uuid: str | None,
reset_traffic: bool,
reset_reason: str | None,
) -> RemnaWaveUser:
"""Single-subscription mode (legacy): one Remnawave user per bot user."""
description = settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
# Search for existing Remnawave user
existing_users = []
if user.remnawave_uuid:
try:
existing_user = await api.get_user_by_uuid(user.remnawave_uuid)
if existing_user:
existing_users = [existing_user]
except Exception:
pass
if not existing_users and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if not existing_users and user.email:
try:
existing_users = await api.get_user_by_email(user.email)
except Exception:
pass
common_kwargs = dict(
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
email=user.email,
description=description,
)
if subscription.connected_squads:
common_kwargs['active_internal_squads'] = subscription.connected_squads
if user_tag is not None:
common_kwargs['tag'] = user_tag
if hwid_limit is not None:
common_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
common_kwargs['external_squad_uuid'] = ext_squad_uuid
if existing_users:
logger.info(
'🔄 Найден существующий пользователь в панели для', _format_user_log=self._format_user_log(user)
)
remnawave_user = existing_users[0]
try:
await api.reset_user_devices(remnawave_user.uuid)
logger.info('🔧 Сброшены HWID устройства для', _format_user_log=self._format_user_log(user))
except Exception as hwid_error:
logger.warning('⚠️ Не удалось сбросить HWID', hwid_error=hwid_error)
updated_user = await api.update_user(uuid=remnawave_user.uuid, **common_kwargs)
if reset_traffic:
await self._reset_user_traffic(api, updated_user.uuid, user, reset_reason)
return updated_user
logger.info('🆕 Создаем нового пользователя в панели для', _format_user_log=self._format_user_log(user))
username = settings.format_remnawave_username(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
updated_user = await api.create_user(username=username, **common_kwargs)
if reset_traffic:
await self._reset_user_traffic(api, updated_user.uuid, user, reset_reason)
return updated_user
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: str | None = None,
sync_squads: bool = True,
) -> RemnaWaveUser | None:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error('Пользователь не найден', user_id=subscription.user_id)
return None
# Resolve the Remnawave UUID: prefer subscription-level in multi-tariff mode
if settings.is_multi_tariff_enabled():
remnawave_uuid = subscription.remnawave_uuid
if not remnawave_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot update panel',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return None
else:
remnawave_uuid = user.remnawave_uuid
if not remnawave_uuid:
logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id)
return None
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass # tariff может быть None или уже загружен
current_time = datetime.now(UTC)
# Определяем актуальный статус для отправки в RemnaWave
# НЕ меняем статус подписки здесь - это задача scheduled job
is_actually_active = (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date > current_time
)
# Логируем если статус и end_date не согласованы (для отладки)
if (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date <= current_time
):
logger.warning(
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now. Отправляем в RemnaWave как DISABLED, но НЕ меняем статус в БД.',
subscription_id=subscription.id,
end_date=subscription.end_date,
current_time=current_time,
)
user_tag = self._resolve_user_tag(subscription)
# Определяем внешний сквад из тарифа
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
uuid=remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
expire_at=subscription.end_date
if is_actually_active
else max(subscription.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
telegram_id=user.telegram_id,
email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
),
)
# Сквады отправляем только при явном sync_squads=True (propagate_squads и пр.)
# В рутинных обновлениях пропускаем — сквады уже назначены при создании подписки,
# а пересылка стейловых UUID вызывает FK violation → A039 в RemnaWave
if sync_squads and subscription.connected_squads:
update_kwargs['active_internal_squads'] = subscription.connected_squads
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад НЕ пересылаем в рутинных обновлениях — он уже назначен
# при создании подписки. Стейловый UUID вызывает FK violation → A039.
# Синхронизация сквадов происходит только при sync_squads=True.
if sync_squads and ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
if settings.is_multi_tariff_enabled():
reset_uuid = subscription.remnawave_uuid
if not reset_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, skipping traffic reset',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
else:
reset_uuid = user.remnawave_uuid
if reset_uuid:
await self._reset_user_traffic(
api,
reset_uuid,
user,
reset_reason,
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
status_text = 'активным' if is_actually_active else 'истёкшим'
logger.info(
'✅ Обновлен RemnaWave пользователь со статусом',
remnawave_uuid=remnawave_uuid,
status_text=status_text,
)
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info('📊 Стратегия сброса трафика', strategy_name=strategy_name)
return updated_user
except RemnaWaveAPIError as e:
logger.error('Ошибка RemnaWave API', error=e)
return None
except Exception as e:
logger.error('Ошибка обновления RemnaWave пользователя', error=e)
return None
@staticmethod
def _format_user_log(user) -> str:
"""Форматирует идентификатор пользователя для логов."""
if user.telegram_id:
return f'user {user.telegram_id}'
if user.email:
return f'user {user.id} ({user.email})'
return f'user {user.id}'
async def _reset_user_traffic(
self,
api: RemnaWaveAPI,
user_uuid: str,
user, # User object вместо telegram_id
reset_reason: str | None = None,
) -> None:
if not user_uuid:
return
try:
await api.reset_user_traffic(user_uuid)
reason_text = f' ({reset_reason})' if reset_reason else ''
logger.info(
'🔄 Сброшен трафик RemnaWave для', _format_user_log=self._format_user_log(user), reason_text=reason_text
)
except Exception as exc:
logger.warning(
'⚠️ Не удалось сбросить трафик RemnaWave для', _format_user_log=self._format_user_log(user), error=exc
)
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.get_api_client() as api:
await api.disable_user(user_uuid)
logger.info('✅ Отключен RemnaWave пользователь', user_uuid=user_uuid)
return True
except Exception as e:
error_msg = str(e).lower()
# "User already disabled" - считаем успехом
if 'already disabled' in error_msg:
logger.info('✅ RemnaWave пользователь уже отключен', user_uuid=user_uuid)
return True
logger.error('Ошибка отключения RemnaWave пользователя', error=e)
return False
async def delete_remnawave_user(self, user_uuid: str) -> bool:
"""Полное удаление пользователя из панели RemnaWave (хуки прекращаются)."""
try:
async with self.get_api_client() as api:
await api.delete_user(user_uuid)
logger.info('🗑 Удалён RemnaWave пользователь', user_uuid=user_uuid)
return True
except Exception as e:
error_msg = str(e).lower()
if 'not found' in error_msg or 'not exist' in error_msg:
logger.info('🗑 RemnaWave пользователь уже удалён', user_uuid=user_uuid)
return True
logger.error('Ошибка удаления RemnaWave пользователя', error=e, user_uuid=user_uuid)
return False
async def enable_remnawave_user(self, user_uuid: str) -> bool:
"""Включить пользователя в RemnaWave (реактивация)."""
try:
async with self.get_api_client() as api:
await api.enable_user(user_uuid)
logger.info('✅ Включен RemnaWave пользователь', user_uuid=user_uuid)
return True
except Exception as e:
error_msg = str(e).lower()
# "User already enabled" - считаем успехом
if 'already enabled' in error_msg:
logger.info('✅ RemnaWave пользователь уже включен', user_uuid=user_uuid)
return True
logger.error('Ошибка включения RemnaWave пользователя', error=e)
return False
async def get_remnawave_squads(self) -> list[dict] | None:
"""Получить список internal squads из RemnaWave."""
try:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
# Преобразуем в формат для sync_with_remnawave
result = []
for squad in squads:
result.append(
{
'uuid': squad.uuid,
'name': squad.name,
}
)
logger.info('✅ Получено серверов из RemnaWave', result_count=len(result))
return result
except Exception as e:
logger.error('Ошибка получения серверов из RemnaWave', error=e)
return None
async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return None
if settings.is_multi_tariff_enabled():
revoke_uuid = subscription.remnawave_uuid
if not revoke_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot revoke',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return None
else:
revoke_uuid = user.remnawave_uuid
if not revoke_uuid:
return None
async with self.get_api_client() as api:
updated_user = await api.revoke_user_subscription(revoke_uuid)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
logger.info('✅ Обновлена ссылка подписки для', _format_user_log=self._format_user_log(user))
return updated_user.subscription_url
except Exception as e:
logger.error('Ошибка обновления ссылки подписки', error=e)
return None
async def get_subscription_info(self, short_uuid: str) -> dict | None:
try:
async with self.get_api_client() as api:
info = await api.get_subscription_info(short_uuid)
return info
except Exception as e:
logger.error('Ошибка получения информации о подписке', error=e)
return None
async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
if settings.is_multi_tariff_enabled():
sync_uuid = subscription.remnawave_uuid
if not sync_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, cannot sync usage',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return False
else:
sync_uuid = user.remnawave_uuid
if not sync_uuid:
return False
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(sync_uuid)
if not remnawave_user:
return False
used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes)
subscription.traffic_used_gb = used_gb
await db.commit()
logger.debug('Синхронизирован трафик для подписки ГБ', subscription_id=subscription.id, used_gb=used_gb)
return True
except Exception as e:
logger.error('Ошибка синхронизации трафика', error=e)
return False
async def ensure_subscription_synced(
self,
db: AsyncSession,
subscription: Subscription,
) -> tuple[bool, str | None]:
"""
Проверяет и синхронизирует подписку с RemnaWave при необходимости.
Если subscription_url отсутствует или данные не синхронизированы,
пытается обновить/создать пользователя в RemnaWave.
Returns:
Tuple[bool, Optional[str]]: (успех, сообщение об ошибке)
"""
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error('Пользователь не найден для подписки', subscription_id=subscription.id)
return False, 'user_not_found'
# Проверяем, нужна ли синхронизация
sub_uuid = subscription.remnawave_uuid if settings.is_multi_tariff_enabled() else user.remnawave_uuid
needs_sync = not subscription.subscription_url or not sub_uuid
if not needs_sync:
# Проверяем, существует ли пользователь в RemnaWave
try:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(sub_uuid)
if not remnawave_user:
needs_sync = True
logger.warning(
'Пользователь не найден в RemnaWave, требуется синхронизация',
remnawave_uuid=sub_uuid,
)
except Exception as check_error:
logger.warning('Не удалось проверить пользователя в RemnaWave', check_error=check_error)
# Продолжаем, возможно проблема временная
if not needs_sync:
return True, None
logger.info(
'Синхронизация подписки с RemnaWave (subscription_url=, remnawave_uuid=)',
subscription_id=subscription.id,
subscription_url=bool(subscription.subscription_url),
remnawave_uuid=bool(sub_uuid),
)
# Пытаемся синхронизировать
result = None
if sub_uuid:
# Пробуем обновить существующего пользователя
result = await self.update_remnawave_user(
db,
subscription,
reset_traffic=False,
)
# Если update не удался (пользователь удалён из RemnaWave) — пробуем создать
if not result:
logger.warning(
'Не удалось обновить пользователя в RemnaWave, пробуем создать заново',
remnawave_uuid=sub_uuid,
)
# Сбрасываем старый UUID, create_remnawave_user установит новый
if settings.is_multi_tariff_enabled():
subscription.remnawave_uuid = None
else:
user.remnawave_uuid = None
result = await self.create_remnawave_user(
db,
subscription,
reset_traffic=False,
)
else:
# Создаём нового пользователя
result = await self.create_remnawave_user(
db,
subscription,
reset_traffic=False,
)
if result:
await db.refresh(subscription)
await db.refresh(user)
logger.info(
'Подписка успешно синхронизирована с RemnaWave. URL',
subscription_id=subscription.id,
subscription_url=subscription.subscription_url,
)
return True, None
logger.error('Не удалось синхронизировать подписку с RemnaWave', subscription_id=subscription.id)
return False, 'sync_failed'
except RemnaWaveAPIError as api_error:
logger.error(
'Ошибка RemnaWave API при синхронизации подписки', subscription_id=subscription.id, api_error=api_error
)
return False, 'api_error'
except Exception as e:
logger.error('Ошибка синхронизации подписки', subscription_id=subscription.id, error=e)
return False, 'unknown_error'
async def validate_and_clean_subscription(self, db: AsyncSession, subscription: Subscription, user: User) -> bool:
try:
needs_cleanup = False
user_log = self._format_user_log(user)
# In multi-tariff mode, validate per-subscription UUID, not user-level UUID
check_uuid = subscription.remnawave_uuid if settings.is_multi_tariff_enabled() else user.remnawave_uuid
if check_uuid:
try:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(check_uuid)
if not remnawave_user:
logger.warning(
'⚠️ UUID не найден в панели',
user_log=user_log,
remnawave_uuid=check_uuid,
)
needs_cleanup = True
elif (
user.telegram_id
and remnawave_user.telegram_id
and remnawave_user.telegram_id != user.telegram_id
):
logger.warning(
'⚠️ Несоответствие telegram_id для panel',
user_log=user_log,
telegram_id=remnawave_user.telegram_id,
)
needs_cleanup = True
except Exception as api_error:
logger.error('❌ Ошибка проверки пользователя в панели', api_error=api_error)
needs_cleanup = True
if subscription.remnawave_short_uuid and not check_uuid:
logger.warning('⚠️ У подписки есть short_uuid, но нет remnawave_uuid')
needs_cleanup = True
if needs_cleanup:
logger.info('🧹 Очищаем мусорные данные подписки для', user_log=user_log)
subscription.remnawave_short_uuid = None
subscription.remnawave_uuid = None
subscription.subscription_url = ''
subscription.subscription_crypto_link = ''
if not settings.is_multi_tariff_enabled():
user.remnawave_uuid = None
await db.commit()
logger.info('✅ Мусорные данные очищены для', user_log=user_log)
return True
except Exception as e:
logger.error('❌ Ошибка валидации подписки для', _format_user_log=self._format_user_log(user), error=e)
await db.rollback()
return False
async def get_countries_price_by_uuids(
self,
country_uuids: list[str],
db: AsyncSession,
*,
promo_group_id: int | None = None,
) -> tuple[int, list[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
total_price = 0
prices_list = []
for country_uuid in country_uuids:
server = await get_server_squad_by_uuid(db, country_uuid)
is_allowed = True
if promo_group_id is not None and server:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = promo_group_id in allowed_ids
if server and server.is_available and not server.is_full and is_allowed:
price = server.price_kopeks
total_price += price
prices_list.append(price)
logger.debug('🏷️ Страна ₽', display_name=server.display_name, price=price / 100)
else:
default_price = 0
total_price += default_price
prices_list.append(default_price)
logger.warning(
'⚠️ Сервер недоступен, используем базовую цену: ₽',
country_uuid=country_uuid,
default_price=default_price / 100,
)
logger.info('💰 Общая стоимость стран: ₽', total_price=total_price / 100)
return total_price, prices_list
except Exception as e:
logger.error('Ошибка получения цен стран', error=e)
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
def _gb_to_bytes(self, gb: int | None) -> int:
if not gb: # None or 0
return 0
return gb * 1024 * 1024 * 1024
def _bytes_to_gb(self, bytes_value: int) -> float:
if bytes_value == 0:
return 0.0
return bytes_value / (1024 * 1024 * 1024)
async def propagate_tariff_squads(
self, db: AsyncSession, tariff_id: int, new_squads: list[str], *, concurrency: int = 5
) -> PropagateSquadsResult:
"""Применяет изменение серверов тарифа к активным подпискам и синхронизирует с RemnaWave.
Если new_squads пустой — означает "все серверы", будут подставлены все доступные.
Синхронизация с RemnaWave выполняется параллельно с ограничением concurrency.
Паттерн: предзагрузка данных → параллельные API-вызовы → один commit.
"""
squads_to_set = list(new_squads)
if not squads_to_set:
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
squads_to_set = [s.squad_uuid for s in all_servers if s.squad_uuid]
result = await db.execute(
select(Subscription).where(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
)
)
subscriptions = result.scalars().all()
if not subscriptions:
return PropagateSquadsResult(total=0, synced=0)
for sub in subscriptions:
sub.connected_squads = squads_to_set
await db.commit()
# Предзагружаем пользователей и тарифы — никаких DB-операций внутри gather
user_ids = [sub.user_id for sub in subscriptions]
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
for sub in subscriptions:
try:
await db.refresh(sub, ['tariff'])
except Exception as exc:
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
sample_tariff = subscriptions[0].tariff or None
traffic_strategy = get_traffic_reset_strategy(sample_tariff)
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
failed_ids: list[int] = []
synced = 0
async with self.get_api_client() as api:
semaphore = asyncio.Semaphore(concurrency)
async def _sync_one(sub: Subscription) -> bool:
async with semaphore:
try:
user = users_map.get(sub.user_id)
if not user:
return False
if settings.is_multi_tariff_enabled():
remnawave_uuid = sub.remnawave_uuid
if not remnawave_uuid:
logger.warning(
'Multi-tariff: subscription has no remnawave_uuid, skipping squad sync',
subscription_id=sub.id,
user_id=sub.user_id,
)
return False
else:
remnawave_uuid = user.remnawave_uuid
if not remnawave_uuid:
return False
current_time = datetime.now(UTC)
is_actually_active = (
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and sub.end_date > current_time
)
user_tag = self._resolve_user_tag(sub)
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
update_kwargs = dict(
uuid=remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
expire_at=sub.end_date
if is_actually_active
else max(sub.end_date, current_time + timedelta(minutes=1)),
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
traffic_limit_strategy=traffic_strategy,
telegram_id=user.telegram_id,
email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
),
)
if sub.connected_squads:
update_kwargs['active_internal_squads'] = sub.connected_squads
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Не отправляем null — RemnaWave API не принимает null для externalSquadUuid (A039)
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
updated_user = await api.update_user(**update_kwargs)
# Сохраняем в памяти — commit будет после gather
sub.subscription_url = updated_user.subscription_url
sub.subscription_crypto_link = updated_user.happ_crypto_link
return True
except Exception as e:
logger.warning(
'Не удалось обновить сквады в RemnaWave',
subscription_id=sub.id,
user_id=sub.user_id,
error=e,
)
return False
results = await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
for i, success in enumerate(results):
if success:
synced += 1
else:
failed_ids.append(subscriptions[i].id)
# Один commit после всех API-вызовов
try:
await db.commit()
except Exception as commit_error:
logger.error('Ошибка фиксации транзакции при синхронизации скводов', error=commit_error)
await db.rollback()
failed_ids = [sub.id for sub in subscriptions]
synced = 0
propagate_result = PropagateSquadsResult(total=len(subscriptions), synced=synced, failed_ids=failed_ids)
if failed_ids:
logger.warning(
'Частичная синхронизация скводов с RemnaWave',
tariff_id=tariff_id,
total=propagate_result.total,
synced=synced,
failed_ids=failed_ids,
)
else:
logger.info(
'Обновлены сквады подписок для тарифа',
tariff_id=tariff_id,
total=propagate_result.total,
synced=synced,
)
return propagate_result