diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 72a9b97a..3669895d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -527,6 +527,33 @@ async def deactivate_subscription( return subscription +async def reactivate_subscription( + db: AsyncSession, + subscription: Subscription +) -> Subscription: + """Реактивация подписки (например, после повторной подписки на канал). + + Активирует только если подписка была DISABLED и ещё не истекла. + Не логирует если реактивация не требуется. + """ + now = datetime.utcnow() + + # Тихо выходим если реактивация не нужна + if subscription.status != SubscriptionStatus.DISABLED.value: + return subscription + + if subscription.end_date and subscription.end_date <= now: + return subscription + + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = now + + await db.commit() + await db.refresh(subscription) + + return subscription + + async def get_expiring_subscriptions( db: AsyncSession, days_before: int = 3 diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 43a68d47..cac399fb 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -9,7 +9,7 @@ from aiogram.enums import ChatMemberStatus from app.config import settings from app.database.database import get_db from app.database.crud.campaign import get_campaign_by_start_parameter -from app.database.crud.subscription import deactivate_subscription +from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id from app.database.models import SubscriptionStatus from app.keyboards.inline import get_channel_sub_keyboard @@ -104,12 +104,15 @@ class ChannelCheckerMiddleware(BaseMiddleware): member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id) if member.status in self.GOOD_MEMBER_STATUS: + # Реактивируем подписку если была отключена из-за отписки от канала + if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): + await self._reactivate_subscription_on_subscribe(telegram_id, bot) return await handler(event, data) elif member.status in self.BAD_MEMBER_STATUS: logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): - await self._deactivate_subscription_on_unsubscribe(telegram_id) + await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link) await self._capture_start_payload(state, event, bot) @@ -253,7 +256,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): finally: break - async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None: + async def _deactivate_subscription_on_unsubscribe( + self, telegram_id: int, bot: Bot, channel_link: Optional[str] + ) -> None: if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: logger.debug( "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", @@ -308,6 +313,24 @@ class ChannelCheckerMiddleware(BaseMiddleware): user.remnawave_uuid, api_error, ) + + # Уведомляем пользователя о деактивации + try: + texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) + notification_text = texts.t( + "SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE", + "🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n" + "Подпишитесь на канал снова, чтобы восстановить доступ к VPN." + ) + channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) + await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) + logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}") + except Exception as notify_error: + logger.error( + "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", + telegram_id, + notify_error, + ) except Exception as db_error: logger.error( "❌ Ошибка деактивации подписки пользователя %s после отписки: %s", @@ -317,6 +340,77 @@ class ChannelCheckerMiddleware(BaseMiddleware): finally: break + async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None: + """Реактивация подписки после повторной подписки на канал. + + Вызывается только если подписка в статусе DISABLED. + """ + if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: + return + + async for db in get_db(): + try: + user = await get_user_by_telegram_id(db, telegram_id) + if not user or not user.subscription: + break + + subscription = user.subscription + + # Реактивируем только DISABLED подписки (деактивированные из-за отписки) + # Тихо выходим если подписка не требует реактивации — без логов + if subscription.status != SubscriptionStatus.DISABLED.value: + break + + # Проверяем что подписка ещё не истекла + from datetime import datetime + if subscription.end_date and subscription.end_date <= datetime.utcnow(): + break + + # Реактивируем в БД + await reactivate_subscription(db, subscription) + sub_type = "Триальная" if subscription.is_trial else "Платная" + logger.info( + "✅ %s подписка пользователя %s реактивирована после подписки на канал", + sub_type, + telegram_id, + ) + + # Включаем в RemnaWave + if user.remnawave_uuid: + service = SubscriptionService() + try: + await service.enable_remnawave_user(user.remnawave_uuid) + except Exception as api_error: + logger.error( + "❌ Не удалось включить пользователя RemnaWave %s: %s", + user.remnawave_uuid, + api_error, + ) + + # Уведомляем пользователя о реактивации + try: + texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) + notification_text = texts.t( + "SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE", + "✅ Ваша подписка восстановлена!\n\n" + "Спасибо, что подписались на канал. VPN снова работает." + ) + await bot.send_message(telegram_id, notification_text) + except Exception as notify_error: + logger.warning( + "Не удалось отправить уведомление о реактивации пользователю %s: %s", + telegram_id, + notify_error, + ) + except Exception as db_error: + logger.error( + "❌ Ошибка реактивации подписки пользователя %s: %s", + telegram_id, + db_error, + ) + finally: + break + @staticmethod async def _deny_message( event: TelegramObject, diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index 1ddafb60..bebd3c9a 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -331,7 +331,7 @@ class SubscriptionRenewalService: if devices_limit is None: devices_limit = settings.DEFAULT_DEVICE_LIMIT - # Модем добавляет +1 к device_limit, но оплачивается отдельно, +--- # Модем добавляет +1 к device_limit, но оплачивается отдельно, # поэтому не должен учитываться как платное устройство при продлении if getattr(subscription, 'modem_enabled', False): devices_limit = max(1, devices_limit - 1) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 1bb0cb31..990e963d 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -396,11 +396,23 @@ class SubscriptionService: await api.disable_user(user_uuid) logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") return True - + except Exception as e: logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") return False - + + async def enable_remnawave_user(self, user_uuid: str) -> bool: + """Включить пользователя в RemnaWave (реактивация).""" + try: + async with self.get_api_client() as api: + await api.enable_user(user_uuid) + logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}") + return True + + except Exception as e: + logger.error(f"Ошибка включения RemnaWave пользователя: {e}") + return False + async def revoke_subscription( self, db: AsyncSession,