mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
feat(traffic): добавлен новый мониторинг трафика v2 с проверкой дельты и snapshot
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
This commit is contained in:
32
.env.example
32
.env.example
@@ -66,11 +66,33 @@ ADMIN_REPORTS_CHAT_ID= # Опционально: чат
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
|
||||
# Мониторинг трафика
|
||||
TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление)
|
||||
TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24)
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат)
|
||||
# ===== МОНИТОРИНГ ТРАФИКА =====
|
||||
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
|
||||
# Через указанный интервал проверяется дельта (разница) трафика.
|
||||
# Если дельта превышает порог — отправляется уведомление админам.
|
||||
|
||||
# Быстрая проверка (дельта трафика за интервал)
|
||||
TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку
|
||||
TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах
|
||||
TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал)
|
||||
|
||||
# Суточная проверка (трафик за 24 часа через bandwidth API)
|
||||
TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку
|
||||
TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC)
|
||||
TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ
|
||||
|
||||
# Куда отправлять уведомления
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности
|
||||
|
||||
# Фильтрация по серверам (UUID нод через запятую)
|
||||
TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все)
|
||||
TRAFFIC_IGNORED_NODES= # Исключить эти ноды
|
||||
|
||||
# Производительность
|
||||
TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей
|
||||
TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API
|
||||
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты)
|
||||
TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте)
|
||||
|
||||
# Черный список
|
||||
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
|
||||
|
||||
@@ -237,11 +237,32 @@ class Settings(BaseSettings):
|
||||
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
||||
|
||||
# Настройки мониторинга трафика
|
||||
TRAFFIC_MONITORING_ENABLED: bool = False
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
|
||||
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки)
|
||||
TRAFFIC_MONITORING_ENABLED: bool = False # Глобальный переключатель (для обратной совместимости)
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки (для обратной совместимости)
|
||||
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (для обратной совместимости)
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
# Новый мониторинг трафика v2
|
||||
# Быстрая проверка (текущий использованный трафик)
|
||||
TRAFFIC_FAST_CHECK_ENABLED: bool = False
|
||||
TRAFFIC_FAST_CHECK_INTERVAL_MINUTES: int = 10 # Интервал проверки в минутах
|
||||
TRAFFIC_FAST_CHECK_THRESHOLD_GB: float = 5.0 # Порог в ГБ для быстрой проверки
|
||||
|
||||
# Суточная проверка (трафик за 24 часа)
|
||||
TRAFFIC_DAILY_CHECK_ENABLED: bool = False
|
||||
TRAFFIC_DAILY_CHECK_TIME: str = "00:00" # Время суточной проверки (HH:MM)
|
||||
TRAFFIC_DAILY_THRESHOLD_GB: float = 50.0 # Порог суточного трафика в ГБ
|
||||
|
||||
# Фильтрация по серверам (UUID нод через запятую)
|
||||
TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все)
|
||||
TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды
|
||||
|
||||
# Параллельность и кулдаун
|
||||
TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей
|
||||
TRAFFIC_CHECK_CONCURRENCY: int = 10 # Параллельных запросов
|
||||
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES: int = 60 # Кулдаун уведомлений (минуты)
|
||||
TRAFFIC_SNAPSHOT_TTL_HOURS: int = 24 # TTL для snapshot трафика в Redis (часы)
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
ENABLE_AUTOPAY: bool = False
|
||||
@@ -829,6 +850,23 @@ class Settings(BaseSettings):
|
||||
def get_remnawave_auto_sync_times(self) -> List[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def get_traffic_monitored_nodes(self) -> List[str]:
|
||||
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
|
||||
if not self.TRAFFIC_MONITORED_NODES:
|
||||
return []
|
||||
return [n.strip() for n in self.TRAFFIC_MONITORED_NODES.split(",") if n.strip()]
|
||||
|
||||
def get_traffic_ignored_nodes(self) -> List[str]:
|
||||
"""Возвращает список UUID нод для исключения из мониторинга"""
|
||||
if not self.TRAFFIC_IGNORED_NODES:
|
||||
return []
|
||||
return [n.strip() for n in self.TRAFFIC_IGNORED_NODES.split(",") if n.strip()]
|
||||
|
||||
def get_traffic_daily_check_time(self) -> Optional[time]:
|
||||
"""Возвращает время суточной проверки трафика"""
|
||||
times = self.parse_daily_time_list(self.TRAFFIC_DAILY_CHECK_TIME)
|
||||
return times[0] if times else None
|
||||
|
||||
def get_display_name_banned_keywords(self) -> List[str]:
|
||||
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
||||
if raw_value is None:
|
||||
|
||||
@@ -774,81 +774,53 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_traffic_check")
|
||||
@admin_required
|
||||
async def traffic_check_callback(callback: CallbackQuery):
|
||||
"""Ручная проверка трафика всех пользователей."""
|
||||
"""Ручная проверка трафика — использует snapshot и дельту."""
|
||||
try:
|
||||
# Проверяем, включен ли мониторинг трафика
|
||||
if not traffic_monitoring_scheduler.is_enabled():
|
||||
await callback.answer(
|
||||
"⚠️ Мониторинг трафика отключен в настройках\n"
|
||||
"Включите TRAFFIC_MONITORING_ENABLED=true в .env",
|
||||
"Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer("⏳ Запускаем проверку трафика...")
|
||||
await callback.answer("⏳ Запускаем проверку трафика (дельта)...")
|
||||
|
||||
# Используем run_fast_check — он сравнивает с snapshot и отправляет уведомления
|
||||
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler_v2
|
||||
|
||||
# Устанавливаем бота, если не установлен
|
||||
if not traffic_monitoring_scheduler.bot:
|
||||
traffic_monitoring_scheduler.set_bot(callback.bot)
|
||||
if not traffic_monitoring_scheduler_v2.bot:
|
||||
traffic_monitoring_scheduler_v2.set_bot(callback.bot)
|
||||
|
||||
checked_count = 0
|
||||
exceeded_count = 0
|
||||
exceeded_users = []
|
||||
violations = await traffic_monitoring_scheduler_v2.run_fast_check_now()
|
||||
|
||||
async for db in get_db():
|
||||
from app.database.crud.user import get_users_with_active_subscriptions
|
||||
|
||||
users = await get_users_with_active_subscriptions(db)
|
||||
|
||||
for user in users:
|
||||
if user.remnawave_uuid:
|
||||
is_exceeded, traffic_info = await traffic_monitoring_service.check_user_traffic_threshold(
|
||||
db,
|
||||
user.remnawave_uuid,
|
||||
user.telegram_id
|
||||
)
|
||||
checked_count += 1
|
||||
|
||||
if is_exceeded:
|
||||
exceeded_count += 1
|
||||
total_gb = traffic_info.get('total_gb', 0)
|
||||
exceeded_users.append({
|
||||
'telegram_id': user.telegram_id,
|
||||
'name': user.full_name or str(user.telegram_id),
|
||||
'traffic_gb': total_gb
|
||||
})
|
||||
|
||||
# Отправляем уведомление админам
|
||||
if traffic_monitoring_scheduler._should_send_notification(user.remnawave_uuid):
|
||||
await traffic_monitoring_service.process_suspicious_traffic(
|
||||
db,
|
||||
user.remnawave_uuid,
|
||||
traffic_info,
|
||||
callback.bot
|
||||
)
|
||||
traffic_monitoring_scheduler._record_notification(user.remnawave_uuid)
|
||||
|
||||
break
|
||||
|
||||
threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY
|
||||
# Получаем информацию о snapshot
|
||||
snapshot_age = await traffic_monitoring_scheduler_v2.service.get_snapshot_age_minutes()
|
||||
threshold_gb = traffic_monitoring_scheduler_v2.service.get_fast_check_threshold_gb()
|
||||
|
||||
text = f"""
|
||||
📊 <b>Проверка трафика завершена</b>
|
||||
|
||||
🔍 <b>Результаты:</b>
|
||||
• Проверено пользователей: {checked_count}
|
||||
• Превышений порога: {exceeded_count}
|
||||
• Порог: {threshold_gb} ГБ/сутки
|
||||
🔍 <b>Результаты (дельта):</b>
|
||||
• Превышений за интервал: {len(violations)}
|
||||
• Порог дельты: {threshold_gb} ГБ
|
||||
• Возраст snapshot: {snapshot_age:.1f} мин
|
||||
|
||||
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
||||
"""
|
||||
|
||||
if exceeded_users:
|
||||
text += "\n⚠️ <b>Пользователи с превышением:</b>\n"
|
||||
for u in exceeded_users[:10]:
|
||||
text += f"• {u['name']}: {u['traffic_gb']:.1f} ГБ\n"
|
||||
if len(exceeded_users) > 10:
|
||||
text += f"... и ещё {len(exceeded_users) - 10}\n"
|
||||
if violations:
|
||||
text += "\n⚠️ <b>Превышения дельты:</b>\n"
|
||||
for v in violations[:10]:
|
||||
name = v.full_name or v.user_uuid[:8]
|
||||
text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n"
|
||||
if len(violations) > 10:
|
||||
text += f"... и ещё {len(violations) - 10}\n"
|
||||
text += "\n📨 Уведомления отправлены (с учётом кулдауна)"
|
||||
else:
|
||||
text += "\n✅ Превышений не обнаружено"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
|
||||
@@ -267,7 +267,10 @@ class CloudPaymentsPaymentMixin:
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
# Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений
|
||||
await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
|
||||
|
||||
|
||||
@@ -377,12 +377,14 @@ class CryptoBotPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
@@ -392,7 +394,8 @@ class CryptoBotPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and bot_instance:
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and bot_instance and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -411,9 +411,12 @@ class FreekassaPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -422,7 +425,8 @@ class FreekassaPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -396,9 +396,12 @@ class MulenPayPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -407,7 +410,8 @@ class MulenPayPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -499,9 +499,12 @@ class Pal24PaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -510,7 +513,8 @@ class Pal24PaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -485,9 +485,12 @@ class PlategaPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -496,7 +499,8 @@ class PlategaPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -534,12 +534,14 @@ class TelegramStarsMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
@@ -549,7 +551,8 @@ class TelegramStarsMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
|
||||
@@ -569,9 +569,12 @@ class WataPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -580,7 +583,8 @@ class WataPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -687,8 +687,9 @@ class YooKassaPaymentMixin:
|
||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
# Отправляем уведомление пользователю (если не включен режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP,
|
||||
# т.к. в этом случае уведомление будет отправлено из auto_activate_subscription_after_topup)
|
||||
if getattr(self, "bot", None) and not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
@@ -738,12 +739,14 @@ class YooKassaPaymentMixin:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
topup_amount=payment.amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
@@ -753,7 +756,8 @@ class YooKassaPaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -717,7 +717,8 @@ async def auto_activate_subscription_after_topup(
|
||||
user: User,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
topup_amount: Optional[int] = None,
|
||||
) -> tuple[bool, bool]:
|
||||
"""
|
||||
Умная автоактивация после пополнения баланса.
|
||||
|
||||
@@ -727,6 +728,14 @@ async def auto_activate_subscription_after_topup(
|
||||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||||
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
|
||||
Args:
|
||||
topup_amount: Сумма пополнения в копейках (для отображения в уведомлении)
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool]: (success, notification_sent)
|
||||
- success: True если подписка активирована
|
||||
- notification_sent: True если уведомление отправлено пользователю
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
||||
@@ -739,70 +748,98 @@ async def auto_activate_subscription_after_topup(
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if not user or not getattr(user, "id", None):
|
||||
return False
|
||||
return (False, False)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Если автоактивация отключена - только отправляем предупреждение
|
||||
# Если автоактивация отключена - только отправляем уведомление
|
||||
if not settings.is_auto_activate_after_topup_enabled():
|
||||
# Отправляем предупреждение если включен режим и нет активной подписки
|
||||
if (
|
||||
settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
|
||||
and bot
|
||||
and (not subscription or subscription.status not in ("active", "ACTIVE"))
|
||||
):
|
||||
notification_sent = False
|
||||
# Отправляем уведомление если включен режим
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||||
try:
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
f"{'─' * 25}\n\n"
|
||||
f"⚠️ <b>ВАЖНО!</b> ⚠️\n\n"
|
||||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||||
f"Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||||
f"👇 <b>Выберите действие:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
|
||||
callback_data="subscription_buy",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
|
||||
callback_data="subscription_add_devices",
|
||||
)],
|
||||
]
|
||||
has_active_subscription = (
|
||||
subscription
|
||||
and subscription.status in ("active", "ACTIVE")
|
||||
)
|
||||
|
||||
# Формируем строку с суммой пополнения
|
||||
topup_line = ""
|
||||
if topup_amount:
|
||||
topup_line = f"➕ Пополнено: <b>{settings.format_price(topup_amount)}</b>\n"
|
||||
|
||||
# Определяем состояние подписки
|
||||
is_trial = subscription and getattr(subscription, 'is_trial', False)
|
||||
|
||||
if has_active_subscription and not is_trial:
|
||||
# Активная платная подписка — 2 кнопки
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"{topup_line}"
|
||||
f"💳 Текущий баланс: <b>{settings.format_price(user.balance_kopeks)}</b>\n\n"
|
||||
f"👇 <b>Выберите действие:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💎 Продлить подписку",
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📱 Изменить устройства",
|
||||
callback_data="subscription_change_devices",
|
||||
)],
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Триал или подписка закончилась — 1 кнопка
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"{topup_line}"
|
||||
f"💳 Текущий баланс: <b>{settings.format_price(user.balance_kopeks)}</b>\n\n"
|
||||
f"{'━' * 20}\n\n"
|
||||
f"🚨🚨🚨 <b>ВНИМАНИЕ!</b> 🚨🚨🚨\n\n"
|
||||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||||
f"⚠️ Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||||
f"👇 <b>Обязательно оформите подписку:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="🚀 КУПИТЬ ПОДПИСКУ",
|
||||
callback_data="menu_buy",
|
||||
)],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=warning_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
notification_sent = True
|
||||
logger.info(
|
||||
"⚠️ Отправлено предупреждение об активации подписки пользователю %s (автоактивация выключена)",
|
||||
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (автоактивация выключена, подписка %s)",
|
||||
user.telegram_id,
|
||||
"активна" if has_active_subscription else "неактивна",
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
|
||||
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
return False
|
||||
return (False, notification_sent)
|
||||
|
||||
# Если подписка активна — ничего не делаем
|
||||
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
logger.info(
|
||||
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
return (False, False)
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
@@ -839,7 +876,7 @@ async def auto_activate_subscription_after_topup(
|
||||
|
||||
if not available_periods:
|
||||
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
||||
return False
|
||||
return (False, False)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
@@ -875,56 +912,84 @@ async def auto_activate_subscription_after_topup(
|
||||
user.telegram_id,
|
||||
balance,
|
||||
)
|
||||
# Отправляем предупреждение пользователю если включен режим и подписки нет
|
||||
if (
|
||||
settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
|
||||
and bot
|
||||
and (not subscription or subscription.status not in ("active", "ACTIVE"))
|
||||
):
|
||||
notification_sent = False
|
||||
# Отправляем уведомление если включен режим
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||||
try:
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"💳 Текущий баланс: {settings.format_price(balance)}\n\n"
|
||||
f"{'─' * 25}\n\n"
|
||||
f"⚠️ <b>ВАЖНО!</b> ⚠️\n\n"
|
||||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||||
f"Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||||
f"👇 <b>Выберите действие:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
|
||||
callback_data="subscription_buy",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
|
||||
callback_data="subscription_add_devices",
|
||||
)],
|
||||
]
|
||||
has_active_subscription = (
|
||||
subscription
|
||||
and subscription.status in ("active", "ACTIVE")
|
||||
)
|
||||
|
||||
# Формируем строку с суммой пополнения
|
||||
topup_line2 = ""
|
||||
if topup_amount:
|
||||
topup_line2 = f"➕ Пополнено: <b>{settings.format_price(topup_amount)}</b>\n"
|
||||
|
||||
# Определяем состояние подписки
|
||||
is_trial2 = subscription and getattr(subscription, 'is_trial', False)
|
||||
|
||||
if has_active_subscription and not is_trial2:
|
||||
# Активная платная подписка — 2 кнопки
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"{topup_line2}"
|
||||
f"💳 Текущий баланс: <b>{settings.format_price(balance)}</b>\n\n"
|
||||
f"👇 <b>Выберите действие:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💎 Продлить подписку",
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📱 Изменить устройства",
|
||||
callback_data="subscription_change_devices",
|
||||
)],
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Триал или подписка закончилась — 1 кнопка
|
||||
warning_message = (
|
||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
||||
f"{topup_line2}"
|
||||
f"💳 Текущий баланс: <b>{settings.format_price(balance)}</b>\n\n"
|
||||
f"{'━' * 20}\n\n"
|
||||
f"🚨🚨🚨 <b>ВНИМАНИЕ!</b> 🚨🚨🚨\n\n"
|
||||
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
|
||||
f"⚠️ Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
|
||||
f"👇 <b>Обязательно оформите подписку:</b>"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="🚀 КУПИТЬ ПОДПИСКУ",
|
||||
callback_data="menu_buy",
|
||||
)],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=warning_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
notification_sent = True
|
||||
logger.info(
|
||||
"⚠️ Отправлено предупреждение об активации подписки пользователю %s",
|
||||
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (недостаточно средств, подписка %s)",
|
||||
user.telegram_id,
|
||||
"активна" if has_active_subscription else "неактивна",
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
|
||||
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
return False
|
||||
return (False, notification_sent)
|
||||
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
|
||||
@@ -1085,7 +1150,7 @@ async def auto_activate_subscription_after_topup(
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return True
|
||||
return (True, True) # success=True, notification_sent=True (об активации)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -1094,7 +1159,7 @@ async def auto_activate_subscription_after_topup(
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return (False, False)
|
||||
|
||||
|
||||
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -316,9 +316,12 @@ class TributeService:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(session, user)
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
session, user, bot=self.bot, topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
@@ -327,7 +330,8 @@ class TributeService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and self.bot:
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and self.bot and not activation_notification_sent:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
7
main.py
7
main.py
@@ -589,10 +589,9 @@ async def main():
|
||||
traffic_monitoring_task = asyncio.create_task(
|
||||
traffic_monitoring_scheduler.start_monitoring()
|
||||
)
|
||||
interval_hours = traffic_monitoring_scheduler.get_interval_hours()
|
||||
threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY
|
||||
stage.log(f"Интервал проверки: {interval_hours} ч")
|
||||
stage.log(f"Порог трафика: {threshold_gb} ГБ/сутки")
|
||||
# Показываем информацию о новом мониторинге v2
|
||||
status_info = traffic_monitoring_scheduler.get_status_info()
|
||||
stage.log(status_info)
|
||||
else:
|
||||
traffic_monitoring_task = None
|
||||
stage.skip("Мониторинг трафика отключен настройками")
|
||||
|
||||
380
tests/services/test_traffic_monitoring_redis.py
Normal file
380
tests/services/test_traffic_monitoring_redis.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Тесты для хранения snapshot трафика в Redis.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.services.traffic_monitoring_service import (
|
||||
TrafficMonitoringServiceV2,
|
||||
TRAFFIC_SNAPSHOT_KEY,
|
||||
TRAFFIC_SNAPSHOT_TIME_KEY,
|
||||
TRAFFIC_NOTIFICATION_CACHE_KEY,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
"""Создаёт экземпляр сервиса для тестов."""
|
||||
return TrafficMonitoringServiceV2()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache():
|
||||
"""Мок для cache сервиса."""
|
||||
with patch('app.services.traffic_monitoring_service.cache') as mock:
|
||||
mock.set = AsyncMock(return_value=True)
|
||||
mock.get = AsyncMock(return_value=None)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_snapshot():
|
||||
"""Пример snapshot данных."""
|
||||
return {
|
||||
"uuid-1": 1073741824.0, # 1 GB
|
||||
"uuid-2": 2147483648.0, # 2 GB
|
||||
"uuid-3": 5368709120.0, # 5 GB
|
||||
}
|
||||
|
||||
|
||||
# ============== Тесты сохранения snapshot в Redis ==============
|
||||
|
||||
async def test_save_snapshot_to_redis_success(service, mock_cache, sample_snapshot):
|
||||
"""Тест успешного сохранения snapshot в Redis."""
|
||||
mock_cache.set = AsyncMock(return_value=True)
|
||||
|
||||
result = await service._save_snapshot_to_redis(sample_snapshot)
|
||||
|
||||
assert result is True
|
||||
assert mock_cache.set.call_count == 2 # snapshot + time
|
||||
|
||||
# Проверяем что сохранён snapshot
|
||||
first_call = mock_cache.set.call_args_list[0]
|
||||
assert first_call[0][0] == TRAFFIC_SNAPSHOT_KEY
|
||||
assert first_call[0][1] == sample_snapshot
|
||||
|
||||
|
||||
async def test_save_snapshot_to_redis_failure(service, mock_cache, sample_snapshot):
|
||||
"""Тест неудачного сохранения snapshot в Redis."""
|
||||
mock_cache.set = AsyncMock(return_value=False)
|
||||
|
||||
result = await service._save_snapshot_to_redis(sample_snapshot)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_save_snapshot_to_redis_exception(service, mock_cache, sample_snapshot):
|
||||
"""Тест обработки исключения при сохранении."""
|
||||
mock_cache.set = AsyncMock(side_effect=Exception("Redis error"))
|
||||
|
||||
result = await service._save_snapshot_to_redis(sample_snapshot)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ============== Тесты загрузки snapshot из Redis ==============
|
||||
|
||||
async def test_load_snapshot_from_redis_success(service, mock_cache, sample_snapshot):
|
||||
"""Тест успешной загрузки snapshot из Redis."""
|
||||
mock_cache.get = AsyncMock(return_value=sample_snapshot)
|
||||
|
||||
result = await service._load_snapshot_from_redis()
|
||||
|
||||
assert result == sample_snapshot
|
||||
mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_KEY)
|
||||
|
||||
|
||||
async def test_load_snapshot_from_redis_empty(service, mock_cache):
|
||||
"""Тест загрузки когда snapshot отсутствует."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
|
||||
result = await service._load_snapshot_from_redis()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_load_snapshot_from_redis_invalid_data(service, mock_cache):
|
||||
"""Тест загрузки невалидных данных."""
|
||||
mock_cache.get = AsyncMock(return_value="not a dict")
|
||||
|
||||
result = await service._load_snapshot_from_redis()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_load_snapshot_from_redis_exception(service, mock_cache):
|
||||
"""Тест обработки исключения при загрузке."""
|
||||
mock_cache.get = AsyncMock(side_effect=Exception("Redis error"))
|
||||
|
||||
result = await service._load_snapshot_from_redis()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============== Тесты времени snapshot ==============
|
||||
|
||||
async def test_get_snapshot_time_from_redis_success(service, mock_cache):
|
||||
"""Тест получения времени snapshot."""
|
||||
test_time = datetime(2024, 1, 15, 12, 30, 0)
|
||||
mock_cache.get = AsyncMock(return_value=test_time.isoformat())
|
||||
|
||||
result = await service._get_snapshot_time_from_redis()
|
||||
|
||||
assert result == test_time
|
||||
mock_cache.get.assert_called_once_with(TRAFFIC_SNAPSHOT_TIME_KEY)
|
||||
|
||||
|
||||
async def test_get_snapshot_time_from_redis_empty(service, mock_cache):
|
||||
"""Тест когда время отсутствует."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
|
||||
result = await service._get_snapshot_time_from_redis()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============== Тесты has_snapshot ==============
|
||||
|
||||
async def test_has_snapshot_redis_exists(service, mock_cache, sample_snapshot):
|
||||
"""Тест has_snapshot когда snapshot есть в Redis."""
|
||||
mock_cache.get = AsyncMock(return_value=sample_snapshot)
|
||||
|
||||
result = await service.has_snapshot()
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_has_snapshot_memory_fallback(service, mock_cache):
|
||||
"""Тест has_snapshot с fallback на память."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
|
||||
# Устанавливаем данные в память
|
||||
service._memory_snapshot = {"uuid-1": 1000.0}
|
||||
service._memory_snapshot_time = datetime.utcnow()
|
||||
|
||||
result = await service.has_snapshot()
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_has_snapshot_none(service, mock_cache):
|
||||
"""Тест has_snapshot когда snapshot нет нигде."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
service._memory_snapshot = {}
|
||||
service._memory_snapshot_time = None
|
||||
|
||||
result = await service.has_snapshot()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ============== Тесты get_snapshot_age_minutes ==============
|
||||
|
||||
async def test_get_snapshot_age_minutes_from_redis(service, mock_cache):
|
||||
"""Тест возраста snapshot из Redis."""
|
||||
# Snapshot создан 30 минут назад
|
||||
past_time = datetime.utcnow() - timedelta(minutes=30)
|
||||
mock_cache.get = AsyncMock(return_value=past_time.isoformat())
|
||||
|
||||
result = await service.get_snapshot_age_minutes()
|
||||
|
||||
assert 29 <= result <= 31 # Допуск на время выполнения
|
||||
|
||||
|
||||
async def test_get_snapshot_age_minutes_memory_fallback(service, mock_cache):
|
||||
"""Тест возраста snapshot из памяти."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
service._memory_snapshot_time = datetime.utcnow() - timedelta(minutes=15)
|
||||
|
||||
result = await service.get_snapshot_age_minutes()
|
||||
|
||||
assert 14 <= result <= 16
|
||||
|
||||
|
||||
async def test_get_snapshot_age_minutes_no_snapshot(service, mock_cache):
|
||||
"""Тест возраста когда snapshot нет."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
service._memory_snapshot_time = None
|
||||
|
||||
result = await service.get_snapshot_age_minutes()
|
||||
|
||||
assert result == float('inf')
|
||||
|
||||
|
||||
# ============== Тесты _save_snapshot (с fallback) ==============
|
||||
|
||||
async def test_save_snapshot_redis_success(service, mock_cache, sample_snapshot):
|
||||
"""Тест сохранения snapshot в Redis успешно."""
|
||||
mock_cache.set = AsyncMock(return_value=True)
|
||||
|
||||
# Заполняем память чтобы проверить что она очистится
|
||||
service._memory_snapshot = {"old": 123.0}
|
||||
service._memory_snapshot_time = datetime.utcnow()
|
||||
|
||||
result = await service._save_snapshot(sample_snapshot)
|
||||
|
||||
assert result is True
|
||||
assert service._memory_snapshot == {} # Память очищена
|
||||
assert service._memory_snapshot_time is None
|
||||
|
||||
|
||||
async def test_save_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot):
|
||||
"""Тест fallback на память когда Redis недоступен."""
|
||||
mock_cache.set = AsyncMock(return_value=False)
|
||||
|
||||
result = await service._save_snapshot(sample_snapshot)
|
||||
|
||||
assert result is True
|
||||
assert service._memory_snapshot == sample_snapshot
|
||||
assert service._memory_snapshot_time is not None
|
||||
|
||||
|
||||
# ============== Тесты _get_current_snapshot ==============
|
||||
|
||||
async def test_get_current_snapshot_from_redis(service, mock_cache, sample_snapshot):
|
||||
"""Тест получения snapshot из Redis."""
|
||||
mock_cache.get = AsyncMock(return_value=sample_snapshot)
|
||||
|
||||
result = await service._get_current_snapshot()
|
||||
|
||||
assert result == sample_snapshot
|
||||
|
||||
|
||||
async def test_get_current_snapshot_fallback_to_memory(service, mock_cache, sample_snapshot):
|
||||
"""Тест fallback на память."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
service._memory_snapshot = sample_snapshot
|
||||
|
||||
result = await service._get_current_snapshot()
|
||||
|
||||
assert result == sample_snapshot
|
||||
|
||||
|
||||
# ============== Тесты уведомлений ==============
|
||||
|
||||
async def test_save_notification_to_redis(service, mock_cache):
|
||||
"""Тест сохранения времени уведомления."""
|
||||
mock_cache.set = AsyncMock(return_value=True)
|
||||
|
||||
result = await service._save_notification_to_redis("uuid-123")
|
||||
|
||||
assert result is True
|
||||
mock_cache.set.assert_called_once()
|
||||
call_args = mock_cache.set.call_args
|
||||
assert "traffic:notifications:uuid-123" in call_args[0][0]
|
||||
|
||||
|
||||
async def test_get_notification_time_from_redis(service, mock_cache):
|
||||
"""Тест получения времени уведомления."""
|
||||
test_time = datetime(2024, 1, 15, 10, 0, 0)
|
||||
mock_cache.get = AsyncMock(return_value=test_time.isoformat())
|
||||
|
||||
result = await service._get_notification_time_from_redis("uuid-123")
|
||||
|
||||
assert result == test_time
|
||||
|
||||
|
||||
async def test_should_send_notification_no_previous(service, mock_cache):
|
||||
"""Тест should_send_notification когда уведомлений не было."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
service._memory_notification_cache = {}
|
||||
|
||||
result = await service.should_send_notification("uuid-123")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_should_send_notification_cooldown_active(service, mock_cache):
|
||||
"""Тест should_send_notification когда кулдаун активен."""
|
||||
# Уведомление было 5 минут назад, кулдаун 60 минут
|
||||
recent_time = datetime.utcnow() - timedelta(minutes=5)
|
||||
mock_cache.get = AsyncMock(return_value=recent_time.isoformat())
|
||||
|
||||
result = await service.should_send_notification("uuid-123")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_should_send_notification_cooldown_expired(service, mock_cache):
|
||||
"""Тест should_send_notification когда кулдаун истёк."""
|
||||
# Уведомление было 120 минут назад, кулдаун 60 минут
|
||||
old_time = datetime.utcnow() - timedelta(minutes=120)
|
||||
mock_cache.get = AsyncMock(return_value=old_time.isoformat())
|
||||
|
||||
result = await service.should_send_notification("uuid-123")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_record_notification_redis(service, mock_cache):
|
||||
"""Тест record_notification сохраняет в Redis."""
|
||||
mock_cache.set = AsyncMock(return_value=True)
|
||||
|
||||
await service.record_notification("uuid-123")
|
||||
|
||||
mock_cache.set.assert_called_once()
|
||||
|
||||
|
||||
async def test_record_notification_fallback_to_memory(service, mock_cache):
|
||||
"""Тест record_notification с fallback на память."""
|
||||
mock_cache.set = AsyncMock(return_value=False)
|
||||
|
||||
await service.record_notification("uuid-123")
|
||||
|
||||
assert "uuid-123" in service._memory_notification_cache
|
||||
|
||||
|
||||
# ============== Тесты create_initial_snapshot ==============
|
||||
|
||||
async def test_create_initial_snapshot_uses_existing_redis(service, mock_cache, sample_snapshot):
|
||||
"""Тест что create_initial_snapshot использует существующий snapshot из Redis."""
|
||||
mock_cache.get = AsyncMock(side_effect=[
|
||||
sample_snapshot, # _load_snapshot_from_redis
|
||||
(datetime.utcnow() - timedelta(minutes=10)).isoformat(), # _get_snapshot_time_from_redis
|
||||
])
|
||||
|
||||
with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users:
|
||||
result = await service.create_initial_snapshot()
|
||||
|
||||
# Не должен вызывать API - используем существующий snapshot
|
||||
mock_get_users.assert_not_called()
|
||||
assert result == len(sample_snapshot)
|
||||
|
||||
|
||||
async def test_create_initial_snapshot_creates_new(service, mock_cache):
|
||||
"""Тест создания нового snapshot когда в Redis пусто."""
|
||||
mock_cache.get = AsyncMock(return_value=None)
|
||||
mock_cache.set = AsyncMock(return_value=True)
|
||||
|
||||
# Мокаем пользователей из API
|
||||
mock_user = MagicMock()
|
||||
mock_user.uuid = "uuid-1"
|
||||
mock_user.user_traffic = MagicMock()
|
||||
mock_user.user_traffic.used_traffic_bytes = 1073741824 # 1 GB
|
||||
|
||||
with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users:
|
||||
mock_get_users.return_value = [mock_user]
|
||||
|
||||
result = await service.create_initial_snapshot()
|
||||
|
||||
mock_get_users.assert_called_once()
|
||||
assert result == 1
|
||||
|
||||
|
||||
# ============== Тесты cleanup_notification_cache ==============
|
||||
|
||||
async def test_cleanup_notification_cache_removes_old(service, mock_cache):
|
||||
"""Тест очистки старых записей из памяти."""
|
||||
old_time = datetime.utcnow() - timedelta(hours=25)
|
||||
recent_time = datetime.utcnow() - timedelta(hours=1)
|
||||
|
||||
service._memory_notification_cache = {
|
||||
"uuid-old": old_time,
|
||||
"uuid-recent": recent_time,
|
||||
}
|
||||
|
||||
await service.cleanup_notification_cache()
|
||||
|
||||
assert "uuid-old" not in service._memory_notification_cache
|
||||
assert "uuid-recent" in service._memory_notification_cache
|
||||
Reference in New Issue
Block a user