feat(traffic): добавлен новый мониторинг трафика v2 с проверкой дельты и snapshot

Новый функционал:
- Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot
- Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API
- Фильтрация по нодам (TRAFFIC_MONIT
This commit is contained in:
gy9vin
2026-01-10 00:47:23 +03:00
parent eaeb6def51
commit 6e1d671df2
17 changed files with 1517 additions and 524 deletions

View File

@@ -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 # Включить проверку пользователей по черному списку

View File

@@ -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:

View File

@@ -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=[

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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"))
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"💳 Текущий баланс: {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"{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_buy",
)],
[InlineKeyboardButton(
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
text="💎 Продлить подписку",
callback_data="subscription_extend",
)],
[InlineKeyboardButton(
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
callback_data="subscription_add_devices",
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"))
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"💳 Текущий баланс: {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"{topup_line2}"
f"💳 Текущий баланс: <b>{settings.format_price(balance)}</b>\n\n"
f"👇 <b>Выберите действие:</b>"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
callback_data="subscription_buy",
)],
[InlineKeyboardButton(
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
text="💎 Продлить подписку",
callback_data="subscription_extend",
)],
[InlineKeyboardButton(
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
callback_data="subscription_add_devices",
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

View File

@@ -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

View File

@@ -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("Мониторинг трафика отключен настройками")

View 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