mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +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_TOPIC_ID= # ID топика для отчетов
|
||||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||||
|
|
||||||
# Мониторинг трафика
|
# ===== МОНИТОРИНГ ТРАФИКА =====
|
||||||
TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей
|
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
|
||||||
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление)
|
# Через указанный интервал проверяется дельта (разница) трафика.
|
||||||
TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24)
|
# Если дельта превышает порог — отправляется уведомление админам.
|
||||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат)
|
|
||||||
|
# Быстрая проверка (дельта трафика за интервал)
|
||||||
|
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 # Включить проверку пользователей по черному списку
|
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
|
||||||
|
|||||||
@@ -237,11 +237,32 @@ class Settings(BaseSettings):
|
|||||||
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
||||||
|
|
||||||
# Настройки мониторинга трафика
|
# Настройки мониторинга трафика
|
||||||
TRAFFIC_MONITORING_ENABLED: bool = False
|
TRAFFIC_MONITORING_ENABLED: bool = False # Глобальный переключатель (для обратной совместимости)
|
||||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
|
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки (для обратной совместимости)
|
||||||
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки)
|
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (для обратной совместимости)
|
||||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
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"
|
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||||
|
|
||||||
ENABLE_AUTOPAY: bool = False
|
ENABLE_AUTOPAY: bool = False
|
||||||
@@ -829,6 +850,23 @@ class Settings(BaseSettings):
|
|||||||
def get_remnawave_auto_sync_times(self) -> List[time]:
|
def get_remnawave_auto_sync_times(self) -> List[time]:
|
||||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
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]:
|
def get_display_name_banned_keywords(self) -> List[str]:
|
||||||
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
||||||
if raw_value is None:
|
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")
|
@router.callback_query(F.data == "admin_mon_traffic_check")
|
||||||
@admin_required
|
@admin_required
|
||||||
async def traffic_check_callback(callback: CallbackQuery):
|
async def traffic_check_callback(callback: CallbackQuery):
|
||||||
"""Ручная проверка трафика всех пользователей."""
|
"""Ручная проверка трафика — использует snapshot и дельту."""
|
||||||
try:
|
try:
|
||||||
# Проверяем, включен ли мониторинг трафика
|
# Проверяем, включен ли мониторинг трафика
|
||||||
if not traffic_monitoring_scheduler.is_enabled():
|
if not traffic_monitoring_scheduler.is_enabled():
|
||||||
await callback.answer(
|
await callback.answer(
|
||||||
"⚠️ Мониторинг трафика отключен в настройках\n"
|
"⚠️ Мониторинг трафика отключен в настройках\n"
|
||||||
"Включите TRAFFIC_MONITORING_ENABLED=true в .env",
|
"Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env",
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
return
|
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:
|
if not traffic_monitoring_scheduler_v2.bot:
|
||||||
traffic_monitoring_scheduler.set_bot(callback.bot)
|
traffic_monitoring_scheduler_v2.set_bot(callback.bot)
|
||||||
|
|
||||||
checked_count = 0
|
violations = await traffic_monitoring_scheduler_v2.run_fast_check_now()
|
||||||
exceeded_count = 0
|
|
||||||
exceeded_users = []
|
|
||||||
|
|
||||||
async for db in get_db():
|
# Получаем информацию о snapshot
|
||||||
from app.database.crud.user import get_users_with_active_subscriptions
|
snapshot_age = await traffic_monitoring_scheduler_v2.service.get_snapshot_age_minutes()
|
||||||
|
threshold_gb = traffic_monitoring_scheduler_v2.service.get_fast_check_threshold_gb()
|
||||||
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
|
|
||||||
|
|
||||||
text = f"""
|
text = f"""
|
||||||
📊 <b>Проверка трафика завершена</b>
|
📊 <b>Проверка трафика завершена</b>
|
||||||
|
|
||||||
🔍 <b>Результаты:</b>
|
🔍 <b>Результаты (дельта):</b>
|
||||||
• Проверено пользователей: {checked_count}
|
• Превышений за интервал: {len(violations)}
|
||||||
• Превышений порога: {exceeded_count}
|
• Порог дельты: {threshold_gb} ГБ
|
||||||
• Порог: {threshold_gb} ГБ/сутки
|
• Возраст snapshot: {snapshot_age:.1f} мин
|
||||||
|
|
||||||
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if exceeded_users:
|
if violations:
|
||||||
text += "\n⚠️ <b>Пользователи с превышением:</b>\n"
|
text += "\n⚠️ <b>Превышения дельты:</b>\n"
|
||||||
for u in exceeded_users[:10]:
|
for v in violations[:10]:
|
||||||
text += f"• {u['name']}: {u['traffic_gb']:.1f} ГБ\n"
|
name = v.full_name or v.user_uuid[:8]
|
||||||
if len(exceeded_users) > 10:
|
text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n"
|
||||||
text += f"... и ещё {len(exceeded_users) - 10}\n"
|
if len(violations) > 10:
|
||||||
|
text += f"... и ещё {len(violations) - 10}\n"
|
||||||
|
text += "\n📨 Уведомления отправлены (с учётом кулдауна)"
|
||||||
|
else:
|
||||||
|
text += "\n✅ Превышений не обнаружено"
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ class CloudPaymentsPaymentMixin:
|
|||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as error:
|
||||||
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
|
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
|
||||||
|
|
||||||
|
|||||||
@@ -377,12 +377,14 @@ class CryptoBotPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
try:
|
||||||
await auto_activate_subscription_after_topup(
|
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
bot=bot_instance,
|
bot=bot_instance,
|
||||||
|
topup_amount=amount_kopeks,
|
||||||
)
|
)
|
||||||
except Exception as auto_activate_error:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -392,7 +394,8 @@ class CryptoBotPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
|
|||||||
@@ -411,9 +411,12 @@ class FreekassaPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -422,7 +425,8 @@ class FreekassaPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
|
|||||||
@@ -396,9 +396,12 @@ class MulenPayPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -407,7 +410,8 @@ class MulenPayPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|||||||
@@ -499,9 +499,12 @@ class Pal24PaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -510,7 +513,8 @@ class Pal24PaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
|
|||||||
@@ -485,9 +485,12 @@ class PlategaPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -496,7 +499,8 @@ class PlategaPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
|
|||||||
@@ -534,12 +534,14 @@ class TelegramStarsMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
try:
|
||||||
await auto_activate_subscription_after_topup(
|
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
bot=getattr(self, "bot", None),
|
bot=getattr(self, "bot", None),
|
||||||
|
topup_amount=amount_kopeks,
|
||||||
)
|
)
|
||||||
except Exception as auto_activate_error:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -549,7 +551,8 @@ class TelegramStarsMixin:
|
|||||||
exc_info=True,
|
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)
|
texts = get_texts(user.language)
|
||||||
cart_message = texts.t(
|
cart_message = texts.t(
|
||||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||||
|
|||||||
@@ -569,9 +569,12 @@ class WataPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -580,7 +583,8 @@ class WataPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
|
|||||||
@@ -687,8 +687,9 @@ class YooKassaPaymentMixin:
|
|||||||
exc_info=True, # Добавляем полный стек вызовов для отладки
|
exc_info=True, # Добавляем полный стек вызовов для отладки
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отправляем уведомление пользователю
|
# Отправляем уведомление пользователю (если не включен режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP,
|
||||||
if getattr(self, "bot", None):
|
# т.к. в этом случае уведомление будет отправлено из auto_activate_subscription_after_topup)
|
||||||
|
if getattr(self, "bot", None) and not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||||
try:
|
try:
|
||||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||||
await self._send_payment_success_notification(
|
await self._send_payment_success_notification(
|
||||||
@@ -738,12 +739,14 @@ class YooKassaPaymentMixin:
|
|||||||
has_saved_cart = False
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
try:
|
||||||
await auto_activate_subscription_after_topup(
|
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
bot=getattr(self, "bot", None),
|
bot=getattr(self, "bot", None),
|
||||||
|
topup_amount=payment.amount_kopeks,
|
||||||
)
|
)
|
||||||
except Exception as auto_activate_error:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -753,7 +756,8 @@ class YooKassaPaymentMixin:
|
|||||||
exc_info=True,
|
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
|
from app.localization.texts import get_texts
|
||||||
|
|||||||
@@ -717,7 +717,8 @@ async def auto_activate_subscription_after_topup(
|
|||||||
user: User,
|
user: User,
|
||||||
*,
|
*,
|
||||||
bot: Optional[Bot] = None,
|
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 datetime import datetime
|
||||||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
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
|
from app.services.admin_notification_service import AdminNotificationService
|
||||||
|
|
||||||
if not user or not getattr(user, "id", None):
|
if not user or not getattr(user, "id", None):
|
||||||
return False
|
return (False, False)
|
||||||
|
|
||||||
subscription = await get_subscription_by_user_id(db, user.id)
|
subscription = await get_subscription_by_user_id(db, user.id)
|
||||||
|
|
||||||
# Если автоактивация отключена - только отправляем предупреждение
|
# Если автоактивация отключена - только отправляем уведомление
|
||||||
if not settings.is_auto_activate_after_topup_enabled():
|
if not settings.is_auto_activate_after_topup_enabled():
|
||||||
# Отправляем предупреждение если включен режим и нет активной подписки
|
notification_sent = False
|
||||||
if (
|
# Отправляем уведомление если включен режим
|
||||||
settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
|
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||||||
and bot
|
|
||||||
and (not subscription or subscription.status not in ("active", "ACTIVE"))
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
texts = get_texts(getattr(user, "language", "ru"))
|
texts = get_texts(getattr(user, "language", "ru"))
|
||||||
warning_message = (
|
has_active_subscription = (
|
||||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
subscription
|
||||||
f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
|
and subscription.status in ("active", "ACTIVE")
|
||||||
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",
|
|
||||||
)],
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Формируем строку с суммой пополнения
|
||||||
|
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(
|
await bot.send_message(
|
||||||
chat_id=user.telegram_id,
|
chat_id=user.telegram_id,
|
||||||
text=warning_message,
|
text=warning_message,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
notification_sent = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"⚠️ Отправлено предупреждение об активации подписки пользователю %s (автоактивация выключена)",
|
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (автоактивация выключена, подписка %s)",
|
||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
|
"активна" if has_active_subscription else "неактивна",
|
||||||
)
|
)
|
||||||
except Exception as notify_error:
|
except Exception as notify_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
|
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
notify_error,
|
notify_error,
|
||||||
)
|
)
|
||||||
return False
|
return (False, notification_sent)
|
||||||
|
|
||||||
# Если подписка активна — ничего не делаем
|
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||||
logger.info(
|
logger.info(
|
||||||
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
|
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
|
||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
)
|
)
|
||||||
return False
|
return (False, False)
|
||||||
|
|
||||||
# Определяем параметры подписки
|
# Определяем параметры подписки
|
||||||
if subscription:
|
if subscription:
|
||||||
@@ -839,7 +876,7 @@ async def auto_activate_subscription_after_topup(
|
|||||||
|
|
||||||
if not available_periods:
|
if not available_periods:
|
||||||
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
||||||
return False
|
return (False, False)
|
||||||
|
|
||||||
subscription_service = SubscriptionService()
|
subscription_service = SubscriptionService()
|
||||||
|
|
||||||
@@ -875,56 +912,84 @@ async def auto_activate_subscription_after_topup(
|
|||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
balance,
|
balance,
|
||||||
)
|
)
|
||||||
# Отправляем предупреждение пользователю если включен режим и подписки нет
|
notification_sent = False
|
||||||
if (
|
# Отправляем уведомление если включен режим
|
||||||
settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
|
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP and bot:
|
||||||
and bot
|
|
||||||
and (not subscription or subscription.status not in ("active", "ACTIVE"))
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
texts = get_texts(getattr(user, "language", "ru"))
|
texts = get_texts(getattr(user, "language", "ru"))
|
||||||
warning_message = (
|
has_active_subscription = (
|
||||||
f"✅ <b>Баланс пополнен!</b>\n\n"
|
subscription
|
||||||
f"💳 Текущий баланс: {settings.format_price(balance)}\n\n"
|
and subscription.status in ("active", "ACTIVE")
|
||||||
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",
|
|
||||||
)],
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Формируем строку с суммой пополнения
|
||||||
|
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(
|
await bot.send_message(
|
||||||
chat_id=user.telegram_id,
|
chat_id=user.telegram_id,
|
||||||
text=warning_message,
|
text=warning_message,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
notification_sent = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"⚠️ Отправлено предупреждение об активации подписки пользователю %s",
|
"⚠️ Отправлено уведомление о пополнении баланса пользователю %s (недостаточно средств, подписка %s)",
|
||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
|
"активна" if has_active_subscription else "неактивна",
|
||||||
)
|
)
|
||||||
except Exception as notify_error:
|
except Exception as notify_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
|
"⚠️ Не удалось отправить уведомление пользователю %s: %s",
|
||||||
user.telegram_id,
|
user.telegram_id,
|
||||||
notify_error,
|
notify_error,
|
||||||
)
|
)
|
||||||
return False
|
return (False, notification_sent)
|
||||||
|
|
||||||
texts = get_texts(getattr(user, "language", "ru"))
|
texts = get_texts(getattr(user, "language", "ru"))
|
||||||
|
|
||||||
@@ -1085,7 +1150,7 @@ async def auto_activate_subscription_after_topup(
|
|||||||
notify_error,
|
notify_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return (True, True) # success=True, notification_sent=True (об активации)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1094,7 +1159,7 @@ async def auto_activate_subscription_after_topup(
|
|||||||
e,
|
e,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return False
|
return (False, False)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
|
__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
|
has_saved_cart = False
|
||||||
|
|
||||||
# Умная автоактивация если автопокупка не сработала
|
# Умная автоактивация если автопокупка не сработала
|
||||||
|
activation_notification_sent = False
|
||||||
if not auto_purchase_success:
|
if not auto_purchase_success:
|
||||||
try:
|
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:
|
except Exception as auto_activate_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||||
@@ -327,7 +330,8 @@ class TributeService:
|
|||||||
exc_info=True,
|
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
|
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_task = asyncio.create_task(
|
||||||
traffic_monitoring_scheduler.start_monitoring()
|
traffic_monitoring_scheduler.start_monitoring()
|
||||||
)
|
)
|
||||||
interval_hours = traffic_monitoring_scheduler.get_interval_hours()
|
# Показываем информацию о новом мониторинге v2
|
||||||
threshold_gb = settings.TRAFFIC_THRESHOLD_GB_PER_DAY
|
status_info = traffic_monitoring_scheduler.get_status_info()
|
||||||
stage.log(f"Интервал проверки: {interval_hours} ч")
|
stage.log(status_info)
|
||||||
stage.log(f"Порог трафика: {threshold_gb} ГБ/сутки")
|
|
||||||
else:
|
else:
|
||||||
traffic_monitoring_task = None
|
traffic_monitoring_task = None
|
||||||
stage.skip("Мониторинг трафика отключен настройками")
|
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