From 799243a988d2997125fa0aa4729571af8b9cd279 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:20:21 +0300 Subject: [PATCH] Support user-specific internal squads --- app/database/crud/user.py | 8 ++- app/database/models.py | 1 + app/database/universal_migration.py | 37 +++++++++++ app/handlers/admin/users.py | 20 +++--- app/services/monitoring_service.py | 5 +- app/services/remnawave_service.py | 95 ++++++++++++++++++++++------ app/services/subscription_service.py | 13 +++- app/utils/internal_squads.py | 21 ++++++ app/webapi/routes/users.py | 14 ++++ app/webapi/schemas/users.py | 9 +++ 10 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 app/utils/internal_squads.py diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 2f944df2..a57104ec 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -171,7 +171,8 @@ async def create_user_no_commit( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[List[str]] = None, ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки @@ -197,6 +198,7 @@ async def create_user_no_commit( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=active_internal_squads, ) db.add(user) @@ -222,7 +224,8 @@ async def create_user( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[List[str]] = None, ) -> User: if not referral_code: @@ -248,6 +251,7 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=active_internal_squads, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index f8e9f719..418ea0c2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -585,6 +585,7 @@ class User(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) last_activity = Column(DateTime, default=func.now()) remnawave_uuid = Column(String(255), nullable=True, unique=True) + active_internal_squads = Column(JSON, nullable=True) broadcasts = relationship("BroadcastHistory", back_populates="admin") referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id") subscription = relationship("Subscription", back_populates="user", uselist=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6bf30aff..b9ea363c 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3650,6 +3650,36 @@ async def add_promo_group_priority_column() -> bool: return False +async def add_user_active_internal_squads_column() -> bool: + """Добавляет колонку active_internal_squads в таблицу users.""" + column_exists = await check_column_exists('users', 'active_internal_squads') + if column_exists: + logger.info("Колонка active_internal_squads уже существует в users") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'JSON' + elif db_type == 'postgresql': + column_def = 'JSONB' + else: + column_def = 'JSON' + + await conn.execute( + text(f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}") + ) + + logger.info("✅ Добавлена колонка active_internal_squads в users") + return True + + except Exception as error: + logger.error(f"Ошибка добавления колонки active_internal_squads: {error}") + return False + + async def create_user_promo_groups_table() -> bool: """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" table_exists = await check_table_exists("user_promo_groups") @@ -3993,6 +4023,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением priority в promo_groups") + logger.info("=== ДОБАВЛЕНИЕ INTERNAL SQUADS ДЛЯ USERS ===") + internal_squads_ready = await add_user_active_internal_squads_column() + if internal_squads_ready: + logger.info("✅ Колонка active_internal_squads в users готова") + else: + logger.warning("⚠️ Проблемы с добавлением active_internal_squads в users") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===") user_promo_groups_ready = await create_user_promo_groups_table() if user_promo_groups_ready: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index d25499e6..2a302158 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.user_utils import get_effective_referral_commission_percent from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy @@ -3401,7 +3402,7 @@ async def _show_servers_for_user( user = await get_user_by_id(db, user_id) current_squads = [] if user and user.subscription: - current_squads = user.subscription.connected_squads or [] + current_squads = resolve_user_internal_squads(user, user.subscription) all_servers, _ = await get_all_server_squads(db, available_only=False) @@ -3490,18 +3491,19 @@ async def toggle_user_server( if not server: await callback.answer("❌ Сервер не найден", show_alert=True) return - + subscription = user.subscription - current_squads = list(subscription.connected_squads or []) - + current_squads = resolve_user_internal_squads(user, subscription) + if server.squad_uuid in current_squads: current_squads.remove(server.squad_uuid) action_text = "удален" else: current_squads.append(server.squad_uuid) action_text = "добавлен" - + subscription.connected_squads = current_squads + user.active_internal_squads = current_squads subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) @@ -4590,8 +4592,10 @@ async def admin_buy_subscription_execute( from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import UserStatus, TrafficLimitStrategy remnawave_service = RemnaWaveService() - + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + active_squads = resolve_user_internal_squads(target_user, subscription) + target_user.active_internal_squads = active_squads if target_user.remnawave_uuid: async with remnawave_service.get_api_client() as api: @@ -4606,7 +4610,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: @@ -4632,7 +4636,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a0f6f95..ad8f9bb6 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -38,6 +38,7 @@ from app.database.crud.user import ( subtract_user_balance, cleanup_expired_promo_offer_discounts, ) +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import format_local_datetime from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, @@ -312,7 +313,9 @@ class MonitoringService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if hwid_limit is not None: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 001b7b54..7a6c7070 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -42,6 +42,7 @@ from app.database.models import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) @@ -182,6 +183,70 @@ class RemnaWaveService: self._config_error or "RemnaWave API не настроен" ) + async def resolve_internal_squad_uuids(self, identifiers: List[str]) -> List[str]: + """Конвертирует названия/UUID сквадов в UUID через панель RemnaWave.""" + + if not identifiers: + return [] + + cleaned: List[str] = [] + seen = set() + + for ident in identifiers: + if not ident: + continue + + value = str(ident).strip() + if not value or value in seen: + continue + + seen.add(value) + cleaned.append(value) + + if not cleaned: + return [] + + try: + async with self.get_api_client() as api: + squads = await api.get_internal_squads() + except Exception as error: + logger.error("❌ Не удалось получить список Internal Squads: %s", error) + return [] + + name_to_uuid = {squad.name.lower(): squad.uuid for squad in squads} + uuid_set = {squad.uuid for squad in squads} + + resolved: List[str] = [] + for value in cleaned: + if value in uuid_set: + resolved.append(value) + continue + + mapped = name_to_uuid.get(value.lower()) + if mapped: + resolved.append(mapped) + else: + logger.warning("⚠️ Неизвестный Internal Squad: %s", value) + + return resolved + + async def normalize_active_internal_squads(self, active_squads: List[Any]) -> List[str]: + """Приводит входные данные о сквадах (имя/UUID/словарь) к списку UUID.""" + + identifiers: List[str] = [] + + for squad in active_squads or []: + identifier = None + if isinstance(squad, dict): + identifier = squad.get('uuid') or squad.get('name') + else: + identifier = squad + + if identifier: + identifiers.append(str(identifier)) + + return await self.resolve_internal_squad_uuids(identifiers) + def _ensure_user_remnawave_uuid( self, user: "User", @@ -1017,6 +1082,7 @@ class RemnaWaveService: uuid=subscription.user.remnawave_uuid, active_internal_squads=new_squads, ) + subscription.user.active_internal_squads = new_squads panel_updated += 1 except Exception as error: panel_failed += 1 @@ -1506,14 +1572,9 @@ class RemnaWaveService: traffic_used_gb = used_traffic_bytes / (1024**3) active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = [] - if isinstance(active_squads, list): - for squad in active_squads: - if isinstance(squad, dict) and 'uuid' in squad: - squad_uuids.append(squad['uuid']) - elif isinstance(squad, str): - squad_uuids.append(squad) - + squad_uuids = await self.normalize_active_internal_squads(active_squads) + user.active_internal_squads = squad_uuids + subscription_data = { 'user_id': user.id, 'status': status.value, @@ -1647,16 +1708,11 @@ class RemnaWaveService: ) if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: subscription.subscription_crypto_link = panel_crypto_link - + active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = [] - if isinstance(active_squads, list): - for squad in active_squads: - if isinstance(squad, dict) and 'uuid' in squad: - squad_uuids.append(squad['uuid']) - elif isinstance(squad, str): - squad_uuids.append(squad) - + squad_uuids = await self.normalize_active_internal_squads(active_squads) + user.active_internal_squads = squad_uuids + current_squads = set(subscription.connected_squads or []) new_squads = set(squad_uuids) @@ -1694,6 +1750,7 @@ class RemnaWaveService: try: subscription = user.subscription hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + active_squads = resolve_user_internal_squads(user, subscription) expire_at = self._safe_expire_at_for_panel(subscription.end_date) status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED @@ -1716,7 +1773,7 @@ class RemnaWaveService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: @@ -1730,7 +1787,7 @@ class RemnaWaveService: traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f7a29e2f..1d34e825 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -11,6 +11,7 @@ from app.external.remnawave_api import ( TrafficLimitStrategy, RemnaWaveAPIError ) from app.database.crud.user import get_user_by_id +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -207,7 +208,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: @@ -245,7 +248,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: @@ -328,7 +333,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: diff --git a/app/utils/internal_squads.py b/app/utils/internal_squads.py new file mode 100644 index 00000000..b40cc6fc --- /dev/null +++ b/app/utils/internal_squads.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from app.database.models import Subscription, User + + +def resolve_user_internal_squads( + user: Optional[User], subscription: Optional[Subscription] +) -> List[str]: + """Возвращает список Internal Squads для пользователя. + + Приоритет у персональных сквадов пользователя. Если они не заданы, + используется список сквадов из подписки. + """ + + if user and getattr(user, "active_internal_squads", None) is not None: + return list(user.active_internal_squads or []) + + if subscription: + return list(subscription.connected_squads or []) + + return [] diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 7e6b444f..4b72fe41 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -17,6 +17,7 @@ from app.database.crud.user import ( update_user, ) from app.database.models import PromoGroup, Subscription, User, UserStatus +from app.services.remnawave_service import RemnaWaveService from ..dependencies import get_db_session, require_api_token from ..schemas.users import ( @@ -30,6 +31,7 @@ from ..schemas.users import ( ) router = APIRouter() +remnawave_service = RemnaWaveService() def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]: @@ -90,6 +92,7 @@ def _serialize_user(user: User) -> UserResponse: last_activity=user.last_activity, promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), + active_internal_squads=list(getattr(user, "active_internal_squads", []) or []), ) @@ -197,6 +200,12 @@ async def create_user_endpoint( if existing: raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists") + active_squads = None + if payload.active_internal_squads is not None: + active_squads = await remnawave_service.resolve_internal_squad_uuids( + payload.active_internal_squads + ) + user = await create_user( db, telegram_id=payload.telegram_id, @@ -205,6 +214,7 @@ async def create_user_endpoint( last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, + active_internal_squads=active_squads, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: @@ -249,6 +259,10 @@ async def update_user_endpoint( updates["has_had_paid_subscription"] = payload.has_had_paid_subscription if payload.has_made_first_topup is not None: updates["has_made_first_topup"] = payload.has_made_first_topup + if payload.active_internal_squads is not None: + updates["active_internal_squads"] = await remnawave_service.resolve_internal_squad_uuids( + payload.active_internal_squads + ) if payload.status is not None: try: diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index fbf910fc..844e50cc 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -51,6 +51,7 @@ class UserResponse(BaseModel): last_activity: Optional[datetime] = None promo_group: Optional[PromoGroupSummary] = None subscription: Optional[SubscriptionSummary] = None + active_internal_squads: List[str] = Field(default_factory=list) class UserListResponse(BaseModel): @@ -68,6 +69,10 @@ class UserCreateRequest(BaseModel): language: str = "ru" referred_by_id: Optional[int] = None promo_group_id: Optional[int] = None + active_internal_squads: Optional[List[str]] = Field( + default=None, + description="Названия или UUID Internal Squads, которые нужно назначить пользователю" + ) class UserUpdateRequest(BaseModel): @@ -80,6 +85,10 @@ class UserUpdateRequest(BaseModel): referral_code: Optional[str] = None has_had_paid_subscription: Optional[bool] = None has_made_first_topup: Optional[bool] = None + active_internal_squads: Optional[List[str]] = Field( + default=None, + description="Названия или UUID Internal Squads, которые нужно назначить пользователю" + ) class BalanceUpdateRequest(BaseModel):