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