From bea83a3635bac13c4b4315706cabb787f4a29d41 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 02:10:34 +0300 Subject: [PATCH] Add RemnaWave squad migration API endpoints --- app/database/crud/server_squad.py | 32 +- app/handlers/admin/remnawave.py | 782 +++++++++++++++++++++++++++++- app/keyboards/admin.py | 6 + app/services/remnawave_service.py | 245 +++++++++- app/states.py | 7 + app/webapi/routes/remnawave.py | 88 ++++ app/webapi/schemas/remnawave.py | 31 ++ locales/en.json | 35 ++ locales/ru.json | 35 ++ 9 files changed, 1248 insertions(+), 13 deletions(-) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index f1dccd7f..88b52008 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -17,7 +17,14 @@ from sqlalchemy import ( from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User +from app.database.models import ( + PromoGroup, + ServerSquad, + SubscriptionServer, + Subscription, + SubscriptionStatus, + User, +) logger = logging.getLogger(__name__) @@ -495,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]: async def get_server_statistics(db: AsyncSession) -> dict: - + total_result = await db.execute(select(func.count(ServerSquad.id))) total_servers = total_result.scalar() - + available_result = await db.execute( select(func.count(ServerSquad.id)) .where(ServerSquad.is_available == True) @@ -537,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict: 'total_revenue_rubles': total_revenue_kopeks / 100 } + +async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int: + """Возвращает количество активных подписок, подключенных к указанному скваду.""" + + result = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + ] + ), + cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'), + ) + ) + + return result.scalar() or 0 + + async def add_user_to_servers( db: AsyncSession, server_squad_ids: List[int] diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index ec5b4629..d6167a27 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1,18 +1,24 @@ import logging +import math from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext -from app.states import SquadRenameStates, SquadCreateStates +from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User +from app.database.crud.server_squad import ( + count_active_users_for_squad, + get_all_server_squads, + get_server_squad_by_uuid, +) from app.keyboards.admin import ( get_admin_remnawave_keyboard, get_sync_options_keyboard, get_node_management_keyboard, get_confirmation_keyboard, get_squad_management_keyboard, get_squad_edit_keyboard ) from app.localization.texts import get_texts -from app.services.remnawave_service import RemnaWaveService +from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_bytes, format_datetime @@ -21,6 +27,769 @@ logger = logging.getLogger(__name__) squad_inbound_selections = {} squad_create_data = {} +MIGRATION_PAGE_SIZE = 8 + + +def _format_migration_server_label(texts, server) -> str: + status = ( + texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен") + if getattr(server, "is_available", True) + else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен") + ) + return texts.t( + "ADMIN_SQUAD_MIGRATION_SERVER_LABEL", + "{name} — 👥 {users} ({status})", + ).format(name=server.display_name, users=server.current_users, status=status) + + +def _build_migration_keyboard( + texts, + squads, + page: int, + total_pages: int, + stage: str, + *, + exclude_uuid: str = None, +): + prefix = "admin_migration_source" if stage == "source" else "admin_migration_target" + rows = [] + has_items = False + + button_template = texts.t( + "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON", + "🌍 {name} — 👥 {users} ({status})", + ) + + for squad in squads: + if exclude_uuid and squad.squad_uuid == exclude_uuid: + continue + + has_items = True + status = ( + texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅") + if getattr(squad, "is_available", True) + else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫") + ) + rows.append( + [ + types.InlineKeyboardButton( + text=button_template.format( + name=squad.display_name, + users=squad.current_users, + status=status, + ), + callback_data=f"{prefix}_{squad.squad_uuid}", + ) + ] + ) + + if total_pages > 1: + nav_buttons = [] + if page > 1: + nav_buttons.append( + types.InlineKeyboardButton( + text="⬅️", + callback_data=f"{prefix}_page_{page - 1}", + ) + ) + nav_buttons.append( + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_PAGE", + "Стр. {page}/{pages}", + ).format(page=page, pages=total_pages), + callback_data="admin_migration_page_info", + ) + ) + if page < total_pages: + nav_buttons.append( + types.InlineKeyboardButton( + text="➡️", + callback_data=f"{prefix}_page_{page + 1}", + ) + ) + rows.append(nav_buttons) + + rows.append( + [ + types.InlineKeyboardButton( + text=texts.CANCEL, + callback_data="admin_migration_cancel", + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=rows), has_items + + +async def _fetch_migration_page( + db: AsyncSession, + page: int, +): + squads, total = await get_all_server_squads( + db, + page=max(1, page), + limit=MIGRATION_PAGE_SIZE, + ) + total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE)) + + if page < 1: + page = 1 + if page > total_pages: + page = total_pages + squads, total = await get_all_server_squads( + db, + page=page, + limit=MIGRATION_PAGE_SIZE, + ) + total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE)) + + return squads, page, total_pages + + +@admin_required +@error_handler +async def show_squad_migration_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + + await state.clear() + + squads, page, total_pages = await _fetch_migration_page(db, page=1) + keyboard, has_items = _build_migration_keyboard( + texts, + squads, + page, + total_pages, + "source", + ) + + message = ( + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE", + "Выберите сквад, из которого нужно переехать:", + ) + ) + + if not has_items: + message += ( + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_NO_OPTIONS", + "Нет доступных сквадов. Добавьте новые или отмените операцию.", + ) + ) + + await state.set_state(SquadMigrationStates.selecting_source) + + await callback.message.edit_text( + message, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def paginate_migration_source( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + if await state.get_state() != SquadMigrationStates.selecting_source: + await callback.answer() + return + + try: + page = int(callback.data.split("_page_")[-1]) + except (ValueError, IndexError): + await callback.answer() + return + + squads, page, total_pages = await _fetch_migration_page(db, page=page) + texts = get_texts(db_user.language) + keyboard, has_items = _build_migration_keyboard( + texts, + squads, + page, + total_pages, + "source", + ) + + message = ( + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE", + "Выберите сквад, из которого нужно переехать:", + ) + ) + + if not has_items: + message += ( + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_NO_OPTIONS", + "Нет доступных сквадов. Добавьте новые или отмените операцию.", + ) + ) + + await callback.message.edit_text( + message, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_migration_source_selection( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + if await state.get_state() != SquadMigrationStates.selecting_source: + await callback.answer() + return + + if "_page_" in callback.data: + await callback.answer() + return + + source_uuid = callback.data.replace("admin_migration_source_", "", 1) + + texts = get_texts(db_user.language) + server = await get_server_squad_by_uuid(db, source_uuid) + + if not server: + await callback.answer( + texts.t( + "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND", + "Сквад не найден или недоступен.", + ), + show_alert=True, + ) + return + + await state.update_data( + source_uuid=server.squad_uuid, + source_display=_format_migration_server_label(texts, server), + ) + + squads, page, total_pages = await _fetch_migration_page(db, page=1) + keyboard, has_items = _build_migration_keyboard( + texts, + squads, + page, + total_pages, + "target", + exclude_uuid=server.squad_uuid, + ) + + message = ( + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", + "Источник: {source}", + ).format(source=_format_migration_server_label(texts, server)) + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", + "Выберите сквад, в который нужно переехать:", + ) + ) + + if not has_items: + message += ( + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", + "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", + ) + ) + + await state.set_state(SquadMigrationStates.selecting_target) + + await callback.message.edit_text( + message, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def paginate_migration_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + if await state.get_state() != SquadMigrationStates.selecting_target: + await callback.answer() + return + + try: + page = int(callback.data.split("_page_")[-1]) + except (ValueError, IndexError): + await callback.answer() + return + + data = await state.get_data() + source_uuid = data.get("source_uuid") + if not source_uuid: + await callback.answer() + return + + texts = get_texts(db_user.language) + + squads, page, total_pages = await _fetch_migration_page(db, page=page) + keyboard, has_items = _build_migration_keyboard( + texts, + squads, + page, + total_pages, + "target", + exclude_uuid=source_uuid, + ) + + source_display = data.get("source_display") or source_uuid + + message = ( + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", + "Источник: {source}", + ).format(source=source_display) + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", + "Выберите сквад, в который нужно переехать:", + ) + ) + + if not has_items: + message += ( + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", + "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", + ) + ) + + await callback.message.edit_text( + message, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def handle_migration_target_selection( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + current_state = await state.get_state() + if current_state != SquadMigrationStates.selecting_target: + await callback.answer() + return + + if "_page_" in callback.data: + await callback.answer() + return + + data = await state.get_data() + source_uuid = data.get("source_uuid") + + if not source_uuid: + await callback.answer() + return + + target_uuid = callback.data.replace("admin_migration_target_", "", 1) + + texts = get_texts(db_user.language) + + if target_uuid == source_uuid: + await callback.answer( + texts.t( + "ADMIN_SQUAD_MIGRATION_SAME_SQUAD", + "Нельзя выбрать тот же сквад.", + ), + show_alert=True, + ) + return + + target_server = await get_server_squad_by_uuid(db, target_uuid) + if not target_server: + await callback.answer( + texts.t( + "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND", + "Сквад не найден или недоступен.", + ), + show_alert=True, + ) + return + + source_display = data.get("source_display") or source_uuid + + users_to_move = await count_active_users_for_squad(db, source_uuid) + + await state.update_data( + target_uuid=target_server.squad_uuid, + target_display=_format_migration_server_label(texts, target_server), + migration_count=users_to_move, + ) + + await state.set_state(SquadMigrationStates.confirming) + + message_lines = [ + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов"), + "", + texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS", + "Проверьте параметры переезда:", + ), + texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", + "• Из: {source}", + ).format(source=source_display), + texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", + "• В: {target}", + ).format(target=_format_migration_server_label(texts, target_server)), + texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT", + "• Пользователей к переносу: {count}", + ).format(count=users_to_move), + "", + texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT", + "Подтвердите выполнение операции.", + ), + ] + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON", + "✅ Подтвердить", + ), + callback_data="admin_migration_confirm", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET", + "🔄 Изменить сервер назначения", + ), + callback_data="admin_migration_change_target", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.CANCEL, + callback_data="admin_migration_cancel", + ) + ], + ] + ) + + await callback.message.edit_text( + "\n".join(message_lines), + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def change_migration_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + data = await state.get_data() + source_uuid = data.get("source_uuid") + + if not source_uuid: + await callback.answer() + return + + await state.set_state(SquadMigrationStates.selecting_target) + + texts = get_texts(db_user.language) + squads, page, total_pages = await _fetch_migration_page(db, page=1) + keyboard, has_items = _build_migration_keyboard( + texts, + squads, + page, + total_pages, + "target", + exclude_uuid=source_uuid, + ) + + source_display = data.get("source_display") or source_uuid + + message = ( + texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", + "Источник: {source}", + ).format(source=source_display) + + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", + "Выберите сквад, в который нужно переехать:", + ) + ) + + if not has_items: + message += ( + "\n\n" + + texts.t( + "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", + "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", + ) + ) + + await callback.message.edit_text( + message, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_squad_migration( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + current_state = await state.get_state() + if current_state != SquadMigrationStates.confirming: + await callback.answer() + return + + data = await state.get_data() + source_uuid = data.get("source_uuid") + target_uuid = data.get("target_uuid") + + if not source_uuid or not target_uuid: + await callback.answer() + return + + texts = get_texts(db_user.language) + remnawave_service = RemnaWaveService() + + await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд...")) + + try: + result = await remnawave_service.migrate_squad_users( + db, + source_uuid=source_uuid, + target_uuid=target_uuid, + ) + except RemnaWaveConfigurationError as error: + message = texts.t( + "ADMIN_SQUAD_MIGRATION_API_ERROR", + "❌ RemnaWave API не настроен: {error}", + ).format(error=str(error)) + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", + "⬅️ В Remnawave", + ), + callback_data="admin_remnawave", + ) + ] + ] + ) + await callback.message.edit_text(message, reply_markup=reply_markup) + await state.clear() + return + + source_display = data.get("source_display") or source_uuid + target_display = data.get("target_display") or target_uuid + + if not result.get("success"): + error_message = result.get("message") or "" + error_code = result.get("error") or "unexpected" + message = texts.t( + "ADMIN_SQUAD_MIGRATION_ERROR", + "❌ Не удалось выполнить переезд (код: {code}). {details}", + ).format(code=error_code, details=error_message) + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", + "⬅️ В Remnawave", + ), + callback_data="admin_remnawave", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_NEW_BUTTON", + "🔁 Новый переезд", + ), + callback_data="admin_rw_migration", + ) + ], + ] + ) + await callback.message.edit_text(message, reply_markup=reply_markup) + await state.clear() + return + + message_lines = [ + texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"), + "", + texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format( + source=source_display + ), + texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format( + target=target_display + ), + "", + texts.t( + "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL", + "Найдено подписок: {count}", + ).format(count=result.get("total", 0)), + texts.t( + "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED", + "Перенесено: {count}", + ).format(count=result.get("updated", 0)), + ] + + panel_updated = result.get("panel_updated", 0) + panel_failed = result.get("panel_failed", 0) + + if panel_updated: + message_lines.append( + texts.t( + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED", + "Обновлено в панели: {count}", + ).format(count=panel_updated) + ) + if panel_failed: + message_lines.append( + texts.t( + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED", + "Не удалось обновить в панели: {count}", + ).format(count=panel_failed) + ) + + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_NEW_BUTTON", + "🔁 Новый переезд", + ), + callback_data="admin_rw_migration", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", + "⬅️ В Remnawave", + ), + callback_data="admin_remnawave", + ) + ], + ] + ) + + await callback.message.edit_text( + "\n".join(message_lines), + reply_markup=reply_markup, + disable_web_page_preview=True, + ) + await state.clear() + + +@admin_required +@error_handler +async def cancel_squad_migration( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.clear() + + message = texts.t( + "ADMIN_SQUAD_MIGRATION_CANCELLED", + "❌ Переезд отменен.", + ) + + reply_markup = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", + "⬅️ В Remnawave", + ), + callback_data="admin_remnawave", + ) + ] + ] + ) + + await callback.message.edit_text(message, reply_markup=reply_markup) + await callback.answer() + + +@admin_required +@error_handler +async def handle_migration_page_info( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."), + show_alert=False, + ) + @admin_required @error_handler async def show_remnawave_menu( @@ -1920,6 +2689,15 @@ def register_handlers(dp: Dispatcher): 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_all_users, F.data == "sync_all_users") + dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration") + dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_")) + dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_")) + dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_")) + dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_")) + dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target") + dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm") + dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel") + dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info") dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads") dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_")) dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 076fe30e..a1d9e3a8 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -674,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_rw_squads" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"), + callback_data="admin_rw_migration" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"), diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 6ec15eea..84e90c6f 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1,7 +1,7 @@ import logging import os import re -from contextlib import asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from datetime import datetime, timedelta from typing import Any, Dict, List, Optional @@ -12,7 +12,8 @@ from app.external.remnawave_api import ( RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad, RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError ) -from sqlalchemy import delete +from sqlalchemy import and_, cast, delete, func, select, update, String +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user from app.database.crud.subscription import ( @@ -20,9 +21,16 @@ from app.database.crud.subscription import ( 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, SubscriptionServer, Transaction, ReferralEarning, - PromoCodeUse, SubscriptionStatus + User, + Subscription, + SubscriptionServer, + Transaction, + ReferralEarning, + PromoCodeUse, + SubscriptionStatus, + ServerSquad, ) logger = logging.getLogger(__name__) @@ -481,16 +489,237 @@ class RemnaWaveService: 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} diff --git a/app/states.py b/app/states.py index b7eb5765..0d10a629 100644 --- a/app/states.py +++ b/app/states.py @@ -166,6 +166,13 @@ class SquadCreateStates(StatesGroup): class SquadRenameStates(StatesGroup): waiting_for_new_name = State() + +class SquadMigrationStates(StatesGroup): + selecting_source = State() + selecting_target = State() + confirming = State() + + class AdminSubmenuStates(StatesGroup): in_users_submenu = State() in_promo_submenu = State() diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 35bfc0c3..421db6d3 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.server_squad import ( + count_active_users_for_squad, + get_server_squad_by_uuid, +) + from ..dependencies import get_db_session, require_api_token from ..schemas.remnawave import ( RemnaWaveConnectionStatus, @@ -22,6 +27,10 @@ from ..schemas.remnawave import ( RemnaWaveSquadActionRequest, RemnaWaveSquadCreateRequest, RemnaWaveSquadListResponse, + RemnaWaveSquadMigrationPreviewResponse, + RemnaWaveSquadMigrationRequest, + RemnaWaveSquadMigrationResponse, + RemnaWaveSquadMigrationStats, RemnaWaveSquadUpdateRequest, RemnaWaveStatusResponse, RemnaWaveSystemStatsResponse, @@ -372,6 +381,30 @@ async def get_user_traffic( return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats) +@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse) +async def preview_squad_migration( + squad_uuid: str, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> RemnaWaveSquadMigrationPreviewResponse: + service = _get_service() + _ensure_service_configured(service) + + squad = await get_server_squad_by_uuid(db, squad_uuid) + if not squad: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден") + + users_to_migrate = await count_active_users_for_squad(db, squad_uuid) + + return RemnaWaveSquadMigrationPreviewResponse( + squad_uuid=squad.squad_uuid, + squad_name=squad.display_name, + current_users=squad.current_users or 0, + max_users=squad.max_users, + users_to_migrate=users_to_migrate, + ) + + @router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse) async def sync_from_panel( payload: RemnaWaveSyncFromPanelRequest, @@ -454,3 +487,58 @@ async def get_sync_recommendations( data = await service.get_sync_recommendations(db) detail = "Рекомендации получены" return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data) + + +@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse) +async def migrate_squad( + payload: RemnaWaveSquadMigrationRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> RemnaWaveSquadMigrationResponse: + service = _get_service() + _ensure_service_configured(service) + + source_uuid = payload.source_uuid.strip() + target_uuid = payload.target_uuid.strip() + + if source_uuid == target_uuid: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают") + + source = await get_server_squad_by_uuid(db, source_uuid) + if not source: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден") + + target = await get_server_squad_by_uuid(db, target_uuid) + if not target: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден") + + try: + result = await service.migrate_squad_users( + db, + source_uuid=source.squad_uuid, + target_uuid=target.squad_uuid, + ) + except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + + if not result.get("success"): + detail = result.get("message") or "Не удалось выполнить переезд" + return RemnaWaveSquadMigrationResponse( + success=False, + detail=detail, + error=result.get("error"), + ) + + stats = RemnaWaveSquadMigrationStats( + source_uuid=source.squad_uuid, + target_uuid=target.squad_uuid, + total=result.get("total", 0), + updated=result.get("updated", 0), + panel_updated=result.get("panel_updated", 0), + panel_failed=result.get("panel_failed", 0), + source_removed=result.get("source_removed", 0), + target_added=result.get("target_added", 0), + ) + + detail = result.get("message") or "Переезд выполнен" + return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats) diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py index 4236d4aa..bc41a41c 100644 --- a/app/webapi/schemas/remnawave.py +++ b/app/webapi/schemas/remnawave.py @@ -169,3 +169,34 @@ class RemnaWaveGenericSyncResponse(BaseModel): success: bool detail: Optional[str] = None data: Optional[Dict[str, Any]] = None + + +class RemnaWaveSquadMigrationPreviewResponse(BaseModel): + squad_uuid: str + squad_name: str + current_users: int + max_users: Optional[int] = None + users_to_migrate: int + + +class RemnaWaveSquadMigrationRequest(BaseModel): + source_uuid: str + target_uuid: str + + +class RemnaWaveSquadMigrationStats(BaseModel): + source_uuid: str + target_uuid: str + total: int = 0 + updated: int = 0 + panel_updated: int = 0 + panel_failed: int = 0 + source_removed: int = 0 + target_added: int = 0 + + +class RemnaWaveSquadMigrationResponse(BaseModel): + success: bool + detail: Optional[str] = None + error: Optional[str] = None + data: Optional[RemnaWaveSquadMigrationStats] = None diff --git a/locales/en.json b/locales/en.json index 3444c456..217035a6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -870,7 +870,42 @@ "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes", "ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization", "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads", + "ADMIN_REMNAWAVE_MIGRATION": "🚚 Migration", "ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic", + "ADMIN_SQUAD_MIGRATION_TITLE": "🚚 Squad migration", + "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Choose the squad you want to migrate from:", + "ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "No squads available. Add new ones or cancel the operation.", + "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Available", + "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Unavailable", + "ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})", + "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})", + "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅", + "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫", + "ADMIN_SQUAD_MIGRATION_PAGE": "Page {page}/{pages}", + "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Source: {source}", + "ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Choose the destination squad:", + "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "No other squads available. Cancel or create new squads.", + "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Squad not found or unavailable.", + "ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "You can't choose the same squad.", + "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Review the migration parameters:", + "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• From: {source}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• To: {target}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Users to migrate: {count}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Confirm the operation.", + "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Confirm", + "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Change destination", + "ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Starting migration...", + "ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API is not configured: {error}", + "ADMIN_SQUAD_MIGRATION_ERROR": "❌ Failed to migrate (code: {code}). {details}", + "ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 New migration", + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ Back to Remnawave", + "ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Migration completed", + "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Subscriptions matched: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Migrated: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Updated in panel: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Panel update failed: {count}", + "ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Migration cancelled.", + "ADMIN_SQUAD_MIGRATION_PAGE_HINT": "This is the current page.", "ADMIN_STATS_USERS": "👥 Users", "ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions", "ADMIN_STATS_REVENUE": "💰 Revenue", diff --git a/locales/ru.json b/locales/ru.json index 47242a4d..2348dd4b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -872,7 +872,42 @@ "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами", "ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация", "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами", + "ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд", "ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик", + "ADMIN_SQUAD_MIGRATION_TITLE": "🚚 Переезд сквадов", + "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Выберите сквад, из которого нужно переехать:", + "ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "Нет доступных сквадов. Добавьте новые или отмените операцию.", + "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Доступен", + "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Недоступен", + "ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})", + "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})", + "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅", + "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫", + "ADMIN_SQUAD_MIGRATION_PAGE": "Стр. {page}/{pages}", + "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Источник: {source}", + "ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Выберите сквад, в который нужно переехать:", + "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", + "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Сквад не найден или недоступен.", + "ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "Нельзя выбрать тот же сквад.", + "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Проверьте параметры переезда:", + "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• Из: {source}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• В: {target}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Пользователей к переносу: {count}", + "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Подтвердите выполнение операции.", + "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Подтвердить", + "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Изменить сервер назначения", + "ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Запускаю переезд...", + "ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API не настроен: {error}", + "ADMIN_SQUAD_MIGRATION_ERROR": "❌ Не удалось выполнить переезд (код: {code}). {details}", + "ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 Новый переезд", + "ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ В Remnawave", + "ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Переезд завершен", + "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Найдено подписок: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Перенесено: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Обновлено в панели: {count}", + "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Не удалось обновить в панели: {count}", + "ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Переезд отменен.", + "ADMIN_SQUAD_MIGRATION_PAGE_HINT": "Это текущая страница.", "ADMIN_STATS_USERS": "👥 Пользователи", "ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_STATS_REVENUE": "💰 Доходы",