+138
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user