Merge pull request #2234 from Gy9vin/main

Обновки
This commit is contained in:
Egor
2026-01-08 14:43:22 +03:00
committed by GitHub
21 changed files with 2374 additions and 53 deletions
+138
View File
@@ -10,6 +10,19 @@ BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
# Режим системы поддержки: tickets (тикеты), contact (контакт), both (оба)
SUPPORT_SYSTEM_MODE=both
# SLA для тикетов поддержки
SUPPORT_TICKET_SLA_ENABLED=false
SUPPORT_TICKET_SLA_MINUTES=60
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS=300
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES=30
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
@@ -93,6 +106,8 @@ LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
# Время жизни корзины пользователя в Redis (секунды, по умолчанию 1 час)
CART_TTL_SECONDS=3600
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
@@ -130,6 +145,17 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# Автоматическая синхронизация пользователей с панелью Remnawave
REMNAWAVE_AUTO_SYNC_ENABLED=false
# Времена синхронизации (через запятую, формат HH:MM по МСК)
REMNAWAVE_AUTO_SYNC_TIMES=03:00
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
# Тег для платных пользователей (опционально)
# PAID_SUBSCRIPTION_USER_TAG=PAID
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
@@ -183,6 +209,8 @@ FIXED_TRAFFIC_LIMIT_GB=100
# ===== ДОКУПКА ТРАФИКА =====
# Включить/выключить функцию докупки трафика к существующей подписке
TRAFFIC_TOPUP_ENABLED=true
# Показывать кнопку "Докупить трафик" в меню
BUY_TRAFFIC_BUTTON_VISIBLE=true
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
@@ -235,6 +263,8 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за безлимитный трафик (в копейках)
PRICE_TRAFFIC_UNLIMITED=20000
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
@@ -267,8 +297,34 @@ REFERRAL_COMMISSION_PERCENT=25
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== ВЫВОД РЕФЕРАЛЬНОГО БАЛАНСА =====
# Включить функцию вывода реферального баланса
REFERRAL_WITHDRAWAL_ENABLED=false
# Минимальная сумма вывода в копейках (по умолчанию 50000 = 500₽)
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID=0
# Тестовый режим (позволяет админам тестировать функционал)
REFERRAL_WITHDRAWAL_TEST_MODE=false
# Настройки анализа на подозрительную активность
# Минимальная сумма депозита от реферала для анализа (в копейках)
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS=100000
# Максимум пополнений от одного реферала в месяц
REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH=10
# Коэффициент подозрительности (пополнено в X раз больше, чем потрачено)
REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO=3
# ===== АВТОПРОДЛЕНИЕ =====
# Глобально включить/выключить функцию автопродления (false = функция скрыта)
ENABLE_AUTOPAY=false
# Дни до окончания подписки, когда отправлять предупреждение (через запятую)
AUTOPAY_WARNING_DAYS=3,1
# Включить автопродление для новых пользователей по умолчанию
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -345,6 +401,8 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
# Доверенные сети для webhook (IP-адреса YooKassa, через запятую)
# YOOKASSA_TRUSTED_PROXY_NETWORKS=185.71.76.0/24,185.71.77.0/24
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -421,13 +479,19 @@ MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
# Название кнопки в интерфейсе
MULENPAY_DISPLAY_NAME=Mulen Pay
MULENPAY_DESCRIPTION="Пополнение баланса"
# Запрещённые ключевые слова в display_name (КАЗИНО, СТАВКИ и т.п. — блокируются Mulenpay)
# DISPLAY_NAME_BANNED_KEYWORDS=КАЗИНО,СТАВКИ,CASINO,BET,1XBET
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# Ожидаемый origin для iframe (опционально, для безопасности)
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
# PAYPALYCH / PAL24
PAL24_ENABLED=false
@@ -451,6 +515,8 @@ PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
# Название кнопки в интерфейсе
PLATEGA_DISPLAY_NAME=Platega
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
@@ -483,6 +549,50 @@ FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
WATA_ACCESS_TOKEN=
WATA_TERMINAL_PUBLIC_ID=
WATA_PAYMENT_DESCRIPTION=Пополнение баланса
# Тип платежа: card, sbp, all
WATA_PAYMENT_TYPE=all
WATA_SUCCESS_REDIRECT_URL=
WATA_FAIL_REDIRECT_URL=
WATA_LINK_TTL_MINUTES=60
WATA_MIN_AMOUNT_KOPEKS=10000
WATA_MAX_AMOUNT_KOPEKS=10000000
WATA_REQUEST_TIMEOUT=30
WATA_WEBHOOK_PATH=/wata-webhook
WATA_WEBHOOK_HOST=0.0.0.0
WATA_WEBHOOK_PORT=8087
# Кэширование публичного ключа WATA (секунды)
WATA_PUBLIC_KEY_CACHE_SECONDS=3600
# URL для получения публичного ключа (опционально)
# WATA_PUBLIC_KEY_URL=
# ===== CLOUDPAYMENTS =====
CLOUDPAYMENTS_ENABLED=false
CLOUDPAYMENTS_PUBLIC_ID=
CLOUDPAYMENTS_API_SECRET=
# URL API CloudPayments
CLOUDPAYMENTS_API_URL=https://api.cloudpayments.ru
# URL виджета оплаты
CLOUDPAYMENTS_WIDGET_URL=https://widget.cloudpayments.ru/show
CLOUDPAYMENTS_DESCRIPTION=Пополнение баланса
CLOUDPAYMENTS_CURRENCY=RUB
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=10000
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=10000000
CLOUDPAYMENTS_WEBHOOK_PATH=/cloudpayments-webhook
CLOUDPAYMENTS_WEBHOOK_HOST=0.0.0.0
CLOUDPAYMENTS_WEBHOOK_PORT=8089
# URL для возврата после оплаты (опционально)
# CLOUDPAYMENTS_RETURN_URL=
# Скин виджета: mini, classic, modern
CLOUDPAYMENTS_SKIN=mini
CLOUDPAYMENTS_REQUIRE_EMAIL=false
CLOUDPAYMENTS_TEST_MODE=false
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -491,6 +601,8 @@ LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
MAIN_MENU_MODE=default
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
@@ -506,6 +618,8 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
# URL для редиректа на страницу покупки в мини-приложении (опционально)
# MINIAPP_PURCHASE_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -517,6 +631,8 @@ HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Универсальная ссылка для ПК (если MACOS и WINDOWS не заданы отдельно)
HAPP_DOWNLOAD_LINK_PC=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
@@ -647,6 +763,8 @@ BOT_RUN_MODE=polling # polling, webhook или both
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# Реферальные конкурсы (турниры среди рефералов)
REFERRAL_CONTESTS_ENABLED=false
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
@@ -654,6 +772,9 @@ AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# Умная автоактивация: система сама решает — продлить или создать подписку
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
# Показывать предупреждение об активации подписки после пополнения баланса
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
@@ -663,8 +784,25 @@ ACTIVATE_BUTTON_VISIBLE=false
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
# Количество воркеров (для продакшена рекомендуется 2-4)
WEB_API_WORKERS=1
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
# Название и версия API (для документации)
WEB_API_TITLE=Remnawave Bot Admin API
WEB_API_VERSION=1.0.0
# Токен по умолчанию для начальной настройки
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
# Алгоритм хеширования токенов
WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+31 -4
View File
@@ -191,15 +191,30 @@ class Settings(BaseSettings):
# Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
# Настройки вывода реферального баланса
REFERRAL_WITHDRAWAL_ENABLED: bool = False # Включить возможность вывода
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: Optional[int] = None # Топик для уведомлений
# Настройки анализа на подозрительность
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH: int = 10 # Макс. пополнений от 1 реферала/мес
REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO: float = 2.0 # Пополнил в X раз больше чем потратил
# Тестовый режим для вывода (позволяет админам вручную начислять реф. доход)
REFERRAL_WITHDRAWAL_TEST_MODE: bool = False
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
CONTESTS_ENABLED: bool = False
CONTESTS_BUTTON_VISIBLE: bool = False
@@ -286,6 +301,11 @@ class Settings(BaseSettings):
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
# Показывать предупреждение об активации подписки после пополнения баланса
# Если True - после пополнения показывает большое сообщение с кнопками:
# "Активировать", "Продлить", "Добавить устройства"
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
ACTIVATE_BUTTON_VISIBLE: bool = False
@@ -1781,7 +1801,14 @@ class Settings(BaseSettings):
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"withdrawal_enabled": self.REFERRAL_WITHDRAWAL_ENABLED,
"withdrawal_min_amount_kopeks": self.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
"withdrawal_cooldown_days": self.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
}
def is_referral_withdrawal_enabled(self) -> bool:
"""Проверяет, включена ли функция вывода реферального баланса."""
return self.is_referral_program_enabled() and self.REFERRAL_WITHDRAWAL_ENABLED
def is_referral_program_enabled(self) -> bool:
return bool(self.REFERRAL_PROGRAM_ENABLED)
+42
View File
@@ -1234,6 +1234,48 @@ class ReferralEarning(Base):
return self.amount_kopeks / 100
class WithdrawalRequestStatus(Enum):
"""Статусы заявки на вывод реферального баланса."""
PENDING = "pending" # Ожидает рассмотрения
APPROVED = "approved" # Одобрена
REJECTED = "rejected" # Отклонена
COMPLETED = "completed" # Выполнена (деньги переведены)
CANCELLED = "cancelled" # Отменена пользователем
class WithdrawalRequest(Base):
"""Заявка на вывод реферального баланса."""
__tablename__ = "withdrawal_requests"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
amount_kopeks = Column(Integer, nullable=False) # Сумма к выводу
status = Column(String(50), default=WithdrawalRequestStatus.PENDING.value, nullable=False)
# Данные для вывода (заполняет пользователь)
payment_details = Column(Text, nullable=True) # Реквизиты для перевода
# Анализ на отмывание
risk_score = Column(Integer, default=0) # 0-100, чем выше — тем подозрительнее
risk_analysis = Column(Text, nullable=True) # JSON с деталями анализа
# Обработка админом
processed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
processed_at = Column(DateTime, nullable=True)
admin_comment = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", foreign_keys=[user_id], backref="withdrawal_requests")
admin = relationship("User", foreign_keys=[processed_by])
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
class ReferralContest(Base):
__tablename__ = "referral_contests"
+93
View File
@@ -5049,6 +5049,92 @@ async def add_transaction_receipt_columns() -> bool:
return False
async def create_withdrawal_requests_table() -> bool:
"""Создаёт таблицу для заявок на вывод реферального баланса."""
try:
if await check_table_exists('withdrawal_requests'):
logger.debug("Таблица withdrawal_requests уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE withdrawal_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount_kopeks INTEGER NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
payment_details TEXT,
risk_score INTEGER DEFAULT 0,
risk_analysis TEXT,
processed_by INTEGER,
processed_at DATETIME,
admin_comment TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE withdrawal_requests (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount_kopeks INTEGER NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
payment_details TEXT,
risk_score INTEGER DEFAULT 0,
risk_analysis TEXT,
processed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
processed_at TIMESTAMP,
admin_comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_sql = """
CREATE TABLE withdrawal_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount_kopeks INT NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
payment_details TEXT,
risk_score INT DEFAULT 0,
risk_analysis TEXT,
processed_by INT,
processed_at DATETIME,
admin_comment TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица withdrawal_requests создана")
# Создаём индексы
try:
await conn.execute(text(
"CREATE INDEX idx_withdrawal_requests_user_id ON withdrawal_requests(user_id)"
))
await conn.execute(text(
"CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status)"
))
except Exception:
pass # Индексы могут уже существовать
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы withdrawal_requests: {error}")
return False
# =============================================================================
# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ
# =============================================================================
@@ -5776,6 +5862,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонками чеков в transactions")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WITHDRAWAL_REQUESTS ===")
withdrawal_requests_ready = await create_withdrawal_requests_table()
if withdrawal_requests_ready:
logger.info("✅ Таблица withdrawal_requests готова")
else:
logger.warning("⚠️ Проблемы с таблицей withdrawal_requests")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
+101 -5
View File
@@ -11,6 +11,10 @@ from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.services.traffic_monitoring_service import (
traffic_monitoring_service,
traffic_monitoring_scheduler,
)
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
@@ -737,10 +741,10 @@ async def stop_monitoring_callback(callback: CallbackQuery):
async def force_check_callback(callback: CallbackQuery):
try:
await callback.answer("⏳ Выполняем проверку подписок...")
async for db in get_db():
results = await monitoring_service.force_check_subscriptions(db)
text = f"""
✅ <b>Принудительная проверка завершена</b>
@@ -753,20 +757,112 @@ async def force_check_callback(callback: CallbackQuery):
Нажмите "Назад" для возврата в меню мониторинга.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка принудительной проверки: {e}")
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_traffic_check")
@admin_required
async def traffic_check_callback(callback: CallbackQuery):
"""Ручная проверка трафика всех пользователей."""
try:
# Проверяем, включен ли мониторинг трафика
if not traffic_monitoring_scheduler.is_enabled():
await callback.answer(
"⚠️ Мониторинг трафика отключен в настройках\n"
"Включите TRAFFIC_MONITORING_ENABLED=true в .env",
show_alert=True
)
return
await callback.answer("⏳ Запускаем проверку трафика...")
# Устанавливаем бота, если не установлен
if not traffic_monitoring_scheduler.bot:
traffic_monitoring_scheduler.set_bot(callback.bot)
checked_count = 0
exceeded_count = 0
exceeded_users = []
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
text = f"""
📊 <b>Проверка трафика завершена</b>
🔍 <b>Результаты:</b>
• Проверено пользователей: {checked_count}
• Превышений порога: {exceeded_count}
• Порог: {threshold_gb} ГБ/сутки
🕐 <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"
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_mon_traffic_check")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка проверки трафика: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_mon_logs"))
@admin_required
async def monitoring_logs_callback(callback: CallbackQuery):
+464 -3
View File
@@ -1,17 +1,22 @@
import json
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import datetime
from app.config import settings
from app.database.models import User
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus, ReferralEarning
from app.localization.texts import get_texts
from app.database.crud.referral import (
get_referral_statistics,
get_top_referrers_by_period,
get_user_referral_stats,
)
from app.database.crud.user import get_user_by_id
from app.database.crud.user import get_user_by_id, get_user_by_telegram_id
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@@ -78,12 +83,26 @@ async def show_referral_statistics(
<i>🕐 Обновлено: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
keyboard_rows = [
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
]
# Кнопка заявок на вывод (если функция включена)
if settings.is_referral_withdrawal_enabled():
keyboard_rows.append([
types.InlineKeyboardButton(
text="💸 Заявки на вывод",
callback_data="admin_withdrawal_requests"
)
])
keyboard_rows.extend([
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
@@ -292,8 +311,450 @@ async def show_referral_settings(
await callback.answer()
@admin_required
@error_handler
async def show_pending_withdrawal_requests(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает список ожидающих заявок на вывод."""
requests = await referral_withdrawal_service.get_pending_requests(db)
if not requests:
text = "📋 <b>Заявки на вывод</b>\n\nНет ожидающих заявок."
keyboard_rows = []
# Кнопка тестового начисления (только в тестовом режиме)
if settings.REFERRAL_WITHDRAWAL_TEST_MODE:
keyboard_rows.append([
types.InlineKeyboardButton(
text="🧪 Тестовое начисление",
callback_data="admin_test_referral_earning"
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
)
await callback.answer()
return
text = f"📋 <b>Заявки на вывод ({len(requests)})</b>\n\n"
for req in requests[:10]:
user = await get_user_by_id(db, req.user_id)
user_name = user.full_name if user else "Неизвестно"
user_tg_id = user.telegram_id if user else "N/A"
risk_emoji = "🟢" if req.risk_score < 30 else "🟡" if req.risk_score < 50 else "🟠" if req.risk_score < 70 else "🔴"
text += f"<b>#{req.id}</b> — {user_name} (ID{user_tg_id})\n"
text += f"💰 {req.amount_kopeks / 100:.0f}₽ | {risk_emoji} Риск: {req.risk_score}/100\n"
text += f"📅 {req.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard_rows = []
for req in requests[:5]:
keyboard_rows.append([
types.InlineKeyboardButton(
text=f"#{req.id}{req.amount_kopeks / 100:.0f}",
callback_data=f"admin_withdrawal_view_{req.id}"
)
])
# Кнопка тестового начисления (только в тестовом режиме)
if settings.REFERRAL_WITHDRAWAL_TEST_MODE:
keyboard_rows.append([
types.InlineKeyboardButton(
text="🧪 Тестовое начисление",
callback_data="admin_test_referral_earning"
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
)
await callback.answer()
@admin_required
@error_handler
async def view_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает детали заявки на вывод."""
request_id = int(callback.data.split("_")[-1])
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request:
await callback.answer("Заявка не найдена", show_alert=True)
return
user = await get_user_by_id(db, request.user_id)
user_name = user.full_name if user else "Неизвестно"
user_tg_id = user.telegram_id if user else "N/A"
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
status_text = {
WithdrawalRequestStatus.PENDING.value: "⏳ Ожидает",
WithdrawalRequestStatus.APPROVED.value: "✅ Одобрена",
WithdrawalRequestStatus.REJECTED.value: "❌ Отклонена",
WithdrawalRequestStatus.COMPLETED.value: "✅ Выполнена",
WithdrawalRequestStatus.CANCELLED.value: "🚫 Отменена",
}.get(request.status, request.status)
text = f"""
📋 <b>Заявка #{request.id}</b>
👤 Пользователь: {user_name}
🆔 ID: <code>{user_tg_id}</code>
💰 Сумма: <b>{request.amount_kopeks / 100:.0f}</b>
📊 Статус: {status_text}
💳 <b>Реквизиты:</b>
<code>{request.payment_details}</code>
📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
"""
keyboard = []
if request.status == WithdrawalRequestStatus.PENDING.value:
keyboard.append([
types.InlineKeyboardButton(
text="✅ Одобрить",
callback_data=f"admin_withdrawal_approve_{request.id}"
),
types.InlineKeyboardButton(
text="❌ Отклонить",
callback_data=f"admin_withdrawal_reject_{request.id}"
)
])
if request.status == WithdrawalRequestStatus.APPROVED.value:
keyboard.append([
types.InlineKeyboardButton(
text="✅ Деньги переведены",
callback_data=f"admin_withdrawal_complete_{request.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(
text="👤 Профиль пользователя",
callback_data=f"admin_user_{user_tg_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_withdrawal_requests")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def approve_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Одобряет заявку на вывод."""
request_id = int(callback.data.split("_")[-1])
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request:
await callback.answer("Заявка не найдена", show_alert=True)
return
success, error = await referral_withdrawal_service.approve_request(
db, request_id, db_user.id
)
if success:
# Уведомляем пользователя
user = await get_user_by_id(db, request.user_id)
if user:
try:
texts = get_texts(user.language)
await callback.bot.send_message(
user.telegram_id,
texts.t(
"REFERRAL_WITHDRAWAL_APPROVED",
"✅ <b>Заявка на вывод #{id} одобрена!</b>\n\n"
"Сумма: <b>{amount}</b>\n"
"Средства списаны с баланса.\n\n"
"Ожидайте перевод на указанные реквизиты."
).format(id=request.id, amount=texts.format_price(request.amount_kopeks))
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю: {e}")
await callback.answer("✅ Заявка одобрена, средства списаны с баланса")
# Обновляем отображение
await view_withdrawal_request(callback, db_user, db)
else:
await callback.answer(f"{error}", show_alert=True)
@admin_required
@error_handler
async def reject_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Отклоняет заявку на вывод."""
request_id = int(callback.data.split("_")[-1])
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request:
await callback.answer("Заявка не найдена", show_alert=True)
return
success = await referral_withdrawal_service.reject_request(
db, request_id, db_user.id, "Отклонено администратором"
)
if success:
# Уведомляем пользователя
user = await get_user_by_id(db, request.user_id)
if user:
try:
texts = get_texts(user.language)
await callback.bot.send_message(
user.telegram_id,
texts.t(
"REFERRAL_WITHDRAWAL_REJECTED",
"❌ <b>Заявка на вывод #{id} отклонена</b>\n\n"
"Сумма: <b>{amount}</b>\n\n"
"Если у вас есть вопросы, обратитесь в поддержку."
).format(id=request.id, amount=texts.format_price(request.amount_kopeks))
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю: {e}")
await callback.answer("❌ Заявка отклонена")
# Обновляем отображение
await view_withdrawal_request(callback, db_user, db)
else:
await callback.answer("❌ Ошибка отклонения", show_alert=True)
@admin_required
@error_handler
async def complete_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Отмечает заявку как выполненную (деньги переведены)."""
request_id = int(callback.data.split("_")[-1])
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request:
await callback.answer("Заявка не найдена", show_alert=True)
return
success = await referral_withdrawal_service.complete_request(
db, request_id, db_user.id, "Перевод выполнен"
)
if success:
# Уведомляем пользователя
user = await get_user_by_id(db, request.user_id)
if user:
try:
texts = get_texts(user.language)
await callback.bot.send_message(
user.telegram_id,
texts.t(
"REFERRAL_WITHDRAWAL_COMPLETED",
"💸 <b>Выплата по заявке #{id} выполнена!</b>\n\n"
"Сумма: <b>{amount}</b>\n\n"
"Деньги отправлены на указанные реквизиты."
).format(id=request.id, amount=texts.format_price(request.amount_kopeks))
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю: {e}")
await callback.answer("✅ Заявка выполнена")
# Обновляем отображение
await view_withdrawal_request(callback, db_user, db)
else:
await callback.answer("❌ Ошибка выполнения", show_alert=True)
@admin_required
@error_handler
async def start_test_referral_earning(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Начинает процесс тестового начисления реферального дохода."""
if not settings.REFERRAL_WITHDRAWAL_TEST_MODE:
await callback.answer("Тестовый режим отключён", show_alert=True)
return
await state.set_state(AdminStates.test_referral_earning_input)
text = """
🧪 <b>Тестовое начисление реферального дохода</b>
Введите данные в формате:
<code>telegram_id сумма_в_рублях</code>
Примеры:
<code>123456789 500</code> начислит 500 пользователю 123456789
<code>987654321 1000</code> начислит 1000 пользователю 987654321
Это создаст реальную запись ReferralEarning, как будто пользователь заработал с реферала.
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_withdrawal_requests")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def process_test_referral_earning(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Обрабатывает ввод тестового начисления."""
if not settings.REFERRAL_WITHDRAWAL_TEST_MODE:
await message.answer("❌ Тестовый режим отключён")
await state.clear()
return
text_input = message.text.strip()
parts = text_input.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат. Введите: <code>telegram_id сумма</code>\n\n"
"Например: <code>123456789 500</code>"
)
return
try:
target_telegram_id = int(parts[0])
amount_rubles = float(parts[1].replace(",", "."))
amount_kopeks = int(amount_rubles * 100)
if amount_kopeks <= 0:
await message.answer("❌ Сумма должна быть положительной")
return
if amount_kopeks > 10000000: # Лимит 100 000₽
await message.answer("❌ Максимальная сумма тестового начисления: 100 000₽")
return
except ValueError:
await message.answer(
"❌ Неверный формат чисел. Введите: <code>telegram_id сумма</code>\n\n"
"Например: <code>123456789 500</code>"
)
return
# Ищем целевого пользователя
target_user = await get_user_by_telegram_id(db, target_telegram_id)
if not target_user:
await message.answer(f"❌ Пользователь с ID {target_telegram_id} не найден в базе")
return
# Создаём тестовое начисление
earning = ReferralEarning(
user_id=target_user.id,
referral_id=target_user.id, # Сам на себя (тестовое)
amount_kopeks=amount_kopeks,
reason="test_earning"
)
db.add(earning)
# Добавляем на баланс пользователя
target_user.balance_kopeks += amount_kopeks
await db.commit()
await state.clear()
await message.answer(
f"✅ <b>Тестовое начисление создано!</b>\n\n"
f"👤 Пользователь: {target_user.full_name or 'Без имени'}\n"
f"🆔 ID: <code>{target_telegram_id}</code>\n"
f"💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n"
f"💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\n\n"
f"Начисление добавлено как реферальный доход.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 К заявкам", callback_data="admin_withdrawal_requests")],
[types.InlineKeyboardButton(text="👤 Профиль", callback_data=f"admin_user_manage_{target_telegram_id}")]
])
)
logger.info(
f"Тестовое начисление: админ {db_user.telegram_id} начислил {amount_rubles}"
f"пользователю {target_telegram_id}"
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
# Хендлеры заявок на вывод
dp.callback_query.register(show_pending_withdrawal_requests, F.data == "admin_withdrawal_requests")
dp.callback_query.register(view_withdrawal_request, F.data.startswith("admin_withdrawal_view_"))
dp.callback_query.register(approve_withdrawal_request, F.data.startswith("admin_withdrawal_approve_"))
dp.callback_query.register(reject_withdrawal_request, F.data.startswith("admin_withdrawal_reject_"))
dp.callback_query.register(complete_withdrawal_request, F.data.startswith("admin_withdrawal_complete_"))
# Тестовое начисление
dp.callback_query.register(start_test_referral_earning, F.data == "admin_test_referral_earning")
dp.message.register(process_test_referral_earning, AdminStates.test_referral_earning_input)
+422 -1
View File
@@ -1,9 +1,11 @@
import json
import logging
from pathlib import Path
import qrcode
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,9 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.services.admin_notification_service import AdminNotificationService
from app.states import ReferralWithdrawalStates
from app.utils.photo_message import edit_or_answer_photo
from app.utils.user_utils import (
get_detailed_referral_list,
@@ -463,8 +468,386 @@ async def create_invite_message(
await callback.answer()
async def show_withdrawal_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Показывает информацию о выводе реферального баланса."""
texts = get_texts(db_user.language)
if not settings.is_referral_withdrawal_enabled():
await callback.answer(
texts.t("REFERRAL_WITHDRAWAL_DISABLED", "Функция вывода отключена"),
show_alert=True
)
return
# Получаем детальную статистику баланса
stats = await referral_withdrawal_service.get_referral_balance_stats(db, db_user.id)
min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS
cooldown_days = settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS
# Проверяем возможность вывода
can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
text = texts.t("REFERRAL_WITHDRAWAL_TITLE", "💸 <b>Вывод реферального баланса</b>") + "\n\n"
# Показываем детальную статистику
text += referral_withdrawal_service.format_balance_stats_for_user(stats, texts)
text += "\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_MIN_AMOUNT",
"📊 Минимальная сумма: <b>{amount}</b>"
).format(amount=texts.format_price(min_amount)) + "\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_COOLDOWN",
"⏱ Частота вывода: раз в <b>{days}</b> дней"
).format(days=cooldown_days) + "\n\n"
keyboard = []
if can_request:
text += texts.t(
"REFERRAL_WITHDRAWAL_READY",
"✅ Вы можете запросить вывод средств"
) + "\n"
keyboard.append([types.InlineKeyboardButton(
text=texts.t("REFERRAL_WITHDRAWAL_REQUEST_BUTTON", "📝 Оформить заявку"),
callback_data="referral_withdrawal_start"
)])
else:
text += f"{reason}\n"
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)])
await edit_or_answer_photo(
callback,
text,
types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
async def start_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Начинает процесс оформления заявки на вывод."""
texts = get_texts(db_user.language)
# Повторная проверка
can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
if not can_request:
await callback.answer(reason, show_alert=True)
return
available = await referral_withdrawal_service.get_available_for_withdrawal(db, db_user.id)
# Сохраняем доступный баланс в состоянии
await state.update_data(available_balance=available)
await state.set_state(ReferralWithdrawalStates.waiting_for_amount)
text = texts.t(
"REFERRAL_WITHDRAWAL_ENTER_AMOUNT",
"💸 Введите сумму для вывода в рублях\n\nДоступно: <b>{amount}</b>"
).format(amount=texts.format_price(available))
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("REFERRAL_WITHDRAWAL_ALL", f"Вывести всё ({available / 100:.0f}₽)"),
callback_data=f"referral_withdrawal_amount_{available}"
)],
[types.InlineKeyboardButton(
text=texts.t("CANCEL", "❌ Отмена"),
callback_data="referral_withdrawal_cancel"
)]
])
await edit_or_answer_photo(callback, text, keyboard)
await callback.answer()
async def process_withdrawal_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Обрабатывает ввод суммы для вывода."""
texts = get_texts(db_user.language)
data = await state.get_data()
available = data.get("available_balance", 0)
try:
# Парсим сумму (в рублях)
amount_text = message.text.strip().replace(",", ".").replace("", "").replace(" ", "")
amount_rubles = float(amount_text)
amount_kopeks = int(amount_rubles * 100)
if amount_kopeks <= 0:
await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите положительную сумму"))
return
min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
"REFERRAL_WITHDRAWAL_MIN_ERROR",
"❌ Минимальная сумма: {amount}"
).format(amount=texts.format_price(min_amount))
)
return
if amount_kopeks > available:
await message.answer(
texts.t(
"REFERRAL_WITHDRAWAL_INSUFFICIENT",
"❌ Недостаточно средств. Доступно: {amount}"
).format(amount=texts.format_price(available))
)
return
# Сохраняем сумму и переходим к вводу реквизитов
await state.update_data(withdrawal_amount=amount_kopeks)
await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details)
text = texts.t(
"REFERRAL_WITHDRAWAL_ENTER_DETAILS",
"💳 Введите реквизиты для перевода:\n\n"
"Например:\n"
"• СБП: +7 999 123-45-67 (Сбербанк)"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("CANCEL", "❌ Отмена"),
callback_data="referral_withdrawal_cancel"
)]
])
await message.answer(text, reply_markup=keyboard)
except ValueError:
await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите корректную сумму"))
async def process_withdrawal_amount_callback(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Обрабатывает выбор суммы для вывода через кнопку."""
texts = get_texts(db_user.language)
# Получаем сумму из callback_data
amount_kopeks = int(callback.data.split("_")[-1])
# Сохраняем сумму и переходим к вводу реквизитов
await state.update_data(withdrawal_amount=amount_kopeks)
await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details)
text = texts.t(
"REFERRAL_WITHDRAWAL_ENTER_DETAILS",
"💳 Введите реквизиты для перевода:\n\n"
"Например:\n"
"• СБП: +7 999 123-45-67 (Сбербанк)"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("CANCEL", "❌ Отмена"),
callback_data="referral_withdrawal_cancel"
)]
])
await edit_or_answer_photo(callback, text, keyboard)
await callback.answer()
async def process_payment_details(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Обрабатывает ввод реквизитов и показывает подтверждение."""
texts = get_texts(db_user.language)
data = await state.get_data()
amount_kopeks = data.get("withdrawal_amount", 0)
payment_details = message.text.strip()
if len(payment_details) < 10:
await message.answer(
texts.t("REFERRAL_WITHDRAWAL_DETAILS_TOO_SHORT", "❌ Реквизиты слишком короткие")
)
return
# Сохраняем реквизиты
await state.update_data(payment_details=payment_details)
await state.set_state(ReferralWithdrawalStates.confirming)
text = texts.t("REFERRAL_WITHDRAWAL_CONFIRM_TITLE", "📋 <b>Подтверждение заявки</b>") + "\n\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_CONFIRM_AMOUNT",
"💰 Сумма: <b>{amount}</b>"
).format(amount=texts.format_price(amount_kopeks)) + "\n\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_CONFIRM_DETAILS",
"💳 Реквизиты:\n<code>{details}</code>"
).format(details=payment_details) + "\n\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_CONFIRM_WARNING",
"⚠️ После отправки заявка будет рассмотрена администрацией"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("REFERRAL_WITHDRAWAL_CONFIRM_BUTTON", "✅ Подтвердить"),
callback_data="referral_withdrawal_confirm"
)],
[types.InlineKeyboardButton(
text=texts.t("CANCEL", "❌ Отмена"),
callback_data="referral_withdrawal_cancel"
)]
])
await message.answer(text, reply_markup=keyboard)
async def confirm_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Подтверждает и создаёт заявку на вывод."""
texts = get_texts(db_user.language)
data = await state.get_data()
amount_kopeks = data.get("withdrawal_amount", 0)
payment_details = data.get("payment_details", "")
await state.clear()
# Создаём заявку
request, error = await referral_withdrawal_service.create_withdrawal_request(
db, db_user.id, amount_kopeks, payment_details
)
if error:
await callback.answer(f"{error}", show_alert=True)
return
# Отправляем уведомление админам
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
admin_text = f"""
🔔 <b>Новая заявка на вывод #{request.id}</b>
👤 Пользователь: {db_user.full_name or 'Без имени'}
🆔 ID: <code>{db_user.telegram_id}</code>
💰 Сумма: <b>{amount_kopeks / 100:.0f}</b>
💳 Реквизиты:
<code>{payment_details}</code>
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
"""
admin_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="✅ Одобрить",
callback_data=f"admin_withdrawal_approve_{request.id}"
),
types.InlineKeyboardButton(
text="❌ Отклонить",
callback_data=f"admin_withdrawal_reject_{request.id}"
)
],
[types.InlineKeyboardButton(
text="👤 Профиль пользователя",
callback_data=f"admin_user_{db_user.telegram_id}"
)]
])
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_to_admins(
admin_text,
keyboard=admin_keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админам о заявке на вывод: {e}")
# Уведомление в топик, если настроено
topic_id = settings.REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID
if topic_id and settings.ADMIN_NOTIFICATIONS_CHAT_ID:
try:
await callback.bot.send_message(
chat_id=settings.ADMIN_NOTIFICATIONS_CHAT_ID,
message_thread_id=topic_id,
text=admin_text,
reply_markup=admin_keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления в топик о заявке на вывод: {e}")
# Отвечаем пользователю
text = texts.t(
"REFERRAL_WITHDRAWAL_SUCCESS",
"✅ <b>Заявка #{id} создана!</b>\n\n"
"Сумма: <b>{amount}</b>\n\n"
"Ваша заявка будет рассмотрена администрацией. "
"Мы уведомим вас о результате."
).format(id=request.id, amount=texts.format_price(amount_kopeks))
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)]
])
await edit_or_answer_photo(callback, text, keyboard)
await callback.answer()
async def cancel_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""Отменяет процесс создания заявки на вывод."""
await state.clear()
texts = get_texts(db_user.language)
await callback.answer(texts.t("CANCELLED", "Отменено"))
# Возвращаем в меню партнёрки
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)]
])
await edit_or_answer_photo(
callback,
texts.t("REFERRAL_WITHDRAWAL_CANCELLED", "❌ Заявка отменена"),
keyboard
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_referral_info,
F.data == "menu_referrals"
@@ -498,3 +881,41 @@ def register_handlers(dp: Dispatcher):
handle_referral_list_page,
F.data.startswith("referral_list_page_")
)
# Хендлеры вывода реферального баланса
dp.callback_query.register(
show_withdrawal_info,
F.data == "referral_withdrawal"
)
dp.callback_query.register(
start_withdrawal_request,
F.data == "referral_withdrawal_start"
)
dp.callback_query.register(
process_withdrawal_amount_callback,
F.data.startswith("referral_withdrawal_amount_")
)
dp.callback_query.register(
confirm_withdrawal_request,
F.data == "referral_withdrawal_confirm"
)
dp.callback_query.register(
cancel_withdrawal_request,
F.data == "referral_withdrawal_cancel"
)
# Обработка текстового ввода суммы
dp.message.register(
process_withdrawal_amount,
ReferralWithdrawalStates.waiting_for_amount
)
# Обработка текстового ввода реквизитов
dp.message.register(
process_payment_details,
ReferralWithdrawalStates.waiting_for_payment_details
)
+11 -5
View File
@@ -1711,22 +1711,28 @@ def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "🔄 Принудительная проверка"),
text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "🔄 Проверка подписок"),
callback_data="admin_mon_force_check"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_TRAFFIC_CHECK", "📊 Проверка трафика"),
callback_data="admin_mon_traffic_check"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"),
callback_data="admin_mon_logs"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📈 Статистика"),
callback_data="admin_mon_statistics"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"),
callback_data="admin_mon_test_notifications"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"),
callback_data="admin_mon_statistics"
)
],
[
+18 -8
View File
@@ -1548,7 +1548,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEF
def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(
@@ -1574,14 +1574,24 @@ def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMar
callback_data="referral_analytics"
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="back_to_menu"
)
]
]
# Добавляем кнопку вывода, если включена
if settings.is_referral_withdrawal_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("REFERRAL_WITHDRAWAL_BUTTON", "💸 Запросить вывод"),
callback_data="referral_withdrawal"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data="back_to_menu"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+1 -1
View File
@@ -267,7 +267,7 @@ class CloudPaymentsPaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as error:
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
+1 -1
View File
@@ -413,7 +413,7 @@ class FreekassaPaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
+1 -1
View File
@@ -398,7 +398,7 @@ class MulenPayPaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
+1 -1
View File
@@ -501,7 +501,7 @@ class Pal24PaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
+1 -1
View File
@@ -487,7 +487,7 @@ class PlategaPaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
+1 -1
View File
@@ -571,7 +571,7 @@ class WataPaymentMixin:
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
await auto_activate_subscription_after_topup(db, user, bot=getattr(self, "bot", None))
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
+698
View File
@@ -0,0 +1,698 @@
"""
Сервис для обработки запросов на вывод реферального баланса
с анализом на подозрительную активность (отмывание денег).
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
User,
Transaction,
ReferralEarning,
WithdrawalRequest,
WithdrawalRequestStatus,
)
logger = logging.getLogger(__name__)
class ReferralWithdrawalService:
"""Сервис для обработки запросов на вывод реферального баланса."""
# ==================== МЕТОДЫ РАСЧЁТА БАЛАНСОВ ====================
async def get_total_referral_earnings(self, db: AsyncSession, user_id: int) -> int:
"""
Получает ОБЩУЮ сумму реферальных начислений (за всё время).
Возвращает сумму в копейках.
"""
result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id == user_id)
)
return result.scalar() or 0
async def get_user_own_deposits(self, db: AsyncSession, user_id: int) -> int:
"""
Получает сумму собственных пополнений пользователя (НЕ реферальные).
"""
result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
Transaction.user_id == user_id,
Transaction.type == "deposit",
Transaction.is_completed == True
)
)
return result.scalar() or 0
async def get_first_referral_earning_date(self, db: AsyncSession, user_id: int) -> Optional[datetime]:
"""
Получает дату первого реферального начисления.
"""
result = await db.execute(
select(func.min(ReferralEarning.created_at))
.where(ReferralEarning.user_id == user_id)
)
return result.scalar()
async def get_user_spending(self, db: AsyncSession, user_id: int) -> int:
"""
Получает сумму трат пользователя (покупки подписок, сброс трафика и т.д.).
"""
result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
Transaction.user_id == user_id,
Transaction.type.in_(["subscription_payment", "withdrawal"]),
Transaction.is_completed == True
)
)
return abs(result.scalar() or 0)
async def get_user_spending_after_first_earning(self, db: AsyncSession, user_id: int) -> int:
"""
Получает сумму трат ПОСЛЕ первого реферального начисления.
Только эти траты могут быть засчитаны как "потрачено из реф. баланса".
"""
first_earning_date = await self.get_first_referral_earning_date(db, user_id)
if not first_earning_date:
return 0
result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
Transaction.user_id == user_id,
Transaction.type.in_(["subscription_payment", "withdrawal"]),
Transaction.is_completed == True,
Transaction.created_at >= first_earning_date
)
)
return abs(result.scalar() or 0)
async def get_withdrawn_amount(self, db: AsyncSession, user_id: int) -> int:
"""
Получает сумму уже выведенных средств (одобренные/выполненные заявки).
"""
result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0))
.where(
WithdrawalRequest.user_id == user_id,
WithdrawalRequest.status.in_([
WithdrawalRequestStatus.APPROVED.value,
WithdrawalRequestStatus.COMPLETED.value
])
)
)
return result.scalar() or 0
async def get_pending_withdrawal_amount(self, db: AsyncSession, user_id: int) -> int:
"""
Получает сумму заявок в ожидании (заморожено).
"""
result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0))
.where(
WithdrawalRequest.user_id == user_id,
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
return result.scalar() or 0
async def get_referral_balance_stats(self, db: AsyncSession, user_id: int) -> Dict:
"""
Получает полную статистику реферального баланса.
"""
total_earned = await self.get_total_referral_earnings(db, user_id)
own_deposits = await self.get_user_own_deposits(db, user_id)
spending = await self.get_user_spending(db, user_id)
spending_after_earning = await self.get_user_spending_after_first_earning(db, user_id)
withdrawn = await self.get_withdrawn_amount(db, user_id)
pending = await self.get_pending_withdrawal_amount(db, user_id)
# Сколько реф. баланса потрачено = мин(траты ПОСЛЕ первого начисления, реф_заработок)
# Логика: только траты после получения реф. дохода могут быть из реф. баланса
referral_spent = min(spending_after_earning, total_earned)
# Доступный реферальный баланс
available_referral = max(0, total_earned - referral_spent - withdrawn - pending)
# Если разрешено выводить и свой баланс
if not settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE:
# Свой остаток = пополнения - (траты - реф_потрачено)
own_remaining = max(0, own_deposits - max(0, spending - referral_spent))
available_total = available_referral + own_remaining
else:
own_remaining = 0
available_total = available_referral
return {
"total_earned": total_earned, # Всего заработано с рефералов
"own_deposits": own_deposits, # Собственные пополнения
"spending": spending, # Потрачено на подписки и пр.
"referral_spent": referral_spent, # Сколько реф. баланса потрачено
"withdrawn": withdrawn, # Уже выведено
"pending": pending, # На рассмотрении
"available_referral": available_referral, # Доступно реф. баланса
"available_total": available_total, # Всего доступно к выводу
"only_referral_mode": settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE,
}
async def get_available_for_withdrawal(self, db: AsyncSession, user_id: int) -> int:
"""Получает сумму, доступную для вывода."""
stats = await self.get_referral_balance_stats(db, user_id)
return stats["available_total"]
# ==================== ПРОВЕРКИ ====================
async def get_last_withdrawal_request(
self, db: AsyncSession, user_id: int
) -> Optional[WithdrawalRequest]:
"""Получает последнюю заявку на вывод пользователя."""
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.user_id == user_id)
.order_by(WithdrawalRequest.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def can_request_withdrawal(
self, db: AsyncSession, user_id: int
) -> Tuple[bool, str]:
"""
Проверяет, может ли пользователь запросить вывод.
Возвращает (can_request, reason).
"""
if not settings.is_referral_withdrawal_enabled():
return False, "Функция вывода реферального баланса отключена"
# Проверяем доступный баланс
stats = await self.get_referral_balance_stats(db, user_id)
available = stats["available_total"]
min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS
if available < min_amount:
return False, f"Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}"
# Проверяем cooldown (пропускаем в тестовом режиме)
last_request = await self.get_last_withdrawal_request(db, user_id)
if last_request:
# В тестовом режиме пропускаем проверку cooldown
if not settings.REFERRAL_WITHDRAWAL_TEST_MODE:
cooldown_days = settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS
cooldown_end = last_request.created_at + timedelta(days=cooldown_days)
if datetime.utcnow() < cooldown_end:
days_left = (cooldown_end - datetime.utcnow()).days + 1
return False, f"Следующий запрос на вывод будет доступен через {days_left} дн."
# Проверяем, нет ли активной заявки
if last_request.status == WithdrawalRequestStatus.PENDING.value:
return False, "У вас уже есть активная заявка на рассмотрении"
return True, "OK"
# ==================== АНАЛИЗ НА ОТМЫВАНИЕ ====================
async def analyze_for_money_laundering(
self, db: AsyncSession, user_id: int
) -> Dict:
"""
Детальный анализ активности пользователя на предмет отмывания денег.
"""
analysis = {
"risk_score": 0,
"risk_level": "low",
"recommendation": "approve",
"flags": [],
"details": {}
}
# Получаем статистику баланса
balance_stats = await self.get_referral_balance_stats(db, user_id)
analysis["details"]["balance_stats"] = balance_stats
# 1. ПРОВЕРКА: Пользователь пополнил но не покупал подписки
own_deposits = balance_stats["own_deposits"]
spending = balance_stats["spending"]
ratio_threshold = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO
if own_deposits > 0 and spending == 0:
analysis["risk_score"] += 40
analysis["flags"].append(
f"🔴 Пополнил {own_deposits / 100:.0f}₽, но ничего не покупал!"
)
elif own_deposits > spending * ratio_threshold and spending > 0:
analysis["risk_score"] += 25
analysis["flags"].append(
f"🟠 Пополнил {own_deposits / 100:.0f}₽, потратил только {spending / 100:.0f}"
)
# 2. Получаем информацию о рефералах
referrals = await db.execute(
select(User).where(User.referred_by_id == user_id)
)
referrals_list = referrals.scalars().all()
referral_count = len(referrals_list)
analysis["details"]["referral_count"] = referral_count
if referral_count == 0 and balance_stats["total_earned"] > 0:
analysis["risk_score"] += 50
analysis["flags"].append("🔴 Нет рефералов, но есть реферальный доход!")
# 3. Анализ пополнений каждого реферала
referral_ids = [r.id for r in referrals_list]
suspicious_referrals = []
if referral_ids:
# Получаем детальную статистику по каждому рефералу за последний месяц
month_ago = datetime.utcnow() - timedelta(days=30)
for ref_id in referral_ids:
ref_user = next((r for r in referrals_list if r.id == ref_id), None)
ref_name = ref_user.full_name if ref_user else f"ID{ref_id}"
# Пополнения этого реферала за месяц
ref_deposits = await db.execute(
select(
func.count().label("count"),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total")
)
.where(
Transaction.user_id == ref_id,
Transaction.type == "deposit",
Transaction.is_completed == True,
Transaction.created_at >= month_ago
)
)
deposit_data = ref_deposits.fetchone()
deposit_count = deposit_data.count
deposit_total = deposit_data.total
suspicious_flags = []
# Проверка: слишком много пополнений от одного реферала
max_deposits = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH
if deposit_count > max_deposits:
analysis["risk_score"] += 15
suspicious_flags.append(f"{deposit_count} пополнений/мес")
# Проверка: большие суммы от одного реферала
min_suspicious = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS
if deposit_total > min_suspicious:
analysis["risk_score"] += 10
suspicious_flags.append(f"сумма {deposit_total / 100:.0f}")
if suspicious_flags:
suspicious_referrals.append({
"name": ref_name,
"deposits_count": deposit_count,
"deposits_total": deposit_total,
"flags": suspicious_flags
})
analysis["details"]["suspicious_referrals"] = suspicious_referrals
if suspicious_referrals:
analysis["flags"].append(
f"⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)"
)
# Общая статистика по рефералам
all_ref_deposits = await db.execute(
select(
func.count(func.distinct(Transaction.user_id)).label("paying_count"),
func.count().label("total_deposits"),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total_amount")
)
.where(
Transaction.user_id.in_(referral_ids),
Transaction.type == "deposit",
Transaction.is_completed == True
)
)
ref_stats = all_ref_deposits.fetchone()
analysis["details"]["referral_deposits"] = {
"paying_referrals": ref_stats.paying_count,
"total_deposits": ref_stats.total_deposits,
"total_amount": ref_stats.total_amount
}
# Проверка: только 1 платящий реферал
if ref_stats.paying_count == 1 and balance_stats["total_earned"] > 50000:
analysis["risk_score"] += 20
analysis["flags"].append("⚠️ Весь доход от одного реферала")
# 4. Анализ реферальных начислений по типам
earnings = await db.execute(
select(
ReferralEarning.reason,
func.count().label("count"),
func.sum(ReferralEarning.amount_kopeks).label("total")
)
.where(ReferralEarning.user_id == user_id)
.group_by(ReferralEarning.reason)
)
earnings_by_reason = {r.reason: {"count": r.count, "total": r.total} for r in earnings.fetchall()}
analysis["details"]["earnings_by_reason"] = earnings_by_reason
# 5. Проверка: много начислений за последнюю неделю
week_ago = datetime.utcnow() - timedelta(days=7)
recent_earnings = await db.execute(
select(func.count(), func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= week_ago
)
)
recent_data = recent_earnings.fetchone()
recent_count, recent_amount = recent_data
if recent_count > 20:
analysis["risk_score"] += 15
analysis["flags"].append(f"⚠️ {recent_count} начислений за неделю ({recent_amount / 100:.0f}₽)")
analysis["details"]["recent_activity"] = {
"week_earnings_count": recent_count,
"week_earnings_amount": recent_amount
}
# ==================== ИТОГОВАЯ ОЦЕНКА ====================
score = analysis["risk_score"]
# Ограничиваем максимум
score = min(score, 100)
analysis["risk_score"] = score
if score >= 70:
analysis["risk_level"] = "critical"
analysis["recommendation"] = "reject"
analysis["recommendation_text"] = "🔴 РЕКОМЕНДУЕТСЯ ОТКЛОНИТЬ"
elif score >= 50:
analysis["risk_level"] = "high"
analysis["recommendation"] = "review"
analysis["recommendation_text"] = "🟠 ТРЕБУЕТ ПРОВЕРКИ"
elif score >= 30:
analysis["risk_level"] = "medium"
analysis["recommendation"] = "review"
analysis["recommendation_text"] = "🟡 Рекомендуется проверить"
else:
analysis["risk_level"] = "low"
analysis["recommendation"] = "approve"
analysis["recommendation_text"] = "🟢 Можно одобрить"
return analysis
# ==================== СОЗДАНИЕ И УПРАВЛЕНИЕ ЗАЯВКАМИ ====================
async def create_withdrawal_request(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
payment_details: str
) -> Tuple[Optional[WithdrawalRequest], str]:
"""
Создаёт заявку на вывод с анализом на отмывание.
Возвращает (request, error_message).
"""
# Проверяем возможность вывода
can_request, reason = await self.can_request_withdrawal(db, user_id)
if not can_request:
return None, reason
# Проверяем сумму
stats = await self.get_referral_balance_stats(db, user_id)
available = stats["available_total"]
if amount_kopeks > available:
return None, f"Недостаточно средств. Доступно: {available / 100:.0f}"
# В режиме "только реф. баланс" проверяем реф. баланс
if settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE:
if amount_kopeks > stats["available_referral"]:
return None, f"Недостаточно реферального баланса. Доступно: {stats['available_referral'] / 100:.0f}"
# Анализируем на отмывание
analysis = await self.analyze_for_money_laundering(db, user_id)
# Создаём заявку
request = WithdrawalRequest(
user_id=user_id,
amount_kopeks=amount_kopeks,
payment_details=payment_details,
risk_score=analysis["risk_score"],
risk_analysis=json.dumps(analysis, ensure_ascii=False, default=str)
)
db.add(request)
await db.commit()
await db.refresh(request)
return request, ""
async def get_pending_requests(self, db: AsyncSession) -> List[WithdrawalRequest]:
"""Получает все ожидающие заявки на вывод."""
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
.order_by(WithdrawalRequest.created_at.asc())
)
return result.scalars().all()
async def get_all_requests(
self, db: AsyncSession, limit: int = 50, offset: int = 0
) -> List[WithdrawalRequest]:
"""Получает все заявки на вывод (журнал)."""
result = await db.execute(
select(WithdrawalRequest)
.order_by(WithdrawalRequest.created_at.desc())
.limit(limit)
.offset(offset)
)
return result.scalars().all()
async def approve_request(
self,
db: AsyncSession,
request_id: int,
admin_id: int,
comment: Optional[str] = None
) -> Tuple[bool, str]:
"""
Одобряет заявку на вывод и списывает средства с баланса.
Возвращает (success, error_message).
"""
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request:
return False, "Заявка не найдена"
if request.status != WithdrawalRequestStatus.PENDING.value:
return False, "Заявка уже обработана"
# Проверяем, что баланс всё ещё достаточен
stats = await self.get_referral_balance_stats(db, request.user_id)
if request.amount_kopeks > stats["available_total"]:
return False, f"Недостаточно средств у пользователя. Доступно: {stats['available_total'] / 100:.0f}"
# Получаем пользователя для списания с баланса
user_result = await db.execute(
select(User).where(User.id == request.user_id)
)
user = user_result.scalar_one_or_none()
if not user:
return False, "Пользователь не найден"
# Списываем с баланса
if user.balance_kopeks < request.amount_kopeks:
return False, f"Недостаточно средств на балансе. Баланс: {user.balance_kopeks / 100:.0f}"
user.balance_kopeks -= request.amount_kopeks
# Создаём транзакцию списания
withdrawal_tx = Transaction(
user_id=request.user_id,
type="withdrawal",
amount_kopeks=-request.amount_kopeks,
description=f"Вывод реферального баланса (заявка #{request.id})",
is_completed=True,
completed_at=datetime.utcnow()
)
db.add(withdrawal_tx)
# Обновляем статус заявки
request.status = WithdrawalRequestStatus.APPROVED.value
request.processed_by = admin_id
request.processed_at = datetime.utcnow()
request.admin_comment = comment
await db.commit()
return True, ""
async def reject_request(
self,
db: AsyncSession,
request_id: int,
admin_id: int,
comment: Optional[str] = None
) -> bool:
"""Отклоняет заявку на вывод."""
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request or request.status != WithdrawalRequestStatus.PENDING.value:
return False
request.status = WithdrawalRequestStatus.REJECTED.value
request.processed_by = admin_id
request.processed_at = datetime.utcnow()
request.admin_comment = comment
await db.commit()
return True
async def complete_request(
self,
db: AsyncSession,
request_id: int,
admin_id: int,
comment: Optional[str] = None
) -> bool:
"""Отмечает заявку как выполненную (деньги переведены)."""
result = await db.execute(
select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)
)
request = result.scalar_one_or_none()
if not request or request.status != WithdrawalRequestStatus.APPROVED.value:
return False
request.status = WithdrawalRequestStatus.COMPLETED.value
request.processed_by = admin_id
request.processed_at = datetime.utcnow()
if comment:
request.admin_comment = (request.admin_comment or "") + f"\n{comment}"
await db.commit()
return True
# ==================== ФОРМАТИРОВАНИЕ ====================
def format_balance_stats_for_user(self, stats: Dict, texts) -> str:
"""Форматирует статистику баланса для пользователя."""
text = ""
text += texts.t(
"REFERRAL_WITHDRAWAL_STATS_EARNED",
"📈 Всего заработано с рефералов: <b>{amount}</b>"
).format(amount=texts.format_price(stats["total_earned"])) + "\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_STATS_SPENT",
"💳 Потрачено на подписки: <b>{amount}</b>"
).format(amount=texts.format_price(stats["referral_spent"])) + "\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_STATS_WITHDRAWN",
"💸 Выведено: <b>{amount}</b>"
).format(amount=texts.format_price(stats["withdrawn"])) + "\n"
if stats["pending"] > 0:
text += texts.t(
"REFERRAL_WITHDRAWAL_STATS_PENDING",
"⏳ На рассмотрении: <b>{amount}</b>"
).format(amount=texts.format_price(stats["pending"])) + "\n"
text += "\n"
text += texts.t(
"REFERRAL_WITHDRAWAL_STATS_AVAILABLE",
"✅ <b>Доступно к выводу: {amount}</b>"
).format(amount=texts.format_price(stats["available_total"])) + "\n"
if stats["only_referral_mode"]:
text += texts.t(
"REFERRAL_WITHDRAWAL_ONLY_REF_MODE",
"<i>ℹ️ Выводить можно только реферальный баланс</i>"
) + "\n"
return text
def format_analysis_for_admin(self, analysis: Dict) -> str:
"""Форматирует анализ для отображения админу."""
risk_emoji = {
"low": "🟢",
"medium": "🟡",
"high": "🟠",
"critical": "🔴"
}
text = f"""
🔍 <b>Анализ на подозрительную активность</b>
{risk_emoji.get(analysis['risk_level'], '')} Уровень риска: <b>{analysis['risk_level'].upper()}</b>
📊 Оценка риска: <b>{analysis['risk_score']}/100</b>
{analysis.get('recommendation_text', '')}
"""
if analysis.get("flags"):
text += "\n⚠️ <b>Предупреждения:</b>\n"
for flag in analysis["flags"]:
text += f" {flag}\n"
details = analysis.get("details", {})
# Статистика баланса
if "balance_stats" in details:
bs = details["balance_stats"]
text += "\n💰 <b>Баланс:</b>\n"
text += f"• Заработано с рефералов: {bs['total_earned'] / 100:.0f}\n"
text += f"• Собственные пополнения: {bs['own_deposits'] / 100:.0f}\n"
text += f"• Потрачено: {bs['spending'] / 100:.0f}\n"
text += f"• Уже выведено: {bs['withdrawn'] / 100:.0f}\n"
# Статистика по рефералам
if "referral_deposits" in details:
rd = details["referral_deposits"]
text += f"\n👥 <b>Рефералы:</b>\n"
text += f"• Всего: {details.get('referral_count', 0)}\n"
text += f"• Платящих: {rd['paying_referrals']}\n"
text += f"• Всего пополнений: {rd['total_deposits']} ({rd['total_amount'] / 100:.0f}₽)\n"
# Подозрительные рефералы
if details.get("suspicious_referrals"):
text += "\n🚨 <b>Подозрительные рефералы:</b>\n"
for sr in details["suspicious_referrals"][:5]:
text += f"{sr['name']}: {sr['deposits_count']} поп., {sr['deposits_total'] / 100:.0f}\n"
text += f" Флаги: {', '.join(sr['flags'])}\n"
# Источники дохода
if "earnings_by_reason" in details:
text += "\n📊 <b>Источники дохода:</b>\n"
reason_names = {
"referral_first_topup": "Бонус за 1-е пополнение",
"referral_commission_topup": "Комиссия с пополнений",
"referral_commission": "Комиссия с покупок"
}
for reason, data in details["earnings_by_reason"].items():
name = reason_names.get(reason, reason)
text += f"{name}: {data['count']} шт. ({data['total'] / 100:.0f}₽)\n"
return text
# Синглтон сервиса
referral_withdrawal_service = ReferralWithdrawalService()
@@ -738,14 +738,64 @@ async def auto_activate_subscription_after_topup(
from app.services.subscription_renewal_service import SubscriptionRenewalService
from app.services.admin_notification_service import AdminNotificationService
if not settings.is_auto_activate_after_topup_enabled():
return False
if not user or not getattr(user, "id", None):
return 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"))
):
try:
texts = get_texts(getattr(user, "language", "ru"))
warning_message = (
f"✅ <b>Баланс пополнен!</b>\n\n"
f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
f"{'' * 25}\n\n"
f"⚠️ <b>ВАЖНО!</b> ⚠️\n\n"
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
f"Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
f"👇 <b>Выберите действие:</b>"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
callback_data="subscription_buy",
)],
[InlineKeyboardButton(
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
callback_data="subscription_extend",
)],
[InlineKeyboardButton(
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
callback_data="subscription_add_devices",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=warning_message,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"⚠️ Отправлено предупреждение об активации подписки пользователю %s (автоактивация выключена)",
user.telegram_id,
)
except Exception as notify_error:
logger.warning(
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
user.telegram_id,
notify_error,
)
return False
# Если подписка активна — ничего не делаем
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
logger.info(
@@ -825,6 +875,55 @@ 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"))
):
try:
texts = get_texts(getattr(user, "language", "ru"))
warning_message = (
f"✅ <b>Баланс пополнен!</b>\n\n"
f"💳 Текущий баланс: {settings.format_price(balance)}\n\n"
f"{'' * 25}\n\n"
f"⚠️ <b>ВАЖНО!</b> ⚠️\n\n"
f"🔴 <b>ПОДПИСКА НЕ АКТИВНА!</b>\n\n"
f"Пополнение баланса <b>НЕ активирует</b> подписку автоматически!\n\n"
f"👇 <b>Выберите действие:</b>"
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
callback_data="subscription_buy",
)],
[InlineKeyboardButton(
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
callback_data="subscription_extend",
)],
[InlineKeyboardButton(
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
callback_data="subscription_add_devices",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=warning_message,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"⚠️ Отправлено предупреждение об активации подписки пользователю %s",
user.telegram_id,
)
except Exception as notify_error:
logger.warning(
"⚠️ Не удалось отправить предупреждение пользователю %s: %s",
user.telegram_id,
notify_error,
)
return False
texts = get_texts(getattr(user, "language", "ru"))
+120 -17
View File
@@ -5,12 +5,13 @@
import logging
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Set
from app.config import settings
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.database.crud.user import get_user_by_remnawave_uuid
from app.database.database import get_db
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
@@ -268,8 +269,23 @@ class TrafficMonitoringScheduler:
self.traffic_service = traffic_service
self.check_task = None
self.is_running = False
self.bot = None
# Кэш уведомлений: {user_uuid: дата_последнего_уведомления}
self._notification_cache: Dict[str, datetime] = {}
async def start_monitoring(self, db: AsyncSession, bot):
def set_bot(self, bot):
"""Устанавливает экземпляр бота для отправки уведомлений"""
self.bot = bot
def is_enabled(self) -> bool:
"""Проверяет, включен ли мониторинг трафика"""
return self.traffic_service.is_traffic_monitoring_enabled()
def get_interval_hours(self) -> int:
"""Получает интервал проверки в часах"""
return self.traffic_service.get_monitoring_interval_hours()
async def start_monitoring(self):
"""
Запускает периодическую проверку трафика
"""
@@ -277,40 +293,79 @@ class TrafficMonitoringScheduler:
logger.warning("Мониторинг трафика уже запущен")
return
if not self.traffic_service.is_traffic_monitoring_enabled():
if not self.is_enabled():
logger.info("Мониторинг трафика отключен в настройках")
return
if not self.bot:
logger.error("Бот не установлен для мониторинга трафика")
return
self.is_running = True
interval_hours = self.traffic_service.get_monitoring_interval_hours()
interval_hours = self.get_interval_hours()
interval_seconds = interval_hours * 3600
logger.info(f"Запуск мониторинга трафика с интервалом {interval_hours} часов")
logger.info(f"🚀 Запуск мониторинга трафика с интервалом {interval_hours} ч")
# Запускаем задачу с интервалом
self.check_task = asyncio.create_task(self._periodic_check(db, bot, interval_seconds))
self.check_task = asyncio.create_task(self._periodic_check(interval_seconds))
async def stop_monitoring(self):
def stop_monitoring(self):
"""
Останавливает периодическую проверку трафика
"""
self.is_running = False
if self.check_task:
self.check_task.cancel()
try:
await self.check_task
except asyncio.CancelledError:
pass
self.is_running = False
logger.info("Мониторинг трафика остановлен")
logger.info("ℹ️ Мониторинг трафика остановлен")
async def _periodic_check(self, db: AsyncSession, bot, interval_seconds: int):
def _should_send_notification(self, user_uuid: str) -> bool:
"""
Проверяет, нужно ли отправлять уведомление для пользователя.
Защита от спама: одно уведомление в сутки на пользователя.
"""
now = datetime.utcnow()
last_notification = self._notification_cache.get(user_uuid)
if last_notification is None:
return True
# Если прошло больше 24 часов с последнего уведомления
return (now - last_notification) > timedelta(hours=24)
def _record_notification(self, user_uuid: str):
"""Записывает факт отправки уведомления"""
self._notification_cache[user_uuid] = datetime.utcnow()
def _cleanup_notification_cache(self):
"""Очищает старые записи из кэша (старше 48 часов)"""
now = datetime.utcnow()
expired = [
uuid for uuid, dt in self._notification_cache.items()
if (now - dt) > timedelta(hours=48)
]
for uuid in expired:
del self._notification_cache[uuid]
if expired:
logger.debug(f"🧹 Очищено {len(expired)} старых записей из кэша уведомлений о трафике")
async def _periodic_check(self, interval_seconds: int):
"""
Выполняет периодическую проверку трафика
"""
while self.is_running:
try:
logger.info("Запуск периодической проверки трафика")
await self.traffic_service.check_all_users_traffic(db, bot)
logger.info("📊 Запуск периодической проверки трафика")
# Очищаем старый кэш
self._cleanup_notification_cache()
# Получаем сессию БД внутри цикла
async for db in get_db():
try:
await self._check_all_users_traffic(db)
finally:
break
# Ждем указанный интервал перед следующей проверкой
await asyncio.sleep(interval_seconds)
@@ -319,10 +374,58 @@ class TrafficMonitoringScheduler:
logger.info("Задача периодической проверки трафика отменена")
break
except Exception as e:
logger.error(f"Ошибка в периодической проверке трафика: {e}")
logger.error(f"Ошибка в периодической проверке трафика: {e}")
# Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова
await asyncio.sleep(interval_seconds)
async def _check_all_users_traffic(self, db: AsyncSession):
"""
Проверяет трафик всех пользователей с активной подпиской
"""
try:
from app.database.crud.user import get_users_with_active_subscriptions
# Получаем всех пользователей с активной подпиской
users = await get_users_with_active_subscriptions(db)
checked_count = 0
exceeded_count = 0
logger.info(f"📊 Начинаем проверку трафика для {len(users)} пользователей")
# Проверяем трафик для каждого пользователя
for user in users:
if user.remnawave_uuid:
is_exceeded, traffic_info = await self.traffic_service.check_user_traffic_threshold(
db,
user.remnawave_uuid,
user.telegram_id
)
checked_count += 1
if is_exceeded:
exceeded_count += 1
# Проверяем, не отправляли ли уже уведомление
if self._should_send_notification(user.remnawave_uuid):
await self.traffic_service.process_suspicious_traffic(
db,
user.remnawave_uuid,
traffic_info,
self.bot
)
self._record_notification(user.remnawave_uuid)
else:
logger.debug(
f"⏭️ Пропуск уведомления для {user.telegram_id} — уже отправляли сегодня"
)
logger.info(
f"✅ Проверка трафика завершена: проверено {checked_count}, превышений {exceeded_count}"
)
except Exception as e:
logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}")
# Глобальные экземпляры сервисов
traffic_monitoring_service = TrafficMonitoringService()
+79 -1
View File
@@ -33,7 +33,85 @@ logger = logging.getLogger(__name__)
class UserService:
async def send_topup_success_to_user(
self,
bot: Bot,
user: User,
amount_kopeks: int,
subscription: Optional[Subscription] = None,
) -> bool:
"""
Отправляет пользователю уведомление об успешном пополнении баланса.
Если подписки нет - показывает БОЛЬШОЕ предупреждение что нужно активировать.
"""
try:
texts = get_texts(user.language)
has_active_subscription = (
subscription is not None
and subscription.status in {"active", "trial"}
)
if has_active_subscription:
# У пользователя есть активная подписка - обычное сообщение
message = (
f"✅ <b>Баланс пополнен на {settings.format_price(amount_kopeks)}!</b>\n\n"
f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n"
f"Спасибо за использование нашего сервиса! 🎉"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend"
)]
])
else:
# НЕТ активной подписки - БОЛЬШОЕ ПРЕДУПРЕЖДЕНИЕ
message = (
f"✅ <b>Баланс пополнен на {settings.format_price(amount_kopeks)}!</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"Пополнение баланса НЕ активирует подписку автоматически!\n\n"
f"👇 <b>Выберите действие:</b>"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ",
callback_data="subscription_buy"
)],
[types.InlineKeyboardButton(
text="💎 ПРОДЛИТЬ ПОДПИСКУ",
callback_data="subscription_extend"
)],
[types.InlineKeyboardButton(
text="📱 ДОБАВИТЬ УСТРОЙСТВА",
callback_data="subscription_add_devices"
)]
])
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard
)
logger.info(f"✅ Уведомление о пополнении отправлено пользователю {user.telegram_id}")
return True
except TelegramForbiddenError:
logger.warning(f"⚠️ Пользователь {user.telegram_id} заблокировал бота")
return False
except TelegramBadRequest as e:
logger.error(f"❌ Ошибка Telegram API: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления: {e}")
return False
async def _send_balance_notification(
self,
bot: Bot,
+9
View File
@@ -111,6 +111,9 @@ class AdminStates(StatesGroup):
editing_user_referrals = State()
editing_user_referral_percent = State()
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
@@ -239,3 +242,9 @@ class AdminSubmenuStates(StatesGroup):
class BlacklistStates(StatesGroup):
waiting_for_blacklist_url = State()
class ReferralWithdrawalStates(StatesGroup):
waiting_for_amount = State()
waiting_for_payment_details = State()
confirming = State()
+40
View File
@@ -35,6 +35,7 @@ from app.services.broadcast_service import broadcast_service
from app.services.referral_contest_service import referral_contest_service
from app.services.contest_rotation_service import contest_rotation_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter
@@ -172,6 +173,7 @@ async def main():
monitoring_task = None
maintenance_task = None
version_check_task = None
traffic_monitoring_task = None
polling_task = None
web_api_server = None
telegram_webhook_enabled = False
@@ -237,6 +239,7 @@ async def main():
monitoring_service.bot = bot
maintenance_service.set_bot(bot)
broadcast_service.set_bot(bot)
traffic_monitoring_scheduler.set_bot(bot)
from app.services.admin_notification_service import AdminNotificationService
@@ -577,6 +580,23 @@ async def main():
maintenance_task = None
stage.skip("Служба техработ уже активна")
async with timeline.stage(
"Мониторинг трафика",
"📊",
success_message="Мониторинг трафика запущен",
) as stage:
if traffic_monitoring_scheduler.is_enabled():
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} ГБ/сутки")
else:
traffic_monitoring_task = None
stage.skip("Мониторинг трафика отключен настройками")
async with timeline.stage(
"Сервис проверки версий",
"📄",
@@ -638,6 +658,7 @@ async def main():
services_lines = [
f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}",
f"Техработы: {'Включен' if maintenance_task else 'Отключен'}",
f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}",
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
]
@@ -682,6 +703,16 @@ async def main():
logger.info("🔄 Перезапуск сервиса проверки версий...")
version_check_task = asyncio.create_task(version_service.start_periodic_check())
if traffic_monitoring_task and traffic_monitoring_task.done():
exception = traffic_monitoring_task.exception()
if exception:
logger.error(f"Мониторинг трафика завершился с ошибкой: {exception}")
if traffic_monitoring_scheduler.is_enabled():
logger.info("🔄 Перезапуск мониторинга трафика...")
traffic_monitoring_task = asyncio.create_task(
traffic_monitoring_scheduler.start_monitoring()
)
if auto_verification_active and not auto_payment_verification_service.is_running():
logger.warning(
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
@@ -742,6 +773,15 @@ async def main():
except asyncio.CancelledError:
pass
if traffic_monitoring_task and not traffic_monitoring_task.done():
logger.info("ℹ️ Остановка мониторинга трафика...")
traffic_monitoring_scheduler.stop_monitoring()
traffic_monitoring_task.cancel()
try:
await traffic_monitoring_task
except asyncio.CancelledError:
pass
logger.info("ℹ️ Остановка сервиса отчетов...")
try:
await reporting_service.stop()