diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index e63c1c80..17acf74f 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -2295,6 +2295,12 @@ async def show_sync_options( callback_data="sync_all_users", ) ], + [ + types.InlineKeyboardButton( + text="🔄 Обратная синхронизация", + callback_data="sync_to_panel", + ) + ], [ types.InlineKeyboardButton( text="⚙️ Настройки автосинхронизации", @@ -2311,6 +2317,94 @@ async def show_sync_options( await callback.answer() +@admin_required +@error_handler +async def sync_users_to_panel( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + + progress_text = """ +🔄 Выполняется обратная синхронизация... + +📋 Этапы: +• Загрузка ВСЕХ пользователей из бота +• Проверка существования в панели Remnawave +• Создание новых пользователей в панели +• Обновление данных существующих пользователей +• Настройка подписок и трафика + +⏳ Пожалуйста, подождите... +""" + + await callback.message.edit_text(progress_text, reply_markup=None) + + remnawave_service = RemnaWaveService() + stats = await remnawave_service.sync_users_to_panel(db) + + total_operations = stats['created'] + stats['updated'] + + if stats['errors'] == 0: + status_emoji = "✅" + status_text = "успешно завершена" + elif stats['errors'] < total_operations: + status_emoji = "⚠️" + status_text = "завершена с предупреждениями" + else: + status_emoji = "❌" + status_text = "завершена с ошибками" + + text = f""" +{status_emoji} Обратная синхронизация {status_text} + +📊 Результат: +• 🆕 Создано в панели: {stats['created']} +• 🔄 Обновлено в панели: {stats['updated']} +• ❌ Ошибок: {stats['errors']} +""" + + if stats['errors'] > 0: + text += f""" + +⚠️ Внимание: +Некоторые операции завершились с ошибками. +Проверьте логи для получения подробной информации. +""" + + text += f""" + +💡 Рекомендации: +• Обратная синхронизация выполнена +• Все пользователи из бота теперь в панели Remnawave +• Подписки пользователей синхронизированы (если есть) +""" + + keyboard = [] + + if stats['errors'] > 0: + keyboard.append([ + types.InlineKeyboardButton( + text="🔄 Повторить синхронизацию", + callback_data="sync_to_panel" + ) + ]) + + keyboard.extend([ + [ + types.InlineKeyboardButton(text="📊 Статистика системы", callback_data="admin_rw_system"), + types.InlineKeyboardButton(text="🌐 Ноды", callback_data="admin_rw_nodes") + ], + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + @admin_required @error_handler async def show_auto_sync_settings( @@ -3120,6 +3214,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(manage_node, F.data.startswith("node_restart_")) dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes") dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync") + dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel") dp.callback_query.register(show_auto_sync_settings, F.data == "admin_rw_auto_sync") dp.callback_query.register(toggle_auto_sync_setting, F.data == "remnawave_auto_sync_toggle") dp.callback_query.register(prompt_auto_sync_schedule, F.data == "remnawave_auto_sync_times") diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 4c066533..3ed94d57 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -147,6 +147,12 @@ class RemnaWaveService: password=auth_params.get("password") ) + def _gb_to_bytes(self, gb: int) -> int: + """Конвертирует гигабайты в байты""" + if gb == 0: + return 0 + return gb * 1024 * 1024 * 1024 + @property def is_configured(self) -> bool: return self._config_error is None @@ -2500,5 +2506,132 @@ class RemnaWaveService: "api_url": settings.REMNAWAVE_API_URL, "attempts_used": attempts, } - + + async def sync_users_to_panel(self, db: AsyncSession, limit: Optional[int] = None) -> Dict[str, int]: + """ + Синхронизирует пользователей бота в RemnaWave панель (бот → RemnaWave) + """ + from datetime import datetime, timedelta + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from app.database.models import User, Subscription + from app.external.remnawave_api import UserStatus, TrafficLimitStrategy + + stats = {"created": 0, "updated": 0, "errors": 0, "total_processed": 0} + + try: + # Получаем всех пользователей с их подписками + users_query = select(User).options(selectinload(User.subscription)) + + if limit: + users_query = users_query.limit(limit) + + result = await db.execute(users_query) + users = result.scalars().unique().all() + + logger.info(f"🚀 Начинаем синхронизацию {len(users)} пользователей в RemnaWave") + + async with self.get_api_client() as api: + for user in users: + stats["total_processed"] += 1 + + try: + # Проверяем, есть ли уже пользователь в RemnaWave + existing_users = await api.get_user_by_telegram_id(user.telegram_id) + + if existing_users: + # Пользователь уже существует - обновляем его данные + remnawave_user = existing_users[0] + update_kwargs = { + 'uuid': remnawave_user.uuid, + 'description': settings.format_remnawave_user_description( + full_name=user.full_name, + username=user.username, + telegram_id=user.telegram_id + ) + } + + # Если у пользователя есть активная подписка, обновляем и её параметры + if hasattr(user, 'subscription') and user.subscription: + from app.services.subscription_service import get_traffic_reset_strategy + + update_kwargs.update({ + 'status': UserStatus.ACTIVE if user.subscription.end_date > datetime.utcnow() else UserStatus.DISABLED, + 'expire_at': user.subscription.end_date, + 'traffic_limit_bytes': self._gb_to_bytes(user.subscription.traffic_limit_gb), + 'traffic_limit_strategy': get_traffic_reset_strategy(), + 'active_internal_squads': user.subscription.connected_squads + }) + + # Устанавливаем лимит устройств + from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload + hwid_limit = resolve_hwid_device_limit_for_payload(user.subscription) + if hwid_limit is not None: + update_kwargs['hwid_device_limit'] = hwid_limit + + await api.update_user(**update_kwargs) + stats["updated"] += 1 + + logger.info(f"🔄 Пользователь {user.telegram_id} обновлен в RemnaWave") + + else: + # Создаем нового пользователя в RemnaWave + username = settings.format_remnawave_username( + full_name=user.full_name, + username=user.username, + telegram_id=user.telegram_id, + ) + + # Подготовим параметры для создания + create_kwargs = { + 'username': username, + 'telegram_id': user.telegram_id, + 'description': settings.format_remnawave_user_description( + full_name=user.full_name, + username=user.username, + telegram_id=user.telegram_id + ) + } + + # Если у пользователя есть активная подписка, используем её данные + if hasattr(user, 'subscription') and user.subscription: + from app.services.subscription_service import get_traffic_reset_strategy + + create_kwargs.update({ + 'status': UserStatus.ACTIVE if user.subscription.end_date > datetime.utcnow() else UserStatus.DISABLED, + 'expire_at': user.subscription.end_date, + 'traffic_limit_bytes': self._gb_to_bytes(user.subscription.traffic_limit_gb), + 'traffic_limit_strategy': get_traffic_reset_strategy(), + 'active_internal_squads': user.subscription.connected_squads + }) + + # Устанавливаем лимит устройств + from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload + hwid_limit = resolve_hwid_device_limit_for_payload(user.subscription) + if hwid_limit is not None: + create_kwargs['hwid_device_limit'] = hwid_limit + else: + # Для пользователей без подписки - создаем с базовыми параметрами + create_kwargs.update({ + 'status': UserStatus.DISABLED, # По умолчанию отключаем + 'expire_at': datetime.utcnow() + timedelta(days=30), # 30 дней по умолчанию + 'traffic_limit_bytes': 0, # 0 трафика по умолчанию + 'traffic_limit_strategy': TrafficLimitStrategy.NO_RESET + }) + + await api.create_user(**create_kwargs) + stats["created"] += 1 + + logger.info(f"✅ Пользователь {user.telegram_id} создан в RemnaWave") + + except Exception as user_error: + logger.error(f"❌ Ошибка синхронизации пользователя {user.telegram_id}: {user_error}") + stats["errors"] += 1 + + logger.info(f"✅ Синхронизация завершена. Создано: {stats['created']}, Обновлено: {stats['updated']}, Ошибок: {stats['errors']}") + return stats + + except Exception as e: + logger.error(f"❌ Ошибка синхронизации пользователей в RemnaWave: {e}") + raise e diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 421db6d3..7dd2d54e 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -428,12 +428,13 @@ async def sync_from_panel( async def sync_to_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), + limit: int = Query(None, ge=1, le=10000, description="Ограничение на количество пользователей для синхронизации") ) -> RemnaWaveGenericSyncResponse: service = _get_service() _ensure_service_configured(service) - stats = await service.sync_users_to_panel(db) - detail = "Синхронизация в панель выполнена" + stats = await service.sync_users_to_panel(db, limit=limit) + detail = f"Синхронизация в панель выполнена (лимит: {limit if limit else 'все'})" return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) diff --git a/docker-compose.yml b/docker-compose.yml index 0ad610be..128831d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] interval: 30s @@ -27,7 +27,7 @@ services: volumes: - redis_data:/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s @@ -74,7 +74,7 @@ services: ports: - "${WEB_API_PORT:-8080}:8080" networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"] interval: 60s @@ -89,9 +89,7 @@ volumes: driver: local networks: - bot_network: + remnawave-network: + name: remnawave-network driver: bridge - ipam: - config: - - subnet: 172.20.0.0/16 - gateway: 172.20.0.1 + external: true