diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index 85f1dc58..15af5746 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -1,11 +1,17 @@ import logging from typing import Iterable, List, Optional, Sequence, Tuple -from sqlalchemy import select, and_, func, update, delete, text +from sqlalchemy import select, func, update, delete, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription +from app.database.models import ( + PromoGroup, + ServerSquad, + SubscriptionServer, + Subscription, + User, +) logger = logging.getLogger(__name__) @@ -228,14 +234,48 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: return True +async def get_server_users( + db: AsyncSession, + server_id: int, +) -> List[dict]: + + result = await db.execute( + select(User, Subscription, SubscriptionServer) + .join(Subscription, Subscription.user_id == User.id) + .join( + SubscriptionServer, + SubscriptionServer.subscription_id == Subscription.id, + ) + .where(SubscriptionServer.server_squad_id == server_id) + .order_by(User.id) + ) + + users_info: List[dict] = [] + for user, subscription, link in result.all(): + users_info.append( + { + "user_id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "first_name": user.first_name, + "subscription_id": subscription.id, + "subscription_status": subscription.status, + "subscription_end_date": subscription.end_date, + "connected_at": link.connected_at, + } + ) + + return users_info + + async def sync_with_remnawave( db: AsyncSession, remnawave_squads: List[dict] ) -> Tuple[int, int, int]: - + created = 0 updated = 0 - disabled = 0 + removed = 0 existing_servers = {} result = await db.execute(select(ServerSquad)) @@ -265,15 +305,64 @@ async def sync_with_remnawave( ) created += 1 - for uuid, server in existing_servers.items(): - if uuid not in remnawave_uuids and server.is_available: - server.is_available = False - disabled += 1 - + missing_servers = [ + server for uuid, server in existing_servers.items() + if uuid not in remnawave_uuids + ] + + if missing_servers: + missing_ids = [server.id for server in missing_servers] + missing_uuids = [server.squad_uuid for server in missing_servers] + + connections_count_result = await db.execute( + select(func.count(SubscriptionServer.id)).where( + SubscriptionServer.server_squad_id.in_(missing_ids) + ) + ) + removed_connections = connections_count_result.scalar() or 0 + + await db.execute( + delete(SubscriptionServer).where( + SubscriptionServer.server_squad_id.in_(missing_ids) + ) + ) + + if removed_connections: + logger.info( + "🔁 Удалено %s привязок подписок к отсутствующим серверам", + removed_connections, + ) + + subscriptions_result = await db.execute( + select(Subscription).where(Subscription.connected_squads.isnot(None)) + ) + + for subscription in subscriptions_result.scalars().all(): + current_squads = list(subscription.connected_squads or []) + if not current_squads: + continue + + updated_squads = [ + squad_uuid for squad_uuid in current_squads + if squad_uuid not in missing_uuids + ] + + if updated_squads != current_squads: + subscription.connected_squads = updated_squads + + for server in missing_servers: + logger.info( + "🗑️ Удаляем сервер %s (UUID: %s) — отсутствует в RemnaWave", + server.display_name, + server.squad_uuid, + ) + await db.delete(server) + removed += 1 + await db.commit() - - logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}") - return created, updated, disabled + + logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}") + return created, updated, removed def _generate_display_name(original_name: str) -> str: diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index 9edc3af0..1978236c 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -15,6 +15,7 @@ from app.database.crud.server_squad import ( create_server_squad, get_available_server_squads, update_server_squad_promo_groups, + get_server_users, ) from app.database.crud.promo_group import get_promo_groups_with_counts from app.services.remnawave_service import RemnaWaveService @@ -81,6 +82,11 @@ def _build_server_edit_view(server): text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}" ), ], + [ + types.InlineKeyboardButton( + text="👥 Пользователи", callback_data=f"admin_server_users_{server.id}" + ), + ], [ types.InlineKeyboardButton( text="❌ Отключить" if server.is_available else "✅ Включить", @@ -276,7 +282,7 @@ async def sync_servers_with_remnawave( ) return - created, updated, disabled = await sync_with_remnawave(db, squads) + created, updated, removed = await sync_with_remnawave(db, squads) await cache.delete_pattern("available_countries*") @@ -286,7 +292,7 @@ async def sync_servers_with_remnawave( 📊 Результаты: • Создано новых серверов: {created} • Обновлено существующих: {updated} -• Отключено неактивных: {disabled} +• Удалено отсутствующих: {removed} • Всего обработано: {len(squads)} ℹ️ Новые серверы созданы как недоступные. @@ -1019,9 +1025,119 @@ async def save_server_promo_groups( reply_markup=keyboard, parse_mode="HTML", ) + await callback.answer("✅ Промогруппы обновлены!") +@admin_required +@error_handler +async def show_server_users( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + + server_id = int(callback.data.split('_')[-1]) + server = await get_server_squad_by_id(db, server_id) + + if not server: + await callback.answer("❌ Сервер не найден!", show_alert=True) + return + + users = await get_server_users(db, server_id) + + status_emojis = { + "active": "✅", + "trial": "🧪", + "expired": "⏰", + "disabled": "⛔", + } + status_labels = { + "active": "АКТИВНА", + "trial": "TRIAL", + "expired": "ИСТЕКЛА", + "disabled": "ОТКЛЮЧЕНА", + } + + text_lines = [ + "👥 Пользователи сервера", + "", + f"Сервер: {server.display_name}", + f"UUID: {server.squad_uuid}", + "", + ] + + keyboard_rows = [] + max_buttons = 50 + + if not users: + text_lines.append("На этот сервер пока никто не подключен.") + else: + text_lines.append(f"Всего пользователей: {len(users)}") + text_lines.append("Нажмите на пользователя ниже, чтобы открыть управление.") + text_lines.append("") + + preview_limit = 10 + for user_info in users[:preview_limit]: + name_parts = [] + if user_info.get("username"): + name_parts.append(f"@{user_info['username']}") + if user_info.get("first_name"): + name_parts.append(user_info["first_name"]) + display_name = " ".join(name_parts) or str(user_info["telegram_id"]) + + status = (user_info.get("subscription_status") or "unknown").lower() + status_text = status_labels.get(status, status.upper()) + emoji = status_emojis.get(status, "ℹ️") + + text_lines.append(f"• {display_name} — {emoji} {status_text}") + + if len(users) > preview_limit: + remaining = len(users) - preview_limit + text_lines.append(f"… и ещё {remaining} пользователей") + + for user_info in users[:max_buttons]: + if user_info.get("username"): + label = f"@{user_info['username']}" + elif user_info.get("first_name"): + label = user_info["first_name"] + else: + label = str(user_info["telegram_id"]) + + status = (user_info.get("subscription_status") or "unknown").lower() + emoji = status_emojis.get(status, "ℹ️") + button_text = f"{emoji} {label}"[:64] + + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_user_manage_{user_info['user_id']}", + ) + ] + ) + + if len(users) > max_buttons: + text_lines.append( + f"⚠️ Показаны первые {max_buttons} пользователей. Используйте фильтры в разделе пользователей для подробностей." + ) + + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=f"admin_server_edit_{server.id}" + ) + ] + ) + + await callback.message.edit_text( + "\n".join(text_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + parse_mode="HTML", + ) + await callback.answer() + + @admin_required @error_handler async def sync_server_user_counts_handler( @@ -1105,6 +1221,7 @@ def register_handlers(dp: Dispatcher): & ~F.data.contains("promo"), ) dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) + dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_")) dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_")) dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))