import asyncio
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional, Set
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.database import get_db
from app.database.crud.subscription import (
get_expired_subscriptions, get_expiring_subscriptions,
get_subscriptions_for_autopay, deactivate_subscription,
extend_subscription
)
from app.database.crud.user import (
get_user_by_id, get_inactive_users, delete_user,
subtract_user_balance
)
from app.database.crud.notification import (
notification_sent,
record_notification,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
from app.external.remnawave_api import (
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
logger = logging.getLogger(__name__)
class MonitoringService:
def __init__(self, bot=None):
self.is_running = False
self.subscription_service = SubscriptionService()
self.payment_service = PaymentService()
self.bot = bot
self._notified_users: Set[str] = set()
self._last_cleanup = datetime.utcnow()
async def start_monitoring(self):
if self.is_running:
logger.warning("Мониторинг уже запущен")
return
self.is_running = True
logger.info("🔄 Запуск службы мониторинга")
while self.is_running:
try:
await self._monitoring_cycle()
await asyncio.sleep(settings.MONITORING_INTERVAL * 60)
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await asyncio.sleep(60)
def stop_monitoring(self):
self.is_running = False
logger.info("ℹ️ Мониторинг остановлен")
async def _monitoring_cycle(self):
async for db in get_db():
try:
await self._cleanup_notification_cache()
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
await self._log_monitoring_event(
db, "monitoring_cycle_completed",
"Цикл мониторинга успешно завершен",
{"timestamp": datetime.utcnow().isoformat()}
)
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await self._log_monitoring_event(
db, "monitoring_cycle_error",
f"Ошибка в цикле мониторинга: {str(e)}",
{"error": str(e)},
is_success=False
)
finally:
break
async def _cleanup_notification_cache(self):
current_time = datetime.utcnow()
if (current_time - self._last_cleanup).total_seconds() >= 3600:
old_count = len(self._notified_users)
self._notified_users.clear()
self._last_cleanup = current_time
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
expired_subscriptions = await get_expired_subscriptions(db)
for subscription in expired_subscriptions:
from app.database.crud.subscription import expire_subscription
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
if user and self.bot:
await self._send_subscription_expired_notification(user)
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
if expired_subscriptions:
await self._log_monitoring_event(
db, "expired_subscriptions_processed",
f"Обработано {len(expired_subscriptions)} истёкших подписок",
{"count": len(expired_subscriptions)}
)
except Exception as e:
logger.error(f"Ошибка проверки истёкших подписок: {e}")
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
subscription.subscription_url = updated_user.subscription_url
await db.commit()
status_text = "активным" if is_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def _check_expiring_subscriptions(self, db: AsyncSession):
try:
warning_days = settings.get_autopay_warning_days()
all_processed_users = set()
for days in warning_days:
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
sent_count = 0
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
user_key = f"user_{user.telegram_id}_today"
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
user_key in all_processed_users):
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
continue
should_send = True
for other_days in warning_days:
if other_days < days:
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
if any(s.user_id == user.id for s in other_subs):
should_send = False
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
break
if not should_send:
continue
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
await record_notification(db, user.id, subscription.id, "expiring", days)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
else:
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
if sent_count > 0:
await self._log_monitoring_event(
db, "expiring_notifications_sent",
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
{"days": days, "count": sent_count}
)
except Exception as e:
logger.error(f"Ошибка проверки истекающих подписок: {e}")
async def _check_trial_expiring_soon(self, db: AsyncSession):
try:
threshold_time = datetime.utcnow() + timedelta(hours=2)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.end_date <= threshold_time,
Subscription.end_date > datetime.utcnow()
)
)
)
trial_expiring = result.scalars().all()
for subscription in trial_expiring:
user = subscription.user
if not user:
continue
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
continue
if self.bot:
success = await self._send_trial_ending_notification(user, subscription)
if success:
await record_notification(db, user.id, subscription.id, "trial_2h")
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
await self._log_monitoring_event(
db, "trial_expiring_notifications_sent",
f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок",
{"count": len(trial_expiring)}
)
except Exception as e:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
current_time = datetime.utcnow()
threshold_date = current_time + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= threshold_date
)
)
)
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.debug(f"📅 Текущее время: {current_time}")
logger.debug(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
return subscriptions
async def _process_autopayments(self, db: AsyncSession):
try:
current_time = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False
)
)
)
all_autopay_subscriptions = result.scalars().all()
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= sub.autopay_days_before:
autopay_subscriptions.append(sub)
processed_count = 0
failed_count = 0
for subscription in autopay_subscriptions:
user = subscription.user
if not user:
continue
renewal_cost = settings.PRICE_30_DAYS
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
success = await subtract_user_balance(
db, user, renewal_cost,
"Автопродление подписки"
)
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
if processed_count > 0 or failed_count > 0:
await self._log_monitoring_event(
db, "autopayments_processed",
f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}",
{"processed": processed_count, "failed": failed_count}
)
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
async def _send_subscription_expired_notification(self, user: User) -> bool:
try:
message = """
⛔ Подписка истекла
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
try:
from app.utils.formatters import format_days_declension
texts = get_texts(user.language)
days_text = format_days_declension(days, user.language)
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
⚠️ Подписка истекает через {days_text}!
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 Автоплатеж: {autopay_status}
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
message = f"""
🎁 Тестовая подписка скоро закончится!
Ваша тестовая подписка истекает через 2 часа.
💎 Не хотите остаться без VPN?
Переходите на полную подписку!
🔥 Специальное предложение:
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
• Безлимитный трафик
• Все серверы доступны
• Скорость до 1ГБит/сек
⚡️ Успейте оформить до окончания тестового периода!
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_SUCCESS.format(
days=days,
amount=settings.format_price(amount)
)
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_FAILED.format(
balance=settings.format_price(balance),
required=settings.format_price(required)
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.utcnow()
if now.hour != 3:
return
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
deleted_count = 0
for user in inactive_users:
if not user.subscription or not user.subscription.is_active:
success = await delete_user(db, user)
if success:
deleted_count += 1
if deleted_count > 0:
await self._log_monitoring_event(
db, "inactive_users_cleanup",
f"Удалено {deleted_count} неактивных пользователей",
{"deleted_count": deleted_count}
)
logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей")
except Exception as e:
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
async def _sync_with_remnawave(self, db: AsyncSession):
try:
now = datetime.utcnow()
if now.minute != 0:
return
async with self.subscription_service.api as api:
system_stats = await api.get_system_stats()
await self._log_monitoring_event(
db, "remnawave_sync",
"Синхронизация с RemnaWave завершена",
{"stats": system_stats}
)
except Exception as e:
logger.error(f"Ошибка синхронизации с RemnaWave: {e}")
await self._log_monitoring_event(
db, "remnawave_sync_error",
f"Ошибка синхронизации с RemnaWave: {str(e)}",
{"error": str(e)},
is_success=False
)
async def _log_monitoring_event(
self,
db: AsyncSession,
event_type: str,
message: str,
data: Dict[str, Any] = None,
is_success: bool = True
):
try:
log_entry = MonitoringLog(
event_type=event_type,
message=message,
data=data or {},
is_success=is_success
)
db.add(log_entry)
await db.commit()
except Exception as e:
logger.error(f"Ошибка логирования события мониторинга: {e}")
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
try:
from sqlalchemy import select, desc
recent_events_result = await db.execute(
select(MonitoringLog)
.order_by(desc(MonitoringLog.created_at))
.limit(10)
)
recent_events = recent_events_result.scalars().all()
yesterday = datetime.utcnow() - timedelta(days=1)
events_24h_result = await db.execute(
select(MonitoringLog)
.where(MonitoringLog.created_at >= yesterday)
)
events_24h = events_24h_result.scalars().all()
successful_events = sum(1 for event in events_24h if event.is_success)
failed_events = sum(1 for event in events_24h if not event.is_success)
return {
"is_running": self.is_running,
"last_update": datetime.utcnow(),
"recent_events": [
{
"type": event.event_type,
"message": event.message,
"success": event.is_success,
"created_at": event.created_at
}
for event in recent_events
],
"stats_24h": {
"total_events": len(events_24h),
"successful": successful_events,
"failed": failed_events,
"success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0
}
}
except Exception as e:
logger.error(f"Ошибка получения статуса мониторинга: {e}")
return {
"is_running": self.is_running,
"last_update": datetime.utcnow(),
"recent_events": [],
"stats_24h": {
"total_events": 0,
"successful": 0,
"failed": 0,
"success_rate": 0
}
}
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
try:
expired_subscriptions = await get_expired_subscriptions(db)
expired_count = 0
for subscription in expired_subscriptions:
await deactivate_subscription(db, subscription)
expired_count += 1
expiring_subscriptions = await get_expiring_subscriptions(db, 1)
expiring_count = len(expiring_subscriptions)
autopay_subscriptions = await get_subscriptions_for_autopay(db)
autopay_processed = 0
for subscription in autopay_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if user and user.balance_kopeks >= settings.PRICE_30_DAYS:
autopay_processed += 1
await self._log_monitoring_event(
db, "manual_check_subscriptions",
f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}",
{
"expired": expired_count,
"expiring": expiring_count,
"autopay_ready": autopay_processed
}
)
return {
"expired": expired_count,
"expiring": expiring_count,
"autopay_ready": autopay_processed
}
except Exception as e:
logger.error(f"Ошибка принудительной проверки подписок: {e}")
return {"expired": 0, "expiring": 0, "autopay_ready": 0}
async def get_monitoring_logs(
self,
db: AsyncSession,
limit: int = 50,
event_type: Optional[str] = None,
page: int = 1,
per_page: int = 20
) -> List[Dict[str, Any]]:
try:
from sqlalchemy import select, desc
query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
if page > 1 or per_page != 20:
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
else:
query = query.limit(limit)
result = await db.execute(query)
logs = result.scalars().all()
return [
{
"id": log.id,
"event_type": log.event_type,
"message": log.message,
"data": log.data,
"is_success": log.is_success,
"created_at": log.created_at
}
for log in logs
]
except Exception as e:
logger.error(f"Ошибка получения логов мониторинга: {e}")
return []
async def get_monitoring_logs_count(
self,
db: AsyncSession,
event_type: Optional[str] = None
) -> int:
try:
from sqlalchemy import select, func
query = select(func.count(MonitoringLog.id))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
result = await db.execute(query)
count = result.scalar()
return count or 0
except Exception as e:
logger.error(f"Ошибка получения количества логов: {e}")
return 0
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
try:
from sqlalchemy import delete, select
if days == 0:
result = await db.execute(delete(MonitoringLog))
else:
cutoff_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
)
deleted_count = result.rowcount
await db.commit()
if days == 0:
logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)")
else:
logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)")
return deleted_count
except Exception as e:
logger.error(f"Ошибка очистки логов: {e}")
await db.rollback()
return 0
monitoring_service = MonitoringService()