Добавлена система вывода реферального баланса

Новая функциональность вывода средств:
  - config.py: добавлены настройки вывода (минимальная сумма, кулдаун, анализ подозрительности, тестовый режим)
  - models.py: добавлена модель WithdrawalRequest с полями для заявок, анализа рисков и обработки админ
This commit is contained in:
gy9vin
2026-01-07 14:54:50 +03:00
parent 8342e8fe35
commit 4afefcafa4
8 changed files with 1744 additions and 16 deletions

View File

@@ -181,15 +181,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
@@ -1719,7 +1734,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)

View File

@@ -1067,6 +1067,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"

View File

@@ -4937,6 +4937,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
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -5435,6 +5521,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"))

View File

@@ -1,17 +1,22 @@
import json
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import datetime
from app.config import settings
from app.database.models import User
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus, ReferralEarning
from app.localization.texts import get_texts
from app.database.crud.referral import (
get_referral_statistics,
get_top_referrers_by_period,
get_user_referral_stats,
)
from app.database.crud.user import get_user_by_id
from app.database.crud.user import get_user_by_id, get_user_by_telegram_id
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@@ -78,12 +83,26 @@ async def show_referral_statistics(
<i>🕐 Обновлено: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
keyboard_rows = [
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
]
# Кнопка заявок на вывод (если функция включена)
if settings.is_referral_withdrawal_enabled():
keyboard_rows.append([
types.InlineKeyboardButton(
text="💸 Заявки на вывод",
callback_data="admin_withdrawal_requests"
)
])
keyboard_rows.extend([
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
@@ -292,8 +311,451 @@ 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",
description=f"Тестовое начисление от админа {db_user.telegram_id}"
)
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_{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)

View File

@@ -1,9 +1,11 @@
import json
import logging
from pathlib import Path
import qrcode
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +13,9 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.services.referral_withdrawal_service import referral_withdrawal_service
from app.services.admin_notification_service import AdminNotificationService
from app.states import ReferralWithdrawalStates
from app.utils.photo_message import edit_or_answer_photo
from app.utils.user_utils import (
get_detailed_referral_list,
@@ -463,8 +468,390 @@ 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"
"• Номер карты: 1234 5678 9012 3456\n"
"• СБП: +7 999 123-45-67 (Сбербанк)\n"
"• Кошелёк: ЮMoney 4100..."
)
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"
"• Номер карты: 1234 5678 9012 3456\n"
"• СБП: +7 999 123-45-67 (Сбербанк)\n"
"• Кошелёк: ЮMoney 4100..."
)
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_notification(
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.NOTIFICATIONS_CHAT_ID:
try:
await callback.bot.send_message(
chat_id=settings.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 +885,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
)

View File

@@ -1530,7 +1530,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(
@@ -1556,14 +1556,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)

View File

@@ -0,0 +1,665 @@
"""
Сервис для обработки запросов на вывод реферального баланса
с анализом на подозрительную активность (отмывание денег).
"""
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_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_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)
withdrawn = await self.get_withdrawn_amount(db, user_id)
pending = await self.get_pending_withdrawal_amount(db, user_id)
# Сколько реф. баланса потрачено = мин(траты, реф_заработок)
# Логика: сначала тратим реф. баланс, потом свой
referral_spent = min(spending, 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_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()

View File

@@ -111,6 +111,9 @@ class AdminStates(StatesGroup):
editing_user_referrals = State()
editing_user_referral_percent = State()
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
@@ -218,3 +221,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()