diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 98bf2752..f0e6947c 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -901,7 +901,7 @@ async def get_users_list( query = query.offset(offset).limit(limit) result = await db.execute(query) - users = result.scalars().all() + users = result.scalars().unique().all() # Загружаем дополнительные зависимости для всех пользователей for user in users: diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 88734471..a2491a43 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -1902,7 +1902,11 @@ async def show_selected_user_details( def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]: - subscription = getattr(user, 'subscription', None) + if settings.is_multi_tariff_enabled(): + subs = getattr(user, 'subscriptions', None) or [] + subscription = next((s for s in subs if s.subscription_url), None) + else: + subscription = getattr(user, 'subscription', None) if not subscription: return [] @@ -2122,8 +2126,14 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn if template.offer_type == 'test_access' and squad_uuid: filtered_users: list[User] = [] for user in users: - subscription = getattr(user, 'subscription', None) - connected = set(subscription.connected_squads or []) if subscription else set() + if settings.is_multi_tariff_enabled(): + all_squads: set[str] = set() + for s in (getattr(user, 'subscriptions', None) or []): + all_squads.update(s.connected_squads or []) + connected = all_squads + else: + subscription = getattr(user, 'subscription', None) + connected = set(subscription.connected_squads or []) if subscription else set() if squad_uuid in connected: continue filtered_users.append(user) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 11d3c67a..dcbd7c89 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4375,7 +4375,7 @@ async def _grant_trial_subscription( from app.services.subscription_service import SubscriptionService existing_subscription = await _resolve_admin_subscription(db, user_id, subscription_id) - if existing_subscription: + if existing_subscription and not settings.is_multi_tariff_enabled(): logger.error('У пользователя уже есть подписка', user_id=user_id) return False @@ -4409,7 +4409,7 @@ async def _grant_paid_subscription( from app.services.subscription_service import SubscriptionService existing_subscription = await _resolve_admin_subscription(db, user_id, subscription_id) - if existing_subscription: + if existing_subscription and not settings.is_multi_tariff_enabled(): logger.error('У пользователя уже есть подписка', user_id=user_id) return False diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ad797b48..54c68c38 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1813,7 +1813,9 @@ async def confirm_extend_subscription( if _fsm_sub_id: subscription = await get_subscription_by_id_for_user(db, _fsm_sub_id, db_user.id) else: - subscription = db_user.subscription + # Multi-tariff without FSM state — cannot determine which subscription + await callback.answer('Выберите подписку через "Мои подписки"', show_alert=True) + return else: subscription = db_user.subscription @@ -2845,10 +2847,11 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U return texts = get_texts(db_user.language) - # Multi-tariff note: this handler is reached via 'subscription_settings' callback - # which is shown in the single-subscription info keyboard. In multi-tariff mode, - # show_subscription_info redirects to show_my_subscriptions, so per-subscription - # settings are handled from the my_subscriptions flow. db_user.subscription is safe. + + if settings.is_multi_tariff_enabled(): + await callback.answer('Настройки доступны через "Мои подписки"', show_alert=True) + return + subscription = db_user.subscription # Получаем тариф подписки если есть @@ -4137,7 +4140,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(handle_happ_download_back, F.data == 'happ_download_back') - dp.callback_query.register(handle_connect_subscription, F.data == 'subscription_connect') + dp.callback_query.register(handle_connect_subscription, F.data.startswith('subscription_connect')) dp.callback_query.register(handle_device_guide, F.data.startswith('device_guide_')) @@ -4145,7 +4148,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_') & ~F.data.startswith('app_list_')) - dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link') + dp.callback_query.register(handle_open_subscription_link, F.data.startswith('open_subscription_link')) dp.callback_query.register(handle_subscription_settings, F.data == 'subscription_settings') diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index fb1f1fc3..e4b7b45d 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -314,6 +314,12 @@ async def confirm_reset_traffic( await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True) return + if settings.is_multi_tariff_enabled(): + _state_data = await state.get_data() if state else {} + if not _state_data.get('active_subscription_id'): + await callback.answer('Выберите подписку через "Мои подписки"', show_alert=True) + return + from app.database.crud.user import lock_user_for_pricing db_user = await lock_user_for_pricing(db, db_user.id) diff --git a/app/services/blocked_users_service.py b/app/services/blocked_users_service.py index d6ea06a2..31a39f1a 100644 --- a/app/services/blocked_users_service.py +++ b/app/services/blocked_users_service.py @@ -88,6 +88,7 @@ class BlockCheckResult: status: BlockCheckStatus error_message: str | None = None remnawave_uuid: str | None = None + remnawave_uuids: list[str] = field(default_factory=list) @dataclass @@ -159,6 +160,13 @@ class BlockedUsersService: async def _check_single_user(self, user: User) -> BlockCheckResult: """Проверяет одного пользователя.""" + sub_uuids = [ + s.remnawave_uuid + for s in (getattr(user, 'subscriptions', None) or []) + if s.remnawave_uuid + ] + remnawave_uuids = sub_uuids or ([user.remnawave_uuid] if user.remnawave_uuid else []) + if not user.telegram_id: return BlockCheckResult( user_id=user.id, @@ -167,6 +175,7 @@ class BlockedUsersService: full_name=user.full_name, status=BlockCheckStatus.NO_TELEGRAM_ID, remnawave_uuid=user.remnawave_uuid, + remnawave_uuids=remnawave_uuids, ) status = await self.check_user_blocked(user.telegram_id) @@ -178,6 +187,7 @@ class BlockedUsersService: full_name=user.full_name, status=status, remnawave_uuid=user.remnawave_uuid, + remnawave_uuids=remnawave_uuids, ) async def scan_all_users( @@ -417,12 +427,17 @@ class BlockedUsersService: for i, user_result in enumerate(blocked_users): try: if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH): - if user_result.remnawave_uuid: - success = await self.delete_user_from_remnawave(user_result.remnawave_uuid) + uuids_to_delete = user_result.remnawave_uuids or ( + [user_result.remnawave_uuid] if user_result.remnawave_uuid else [] + ) + for rw_uuid in uuids_to_delete: + success = await self.delete_user_from_remnawave(rw_uuid) if success: result.deleted_from_remnawave += 1 else: - result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave') + result.errors.append( + f'Ошибка удаления {user_result.telegram_id} (uuid={rw_uuid}) из Remnawave' + ) # Задержка для избежания rate limit await asyncio.sleep(self.API_DELAY_SECONDS) diff --git a/app/services/broadcast_service.py b/app/services/broadcast_service.py index 8e9005dd..fb7cc00e 100644 --- a/app/services/broadcast_service.py +++ b/app/services/broadcast_service.py @@ -544,14 +544,11 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No from app.database.crud.subscription import is_active_paid_subscription sub_result = await session.execute( - select(Subscription) - .where(Subscription.user_id == user.id) - .order_by(Subscription.created_at.desc()) - .limit(1) + select(Subscription).where(Subscription.user_id == user.id) ) - user_subscription = sub_result.scalar_one_or_none() + all_subs = sub_result.scalars().all() - if is_active_paid_subscription(user_subscription): + if any(is_active_paid_subscription(s) for s in all_subs): logger.info( '⏭️ Пропуск отключения подписки: у пользователя активная оплаченная подписка', telegram_id=telegram_id, diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index ceed561d..f6eeecf8 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -134,6 +134,7 @@ class AdvertisingCampaignService: active_subs = await get_active_subscriptions_by_user_id(db, user.id) if active_subs: + # Multi-tariff: extend the best existing subscription instead of blocking _non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)] _pool = _non_daily or active_subs existing_subscription = max(_pool, key=lambda s: s.days_left) @@ -141,13 +142,13 @@ class AdvertisingCampaignService: existing_subscription = None else: existing_subscription = await get_subscription_by_user_id(db, user.id) - if existing_subscription: - logger.warning( - '⚠️ У пользователя уже есть подписка, бонус кампании пропущен', - format_user_log=_format_user_log(user), - campaign_id=campaign.id, - ) - return CampaignBonusResult(success=False) + if existing_subscription: + logger.warning( + '⚠️ У пользователя уже есть подписка, бонус кампании пропущен', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, + ) + return CampaignBonusResult(success=False) duration_days = campaign.subscription_duration_days or 0 if duration_days <= 0: @@ -170,21 +171,46 @@ class AdvertisingCampaignService: except Exception as error: logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error) - new_subscription = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=duration_days, - traffic_limit_gb=traffic_limit or 0, - device_limit=device_limit, - connected_squads=squads, - update_server_counters=True, - is_trial=True, - ) + if existing_subscription: + # Multi-tariff: extend the best existing subscription + from app.database.crud.subscription import extend_subscription - try: - await self.subscription_service.create_remnawave_user(db, new_subscription) - except Exception as error: - logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error) + await extend_subscription(db, existing_subscription, duration_days) + try: + await self.subscription_service.update_remnawave_user(db, existing_subscription) + except Exception as error: + logger.error('❌ Ошибка синхронизации RemnaWave при продлении кампании', campaign_id=campaign.id, error=error) + + logger.info( + '🎁 Подписка пользователя продлена по кампании на дней', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, + duration_days=duration_days, + subscription_id=existing_subscription.id, + ) + else: + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + update_server_counters=True, + is_trial=True, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error('❌ Ошибка синхронизации RemnaWave для кампании', campaign_id=campaign.id, error=error) + + logger.info( + '🎁 Пользователю выдана подписка по кампании на дней', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, + duration_days=duration_days, + ) await record_campaign_registration( db, @@ -194,13 +220,6 @@ class AdvertisingCampaignService: subscription_duration_days=duration_days, ) - logger.info( - '🎁 Пользователю выдана подписка по кампании на дней', - format_user_log=_format_user_log(user), - campaign_id=campaign.id, - duration_days=duration_days, - ) - return CampaignBonusResult( success=True, bonus_type='subscription', @@ -242,25 +261,26 @@ class AdvertisingCampaignService: campaign: AdvertisingCampaign, ) -> CampaignBonusResult: """Выдача тарифа на определённое время.""" + existing_subscription = None if settings.is_multi_tariff_enabled(): from app.database.crud.subscription import get_active_subscriptions_by_user_id active_subs = await get_active_subscriptions_by_user_id(db, user.id) - if active_subs: - _non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)] - _pool = _non_daily or active_subs - existing_subscription = max(_pool, key=lambda s: s.days_left) - else: - existing_subscription = None + if active_subs and campaign.tariff_id: + # Multi-tariff: only check for THIS specific tariff + same_tariff_subs = [s for s in active_subs if s.tariff_id == campaign.tariff_id] + if same_tariff_subs: + existing_subscription = max(same_tariff_subs, key=lambda s: s.days_left) + # If no sub for this tariff, existing_subscription stays None -> create new else: existing_subscription = await get_subscription_by_user_id(db, user.id) - if existing_subscription: - logger.warning( - '⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен', - format_user_log=_format_user_log(user), - campaign_id=campaign.id, - ) - return CampaignBonusResult(success=False) + if existing_subscription: + logger.warning( + '⚠️ У пользователя уже есть подписка, бонус тарифа кампании пропущен', + format_user_log=_format_user_log(user), + campaign_id=campaign.id, + ) + return CampaignBonusResult(success=False) if not campaign.tariff_id: logger.error('❌ Кампания не имеет указанного тарифа для выдачи', campaign_id=campaign.id) @@ -295,23 +315,54 @@ class AdvertisingCampaignService: except Exception as error: logger.error('Не удалось подобрать сквад для тарифа кампании', campaign_id=campaign.id, error=error) - # Создаём подписку как платную (не trial) с привязкой к тарифу - new_subscription = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=duration_days, - traffic_limit_gb=traffic_limit or 0, - device_limit=device_limit, - connected_squads=squads, - update_server_counters=True, - is_trial=False, # Это полноценная подписка, не пробная - tariff_id=tariff.id, - ) + if existing_subscription: + # Multi-tariff: extend the existing subscription for this tariff + from app.database.crud.subscription import extend_subscription - try: - await self.subscription_service.create_remnawave_user(db, new_subscription) - except Exception as error: - logger.error('❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error) + await extend_subscription(db, existing_subscription, duration_days, tariff_id=tariff.id) + try: + await self.subscription_service.update_remnawave_user(db, existing_subscription) + except Exception as error: + logger.error( + '❌ Ошибка синхронизации RemnaWave при продлении тарифа кампании', + campaign_id=campaign.id, + error=error, + ) + + logger.info( + '🎁 Подписка пользователя продлена по тарифу кампании на дней', + format_user_log=_format_user_log(user), + tariff_name=tariff.name, + campaign_id=campaign.id, + duration_days=duration_days, + subscription_id=existing_subscription.id, + ) + else: + # Создаём подписку как платную (не trial) с привязкой к тарифу + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + update_server_counters=True, + is_trial=False, + tariff_id=tariff.id, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error('❌ Ошибка синхронизации RemnaWave для тарифа кампании', campaign_id=campaign.id, error=error) + + logger.info( + '🎁 Пользователю выдан тариф по кампании на дней', + format_user_log=_format_user_log(user), + tariff_name=tariff.name, + campaign_id=campaign.id, + duration_days=duration_days, + ) await record_campaign_registration( db, @@ -322,14 +373,6 @@ class AdvertisingCampaignService: tariff_duration_days=duration_days, ) - logger.info( - "🎁 Пользователю выдан тариф '' по кампании на дней", - format_user_log=_format_user_log(user), - tariff_name=tariff.name, - campaign_id=campaign.id, - duration_days=duration_days, - ) - return CampaignBonusResult( success=True, bonus_type='tariff', diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index da998d68..fba7bd20 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1003,7 +1003,12 @@ class RemnaWaveService: try: needs_panel_update = any( - subscription.user and subscription.user.remnawave_uuid for subscription in subscriptions + ( + subscription.remnawave_uuid + if settings.is_multi_tariff_enabled() + else (subscription.user and subscription.user.remnawave_uuid) + ) + for subscription in subscriptions ) api = None diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 9bf110d1..2400d804 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -756,10 +756,17 @@ async def _auto_purchase_tariff( from app.database.crud.subscription import get_active_subscriptions_by_user_id active_subs = await get_active_subscriptions_by_user_id(db, user.id) - existing_subscription = next( - (s for s in active_subs if s.tariff_id == tariff_id), - None, - ) + _cart_sub_id = cart_data.get('subscription_id') + if _cart_sub_id: + existing_subscription = next( + (s for s in active_subs if s.id == int(_cart_sub_id)), + None, + ) + else: + existing_subscription = next( + (s for s in active_subs if s.tariff_id == tariff_id), + None, + ) else: existing_subscription = await get_subscription_by_user_id(db, user.id) @@ -1153,10 +1160,17 @@ async def _auto_purchase_daily_tariff( from app.database.crud.subscription import get_active_subscriptions_by_user_id active_subs = await get_active_subscriptions_by_user_id(db, user.id) - existing_subscription = next( - (s for s in active_subs if s.tariff_id == tariff_id), - None, - ) + _cart_sub_id = cart_data.get('subscription_id') + if _cart_sub_id: + existing_subscription = next( + (s for s in active_subs if s.id == int(_cart_sub_id)), + None, + ) + else: + existing_subscription = next( + (s for s in active_subs if s.tariff_id == tariff_id), + None, + ) else: existing_subscription = await get_subscription_by_user_id(db, user.id) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 76e3b18a..da033eb6 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -406,11 +406,17 @@ class SubscriptionService: return None # Resolve the Remnawave UUID: prefer subscription-level in multi-tariff mode - remnawave_uuid = ( - subscription.remnawave_uuid - if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid - else user.remnawave_uuid - ) + if settings.is_multi_tariff_enabled(): + remnawave_uuid = subscription.remnawave_uuid + if not remnawave_uuid: + logger.warning( + 'Multi-tariff: subscription has no remnawave_uuid, cannot update panel', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + return None + else: + remnawave_uuid = user.remnawave_uuid if not remnawave_uuid: logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id) return None @@ -489,17 +495,23 @@ class SubscriptionService: updated_user = await api.update_user(**update_kwargs) if reset_traffic: - reset_uuid = ( - subscription.remnawave_uuid - if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid - else user.remnawave_uuid - ) - await self._reset_user_traffic( - api, - reset_uuid, - user, - reset_reason, - ) + if settings.is_multi_tariff_enabled(): + reset_uuid = subscription.remnawave_uuid + if not reset_uuid: + logger.warning( + 'Multi-tariff: subscription has no remnawave_uuid, skipping traffic reset', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + else: + reset_uuid = user.remnawave_uuid + if reset_uuid: + await self._reset_user_traffic( + api, + reset_uuid, + user, + reset_reason, + ) subscription.subscription_url = updated_user.subscription_url subscription.subscription_crypto_link = updated_user.happ_crypto_link @@ -609,12 +621,20 @@ class SubscriptionService: async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None: try: user = await get_user_by_id(db, subscription.user_id) - revoke_uuid = ( - subscription.remnawave_uuid - if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid - else user.remnawave_uuid - ) - if not user or not revoke_uuid: + if not user: + return None + if settings.is_multi_tariff_enabled(): + revoke_uuid = subscription.remnawave_uuid + if not revoke_uuid: + logger.warning( + 'Multi-tariff: subscription has no remnawave_uuid, cannot revoke', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + return None + else: + revoke_uuid = user.remnawave_uuid + if not revoke_uuid: return None async with self.get_api_client() as api: @@ -645,12 +665,20 @@ class SubscriptionService: async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool: try: user = await get_user_by_id(db, subscription.user_id) - sync_uuid = ( - subscription.remnawave_uuid - if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid - else user.remnawave_uuid - ) - if not user or not sync_uuid: + if not user: + return False + if settings.is_multi_tariff_enabled(): + sync_uuid = subscription.remnawave_uuid + if not sync_uuid: + logger.warning( + 'Multi-tariff: subscription has no remnawave_uuid, cannot sync usage', + subscription_id=subscription.id, + user_id=subscription.user_id, + ) + return False + else: + sync_uuid = user.remnawave_uuid + if not sync_uuid: return False async with self.get_api_client() as api: @@ -941,12 +969,20 @@ class SubscriptionService: async with semaphore: try: user = users_map.get(sub.user_id) - remnawave_uuid = ( - sub.remnawave_uuid - if settings.is_multi_tariff_enabled() and sub.remnawave_uuid - else (user.remnawave_uuid if user else None) - ) - if not user or not remnawave_uuid: + if not user: + return False + if settings.is_multi_tariff_enabled(): + remnawave_uuid = sub.remnawave_uuid + if not remnawave_uuid: + logger.warning( + 'Multi-tariff: subscription has no remnawave_uuid, skipping squad sync', + subscription_id=sub.id, + user_id=sub.user_id, + ) + return False + else: + remnawave_uuid = user.remnawave_uuid + if not remnawave_uuid: return False current_time = datetime.now(UTC) diff --git a/app/services/user_service.py b/app/services/user_service.py index de7e85c8..0841758e 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -755,7 +755,7 @@ class UserService: logger.info( '✅ RemnaWave подписка восстановлена при разблокировке', subscription_id=sub.id, - remnawave_uuid=user.remnawave_uuid, + remnawave_uuid=sub.remnawave_uuid if settings.is_multi_tariff_enabled() else user.remnawave_uuid, ) except Exception as e: logger.error(