diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py
index 997cf902..0da99ea5 100644
--- a/app/handlers/admin/contests.py
+++ b/app/handlers/admin/contests.py
@@ -1015,6 +1015,10 @@ async def show_virtual_participants(
text='➕ Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
+ types.InlineKeyboardButton(
+ text='🎭 Массовка',
+ callback_data=f'admin_contest_vp_mass_{contest_id}',
+ ),
],
]
if vps:
@@ -1160,7 +1164,10 @@ async def delete_virtual_participant_handler(
lines.append('Пока нет виртуальных участников.')
rows = [
- [types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
+ [
+ types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
+ types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
+ ],
]
if vps:
for v in vps:
@@ -1180,6 +1187,160 @@ async def delete_virtual_participant_handler(
)
+@admin_required
+@error_handler
+async def start_mass_virtual_participants(
+ callback: types.CallbackQuery,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Начинает массовое создание виртуальных участников (массовка)."""
+ contest_id = int(callback.data.split('_')[-1])
+ await state.set_state(AdminStates.adding_mass_virtual_count)
+ await state.update_data(mass_vp_contest_id=contest_id)
+
+ text = """
+🎭 Массовка — массовое создание виртуальных участников
+
+Для чего это нужно?
+Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
+
+Это помогает:
+• Мотивировать реальных участников соревноваться
+• Задать планку для участия
+• Сделать конкурс более живым
+
+Введите количество призраков для создания:
+(от 1 до 50)
+"""
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
+ ]
+ ),
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_mass_virtual_count(
+ message: types.Message,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Обрабатывает количество призраков для массового создания."""
+ try:
+ count = int(message.text.strip())
+ if count < 1 or count > 50:
+ await message.answer(
+ '❌ Введите число от 1 до 50:',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
+ ]
+ ),
+ )
+ return
+ except ValueError:
+ await message.answer(
+ '❌ Введите корректное число от 1 до 50:',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
+ ]
+ ),
+ )
+ return
+
+ await state.update_data(mass_vp_count=count)
+ await state.set_state(AdminStates.adding_mass_virtual_referrals)
+
+ data = await state.get_data()
+ contest_id = data.get('mass_vp_contest_id')
+
+ await message.answer(
+ f'✅ Будет создано {count} призраков.\n\n'
+ f'Введите количество рефералов у каждого:\n'
+ f'(от 1 до 100)',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
+ ]
+ ),
+ )
+
+
+@admin_required
+@error_handler
+async def process_mass_virtual_referrals(
+ message: types.Message,
+ db_user,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Создаёт массовку призраков с рандомными именами."""
+ import random
+ import string
+
+ try:
+ referrals_count = int(message.text.strip())
+ if referrals_count < 1 or referrals_count > 100:
+ await message.answer('❌ Введите число от 1 до 100:')
+ return
+ except ValueError:
+ await message.answer('❌ Введите корректное число от 1 до 100:')
+ return
+
+ data = await state.get_data()
+ contest_id = data.get('mass_vp_contest_id')
+ ghost_count = data.get('mass_vp_count', 1)
+
+ await state.clear()
+
+ # Генерируем и создаём призраков
+ created = []
+ for _ in range(ghost_count):
+ # Рандомное имя до 5 символов (буквы + цифры)
+ name_length = random.randint(3, 5)
+ name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
+
+ vp = await add_virtual_participant(db, contest_id, name, referrals_count)
+ created.append(vp)
+
+ # Показываем результат
+ text = f"""
+✅ Массовка создана!
+
+📊 Результат:
+• Создано призраков: {len(created)}
+• Рефералов у каждого: {referrals_count}
+• Всего виртуальных рефералов: {len(created) * referrals_count}
+
+👻 Созданные призраки:
+"""
+ for vp in created[:10]:
+ text += f'• {vp.display_name} — {vp.referral_count} реф.\n'
+
+ if len(created) > 10:
+ text += f'... и ещё {len(created) - 10}\n'
+
+ await message.answer(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}')],
+ [types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
+ ]
+ ),
+ )
+
+
@admin_required
@error_handler
async def start_edit_virtual_participant(
@@ -1282,7 +1443,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
+ dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
+ dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
+ dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py
index 1f5ab589..6d30fcb5 100644
--- a/app/handlers/admin/referrals.py
+++ b/app/handlers/admin/referrals.py
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
keyboard_rows = [
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
+ [types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
]
# Кнопка заявок на вывод (если функция включена)
@@ -650,11 +651,822 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
)
+def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
+ """Возвращает начальную и конечную даты для заданного периода."""
+ now = datetime.datetime.now()
+ today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ if period == 'today':
+ start_date = today
+ end_date = today + datetime.timedelta(days=1)
+ elif period == 'yesterday':
+ start_date = today - datetime.timedelta(days=1)
+ end_date = today
+ elif period == 'week':
+ start_date = today - datetime.timedelta(days=7)
+ end_date = today + datetime.timedelta(days=1)
+ elif period == 'month':
+ start_date = today - datetime.timedelta(days=30)
+ end_date = today + datetime.timedelta(days=1)
+ else:
+ # По умолчанию — сегодня
+ start_date = today
+ end_date = today + datetime.timedelta(days=1)
+
+ return start_date, end_date
+
+
+def _get_period_display_name(period: str) -> str:
+ """Возвращает человекочитаемое название периода."""
+ names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
+ return names.get(period, 'сегодня')
+
+
+async def _show_diagnostics_for_period(
+ callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str
+):
+ """Внутренняя функция для отображения диагностики за указанный период."""
+ try:
+ await callback.answer('Анализирую логи...')
+
+ from app.services.referral_diagnostics_service import referral_diagnostics_service
+
+ # Сохраняем период в state
+ await state.update_data(diagnostics_period=period)
+ from app.states import AdminStates
+
+ await state.set_state(AdminStates.referral_diagnostics_period)
+
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+
+ # Формируем отчёт
+ period_display = _get_period_display_name(period)
+
+ text = f"""
+🔍 Диагностика рефералов — {period_display}
+
+📊 Статистика переходов:
+• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
+• Уникальных пользователей: {report.unique_users_clicked}
+• Потерянных рефералов: {len(report.lost_referrals)}
+"""
+
+ if report.lost_referrals:
+ text += '\n❌ Потерянные рефералы:\n'
+ text += '(пришли по ссылке, но реферер не засчитался)\n\n'
+
+ for i, lost in enumerate(report.lost_referrals[:15], 1):
+ # Статус пользователя
+ if not lost.registered:
+ status = '⚠️ Не в БД'
+ elif not lost.has_referrer:
+ status = '❌ Без реферера'
+ else:
+ status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
+
+ # Имя или ID
+ user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
+ if lost.username:
+ user_name = f'@{lost.username}'
+
+ # Ожидаемый реферер
+ referrer_info = ''
+ if lost.expected_referrer_name:
+ referrer_info = f' → {lost.expected_referrer_name}'
+ elif lost.expected_referrer_id:
+ referrer_info = f' → ID{lost.expected_referrer_id}'
+
+ # Время
+ time_str = lost.click_time.strftime('%H:%M')
+
+ text += f'{i}. {user_name} — {status}\n'
+ text += f' {lost.referral_code}{referrer_info} ({time_str})\n'
+
+ if len(report.lost_referrals) > 15:
+ text += f'\n... и ещё {len(report.lost_referrals) - 15}\n'
+ else:
+ text += '\n✅ Все рефералы засчитаны!\n'
+
+ # Информация о логах
+ log_path = referral_diagnostics_service.log_path
+ log_exists = log_path.exists()
+ log_size = log_path.stat().st_size if log_exists else 0
+
+ text += f'\n📂 {log_path.name}'
+ if log_exists:
+ text += f' ({log_size / 1024:.0f} KB)'
+ text += f' | Строк: {report.lines_in_period}'
+ else:
+ text += ' (не найден!)'
+ text += ''
+
+ # Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
+ keyboard_rows = [
+ [
+ types.InlineKeyboardButton(
+ text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')
+ ],
+ [
+ types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')
+ ],
+ [
+ types.InlineKeyboardButton(text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest')
+ ],
+ ]
+
+ # Кнопки действий (только если есть потерянные рефералы)
+ if report.lost_referrals:
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
+ )
+
+ keyboard_rows.extend(
+ [
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
+ [types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
+ ]
+ )
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
+ await callback.answer('Ошибка при анализе логов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Показывает диагностику реферальной системы по логам."""
+ # Определяем период из callback_data или используем "today" по умолчанию
+ if ':' in callback.data:
+ period = callback.data.split(':')[1]
+ else:
+ period = 'today'
+
+ await _show_diagnostics_for_period(callback, db, state, period)
+
+
+@admin_required
+@error_handler
+async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Показывает предпросмотр исправлений потерянных рефералов."""
+ try:
+ await callback.answer('Анализирую...')
+
+ # Получаем период из state
+ state_data = await state.get_data()
+ period = state_data.get('diagnostics_period', 'today')
+
+ from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
+
+ # Проверяем, работаем ли с загруженным файлом
+ if period == 'uploaded_file':
+ # Используем сохранённый отчёт из загруженного файла (десериализуем)
+ report_data = state_data.get('uploaded_file_report')
+ if not report_data:
+ await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
+ return
+ report = DiagnosticReport.from_dict(report_data)
+ period_display = 'загруженный файл'
+ else:
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+ period_display = _get_period_display_name(period)
+
+ if not report.lost_referrals:
+ await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
+ return
+
+ # Запускаем предпросмотр исправлений
+ fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
+
+ # Формируем отчёт
+ text = f"""
+📋 Предпросмотр исправлений — {period_display}
+
+📊 Что будет сделано:
+• Исправлено рефералов: {fix_report.users_fixed}
+• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
+• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
+• Ошибок: {fix_report.errors}
+
+🔍 Детали:
+"""
+
+ # Показываем первые 10 деталей
+ for i, detail in enumerate(fix_report.details[:10], 1):
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ if detail.username:
+ user_name = f'@{detail.username}'
+
+ if detail.error:
+ text += f'{i}. {user_name} — ❌ {detail.error}\n'
+ else:
+ text += f'{i}. {user_name}\n'
+ if detail.referred_by_set:
+ text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
+ if detail.had_first_topup:
+ text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
+ if detail.bonus_to_referral_kopeks > 0:
+ text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
+ if detail.bonus_to_referrer_kopeks > 0:
+ text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
+
+ if len(fix_report.details) > 10:
+ text += f'\n... и ещё {len(fix_report.details) - 10}\n'
+
+ text += '\n⚠️ Внимание! Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
+
+ # Кнопка назад зависит от источника
+ back_button_text = '⬅️ К диагностике'
+ back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
+ [types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
+ await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
+ """Применяет исправления потерянных рефералов."""
+ try:
+ await callback.answer('Применяю исправления...')
+
+ # Получаем период из state
+ state_data = await state.get_data()
+ period = state_data.get('diagnostics_period', 'today')
+
+ from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
+
+ # Проверяем, работаем ли с загруженным файлом
+ if period == 'uploaded_file':
+ # Используем сохранённый отчёт из загруженного файла (десериализуем)
+ report_data = state_data.get('uploaded_file_report')
+ if not report_data:
+ await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
+ return
+ report = DiagnosticReport.from_dict(report_data)
+ period_display = 'загруженный файл'
+ else:
+ # Получаем даты периода
+ start_date, end_date = _get_period_dates(period)
+
+ # Анализируем логи
+ report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
+ period_display = _get_period_display_name(period)
+
+ if not report.lost_referrals:
+ await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
+ return
+
+ # Применяем исправления
+ fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
+
+ # Формируем отчёт
+ text = f"""
+✅ Исправления применены — {period_display}
+
+📊 Результаты:
+• Исправлено рефералов: {fix_report.users_fixed}
+• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
+• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
+• Ошибок: {fix_report.errors}
+
+🔍 Детали:
+"""
+
+ # Показываем первые 10 успешных деталей
+ success_count = 0
+ for detail in fix_report.details:
+ if not detail.error and success_count < 10:
+ success_count += 1
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ if detail.username:
+ user_name = f'@{user_name}'
+
+ text += f'{success_count}. {user_name}\n'
+ if detail.referred_by_set:
+ text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
+ if detail.bonus_to_referral_kopeks > 0:
+ text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
+ if detail.bonus_to_referrer_kopeks > 0:
+ text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
+
+ if fix_report.users_fixed > 10:
+ text += f'\n... и ещё {fix_report.users_fixed - 10} исправлений\n'
+
+ # Показываем ошибки
+ if fix_report.errors > 0:
+ text += '\n❌ Ошибки:\n'
+ error_count = 0
+ for detail in fix_report.details:
+ if detail.error and error_count < 5:
+ error_count += 1
+ user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
+ text += f'• {user_name}: {detail.error}\n'
+ if fix_report.errors > 5:
+ text += f'... и ещё {fix_report.errors - 5} ошибок\n'
+
+ # Кнопки зависят от источника
+ keyboard_rows = []
+ if period != 'uploaded_file':
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
+ )
+ keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ # Очищаем сохранённый отчёт из state
+ if period == 'uploaded_file':
+ await state.update_data(uploaded_file_report=None)
+
+ except Exception as e:
+ logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
+ await callback.answer('Ошибка при применении исправлений', show_alert=True)
+
+
+# =============================================================================
+# Проверка бонусов по БД
+# =============================================================================
+
+
+@admin_required
+@error_handler
+async def check_missing_bonuses(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ """Проверяет по БД — всем ли рефералам начислены бонусы."""
+ from app.services.referral_diagnostics_service import (
+ MissingBonusReport,
+ referral_diagnostics_service,
+ )
+
+ await callback.answer('🔍 Проверяю бонусы...')
+
+ try:
+ report = await referral_diagnostics_service.check_missing_bonuses(db)
+
+ # Сохраняем отчёт в state для последующего применения
+ await state.update_data(missing_bonuses_report=report.to_dict())
+
+ text = f"""
+🔍 Проверка бонусов по БД
+
+📊 Статистика:
+• Всего рефералов: {report.total_referrals_checked}
+• С пополнением ≥ минимума: {report.referrals_with_topup}
+• Без бонусов: {len(report.missing_bonuses)}
+"""
+
+ if report.missing_bonuses:
+ text += f"""
+💰 Требуется начислить:
+• Рефералам: {report.total_missing_to_referrals / 100:.0f}₽
+• Рефереерам: {report.total_missing_to_referrers / 100:.0f}₽
+• Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}₽
+
+👤 Список ({len(report.missing_bonuses)} чел.):
+"""
+ for i, mb in enumerate(report.missing_bonuses[:15], 1):
+ referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
+ referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
+ text += f"\n{i}. {referral_name}"
+ text += f"\n └ Пригласил: {referrer_name}"
+ text += f"\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽"
+ text += f"\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}₽"
+
+ if len(report.missing_bonuses) > 15:
+ text += f"\n\n... и ещё {len(report.missing_bonuses) - 15} чел."
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+ else:
+ text += '\n✅ Все бонусы начислены!'
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
+ await callback.answer('Ошибка при проверке бонусов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def apply_missing_bonuses(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ """Применяет начисление пропущенных бонусов."""
+ from app.services.referral_diagnostics_service import (
+ MissingBonus,
+ MissingBonusReport,
+ referral_diagnostics_service,
+ )
+
+ await callback.answer('💰 Начисляю бонусы...')
+
+ try:
+ # Получаем сохранённый отчёт
+ data = await state.get_data()
+ report_dict = data.get('missing_bonuses_report')
+
+ if not report_dict:
+ await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
+ return
+
+ report = MissingBonusReport.from_dict(report_dict)
+
+ if not report.missing_bonuses:
+ await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
+ return
+
+ # Применяем исправления
+ fix_report = await referral_diagnostics_service.fix_missing_bonuses(
+ db, report.missing_bonuses, apply=True
+ )
+
+ text = f"""
+✅ Бонусы начислены!
+
+📊 Результат:
+• Обработано: {fix_report.users_fixed} пользователей
+• Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}₽
+• Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}₽
+• Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}₽
+"""
+
+ if fix_report.errors > 0:
+ text += f'\n⚠️ Ошибок: {fix_report.errors}'
+
+ # Очищаем отчёт из state
+ await state.update_data(missing_bonuses_report=None)
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
+ await callback.answer('Ошибка при начислении бонусов', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def sync_referrals_with_contest(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ """Синхронизирует всех рефералов с активными конкурсами."""
+ from app.database.crud.referral_contest import get_contests_for_events
+ from app.services.referral_contest_service import referral_contest_service
+
+ await callback.answer('🏆 Синхронизирую с конкурсами...')
+
+ try:
+ from datetime import datetime
+
+ now_utc = datetime.utcnow()
+
+ # Получаем активные конкурсы
+ paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
+ reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
+
+ all_contests = list(paid_contests) + list(reg_contests)
+
+ if not all_contests:
+ await callback.message.edit_text(
+ '❌ Нет активных конкурсов рефералов\n\n'
+ 'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Синхронизируем каждый конкурс
+ total_created = 0
+ total_updated = 0
+ total_skipped = 0
+ contest_results = []
+
+ for contest in all_contests:
+ stats = await referral_contest_service.sync_contest(db, contest.id)
+ if 'error' not in stats:
+ total_created += stats.get('created', 0)
+ total_updated += stats.get('updated', 0)
+ total_skipped += stats.get('skipped', 0)
+ contest_results.append(f"• {contest.title}: +{stats.get('created', 0)} новых")
+ else:
+ contest_results.append(f"• {contest.title}: ошибка")
+
+ text = f"""
+🏆 Синхронизация с конкурсами завершена!
+
+📊 Результат:
+• Конкурсов обработано: {len(all_contests)}
+• Новых событий добавлено: {total_created}
+• Обновлено: {total_updated}
+• Пропущено (уже есть): {total_skipped}
+
+📋 По конкурсам:
+"""
+ text += '\n'.join(contest_results)
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
+ await callback.answer('Ошибка при синхронизации', show_alert=True)
+
+
+@admin_required
+@error_handler
+async def request_log_file_upload(
+ callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
+):
+ """Запрашивает загрузку лог-файла для анализа."""
+ await state.set_state(AdminStates.waiting_for_log_file)
+
+ text = """
+📤 Загрузка лог-файла для анализа
+
+Отправьте файл лога (расширение .log или .txt).
+
+Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
+
+⚠️ Важно:
+• Файл должен быть текстовым (.log, .txt)
+• Максимальный размер: 50 MB
+• После анализа файл будет автоматически удалён
+
+Если ротация логов удалила старые данные — загрузите резервную копию.
+"""
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
+ )
+
+ await callback.message.edit_text(text, reply_markup=keyboard)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
+ """Получает и анализирует загруженный лог-файл."""
+ import os
+ import tempfile
+
+ if not message.document:
+ await message.answer(
+ '❌ Пожалуйста, отправьте файл документом.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Проверяем расширение файла
+ file_name = message.document.file_name or 'unknown'
+ file_ext = os.path.splitext(file_name)[1].lower()
+
+ if file_ext not in ['.log', '.txt']:
+ await message.answer(
+ f'❌ Неверный формат файла: {file_ext}\n\n'
+ f'Поддерживаются только текстовые файлы (.log, .txt)',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Проверяем размер файла
+ max_size = 50 * 1024 * 1024 # 50 MB
+ if message.document.file_size > max_size:
+ await message.answer(
+ f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\n'
+ f'Максимальный размер: 50 MB',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+ return
+
+ # Информируем о начале загрузки
+ status_message = await message.answer(
+ f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
+ )
+
+ temp_file_path = None
+
+ try:
+ # Скачиваем файл во временную директорию
+ temp_dir = tempfile.gettempdir()
+ temp_file_path = os.path.join(temp_dir, f'ref_diagnostics_{message.from_user.id}_{file_name}')
+
+ # Скачиваем файл
+ file = await message.bot.get_file(message.document.file_id)
+ await message.bot.download_file(file.file_path, temp_file_path)
+
+ logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
+
+ # Обновляем статус
+ await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
+
+ # Анализируем файл
+ from app.services.referral_diagnostics_service import referral_diagnostics_service
+
+ report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
+
+ # Формируем отчёт
+ text = f"""
+🔍 Анализ лог-файла: {file_name}
+
+📊 Статистика переходов:
+• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
+• Уникальных пользователей: {report.unique_users_clicked}
+• Потерянных рефералов: {len(report.lost_referrals)}
+• Строк в файле: {report.lines_in_period}
+"""
+
+ if report.lost_referrals:
+ text += '\n❌ Потерянные рефералы:\n'
+ text += '(пришли по ссылке, но реферер не засчитался)\n\n'
+
+ for i, lost in enumerate(report.lost_referrals[:15], 1):
+ # Статус пользователя
+ if not lost.registered:
+ status = '⚠️ Не в БД'
+ elif not lost.has_referrer:
+ status = '❌ Без реферера'
+ else:
+ status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
+
+ # Имя или ID
+ user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
+ if lost.username:
+ user_name = f'@{lost.username}'
+
+ # Ожидаемый реферер
+ referrer_info = ''
+ if lost.expected_referrer_name:
+ referrer_info = f' → {lost.expected_referrer_name}'
+ elif lost.expected_referrer_id:
+ referrer_info = f' → ID{lost.expected_referrer_id}'
+
+ # Время
+ time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
+
+ text += f'{i}. {user_name} — {status}\n'
+ text += f' {lost.referral_code}{referrer_info} ({time_str})\n'
+
+ if len(report.lost_referrals) > 15:
+ text += f'\n... и ещё {len(report.lost_referrals) - 15}\n'
+ else:
+ text += '\n✅ Все рефералы засчитаны!\n'
+
+ # Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
+ await state.update_data(
+ diagnostics_period='uploaded_file',
+ uploaded_file_report=report.to_dict(),
+ )
+
+ # Кнопки действий
+ keyboard_rows = []
+
+ if report.lost_referrals:
+ keyboard_rows.append(
+ [types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
+ )
+
+ keyboard_rows.extend(
+ [
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ [types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
+ ]
+ )
+
+ keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
+
+ # Удаляем статусное сообщение
+ await status_message.delete()
+
+ # Отправляем результат
+ await message.answer(text, reply_markup=keyboard)
+
+ # Очищаем состояние
+ await state.set_state(AdminStates.referral_diagnostics_period)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
+
+ try:
+ await status_message.edit_text(
+ f'❌ Ошибка при анализе файла\n\n'
+ f'Файл: {file_name}\n'
+ f'Ошибка: {str(e)}\n\n'
+ f'Проверьте, что файл является текстовым логом бота.',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload')],
+ [types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
+ ]
+ ),
+ )
+ except:
+ await message.answer(
+ f'❌ Ошибка при анализе файла: {str(e)}',
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
+ ]
+ ),
+ )
+
+ finally:
+ # Удаляем временный файл
+ if temp_file_path and os.path.exists(temp_file_path):
+ try:
+ os.remove(temp_file_path)
+ logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
+ except Exception as e:
+ logger.error(f'Ошибка удаления временного файла: {e}')
+
+
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_referral_diagnostics, F.data == 'admin_referral_diagnostics')
+ dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
+ dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
+ dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
+
+ # Загрузка лог-файла
+ dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
+ dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
+
+ # Проверка бонусов по БД
+ dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
+ dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
+ dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
# Хендлеры заявок на вывод
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
diff --git a/app/handlers/start.py b/app/handlers/start.py
index 7bdf8a54..2ae94b36 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -309,24 +309,28 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
data = await state.get_data() or {}
- had_pending_payload = 'pending_start_payload' in data
- pending_start_payload = data.pop('pending_start_payload', None)
+
+ # ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
+ # pending_start_payload обрабатывается отдельно ниже
had_campaign_notification_flag = 'campaign_notification_sent' in data
- campaign_notification_sent = data.pop('campaign_notification_sent', False)
- state_needs_update = had_pending_payload or had_campaign_notification_flag
+ campaign_notification_sent = data.get('campaign_notification_sent', False)
+ state_needs_update = False
+
+ # Получаем payload из state или Redis
+ pending_start_payload = data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
+ data['pending_start_payload'] = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
- # Очищаем Redis после получения
- await delete_pending_payload_from_redis(message.from_user.id)
+ # НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
referral_code = None
campaign = None
@@ -1167,6 +1171,10 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
refresh_subscription_error,
)
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
+ await delete_pending_payload_from_redis(callback.from_user.id)
+ logger.info('🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
+
await state.clear()
if campaign_message:
@@ -1454,6 +1462,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
refresh_subscription_error,
)
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
+ await delete_pending_payload_from_redis(message.from_user.id)
+ logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
+
await state.clear()
if campaign_message:
@@ -1717,19 +1729,22 @@ async def required_sub_channel_check(
try:
state_data = await state.get_data() or {}
- pending_start_payload = state_data.pop('pending_start_payload', None)
+ # ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() чтобы не удалять payload
+ # При повторных кликах "Я подписался" payload должен сохраняться
+ pending_start_payload = state_data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
+ state_data['pending_start_payload'] = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
- state_updated = pending_start_payload is not None
+ state_updated = False
if pending_start_payload:
logger.info(
@@ -1737,27 +1752,36 @@ async def required_sub_channel_check(
pending_start_payload,
)
- # Очищаем Redis после получения payload
- await delete_pending_payload_from_redis(query.from_user.id)
+ # ИСПРАВЛЕНИЕ БАГА: НЕ удаляем Redis payload здесь!
+ # Удаление происходит только после успешной регистрации пользователя
- # Всегда обновляем referral_code если есть новый payload
- # (исправление бага с устаревшими данными в state)
- campaign = await get_campaign_by_start_parameter(
- db,
- pending_start_payload,
- only_active=True,
- )
-
- if campaign:
- state_data['campaign_id'] = campaign.id
- logger.info(
- '📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
- campaign.id,
+ # Обрабатываем payload только если ещё не обработан
+ # (проверяем по наличию referral_code или campaign_id в state)
+ if not state_data.get('referral_code') and not state_data.get('campaign_id'):
+ campaign = await get_campaign_by_start_parameter(
+ db,
+ pending_start_payload,
+ only_active=True,
)
+
+ if campaign:
+ state_data['campaign_id'] = campaign.id
+ state_updated = True
+ logger.info(
+ '📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
+ campaign.id,
+ )
+ else:
+ state_data['referral_code'] = pending_start_payload
+ state_updated = True
+ logger.info(
+ '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
+ pending_start_payload,
+ )
else:
- state_data['referral_code'] = pending_start_payload
logger.info(
- '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
+ '✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
+ state_data.get('referral_code') or f"campaign_id={state_data.get('campaign_id')}",
)
if state_updated:
@@ -1821,6 +1845,12 @@ async def required_sub_channel_check(
except Exception as e:
logger.warning(f'Не удалось удалить сообщение: {e}')
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
+ # и перед показом главного меню или завершением регистрации
+ if pending_start_payload:
+ await delete_pending_payload_from_redis(query.from_user.id)
+ logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
+
if user and user.status != UserStatus.DELETED.value:
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
@@ -1903,6 +1933,11 @@ async def required_sub_channel_check(
)
await db.refresh(user, ['subscription'])
+ # ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
+ state_data.pop('pending_start_payload', None)
+ await state.set_data(state_data)
+ logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
+
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
diff --git a/app/services/referral_diagnostics_service.py b/app/services/referral_diagnostics_service.py
new file mode 100644
index 00000000..aebf1e26
--- /dev/null
+++ b/app/services/referral_diagnostics_service.py
@@ -0,0 +1,1094 @@
+"""
+Сервис диагностики реферальной системы по логам.
+
+Анализирует логи бота для выявления проблем с реферальной системой:
+- Переходы по реф-ссылкам
+- Сверка с БД — засчитался ли реферал
+- Выявление потерянных рефералов
+"""
+
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.referral import create_referral_earning
+from app.database.crud.user import add_user_balance
+from app.database.models import ReferralEarning, User
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ReferralClick:
+ """Информация о переходе по реф-ссылке."""
+
+ timestamp: datetime
+ telegram_id: int
+ raw_code: str # Код как в логе (может быть ref_refXXX)
+ clean_code: str # Очищенный код (refXXX)
+ log_line: str
+
+
+@dataclass
+class LostReferral:
+ """Потерянный реферал — пришёл по ссылке, но реферер не засчитался."""
+
+ telegram_id: int
+ username: Optional[str]
+ full_name: Optional[str]
+ referral_code: str # По какому коду пришёл
+ expected_referrer_code: str # Код реферера
+ expected_referrer_id: Optional[int] # ID реферера в БД
+ expected_referrer_name: Optional[str] # Имя реферера
+ click_time: datetime
+ registered: bool # Есть в БД?
+ has_referrer: bool # Есть referred_by_id?
+ current_referrer_id: Optional[int] # Текущий referred_by_id
+
+ def to_dict(self) -> dict:
+ """Сериализация в dict для хранения в Redis."""
+ return {
+ 'telegram_id': self.telegram_id,
+ 'username': self.username,
+ 'full_name': self.full_name,
+ 'referral_code': self.referral_code,
+ 'expected_referrer_code': self.expected_referrer_code,
+ 'expected_referrer_id': self.expected_referrer_id,
+ 'expected_referrer_name': self.expected_referrer_name,
+ 'click_time': self.click_time.isoformat() if self.click_time else None,
+ 'registered': self.registered,
+ 'has_referrer': self.has_referrer,
+ 'current_referrer_id': self.current_referrer_id,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'LostReferral':
+ """Десериализация из dict."""
+ click_time = data.get('click_time')
+ if click_time and isinstance(click_time, str):
+ click_time = datetime.fromisoformat(click_time)
+ return cls(
+ telegram_id=data['telegram_id'],
+ username=data.get('username'),
+ full_name=data.get('full_name'),
+ referral_code=data['referral_code'],
+ expected_referrer_code=data['expected_referrer_code'],
+ expected_referrer_id=data.get('expected_referrer_id'),
+ expected_referrer_name=data.get('expected_referrer_name'),
+ click_time=click_time,
+ registered=data.get('registered', False),
+ has_referrer=data.get('has_referrer', False),
+ current_referrer_id=data.get('current_referrer_id'),
+ )
+
+
+@dataclass
+class DiagnosticReport:
+ """Отчёт о диагностике реферальной системы."""
+
+ # Статистика
+ total_ref_clicks: int = 0 # Всего переходов по реф-ссылкам
+ unique_users_clicked: int = 0 # Уникальных пользователей
+
+ # Проблемные случаи
+ lost_referrals: list[LostReferral] = field(default_factory=list)
+
+ # Период анализа
+ analysis_period_start: Optional[datetime] = None
+ analysis_period_end: Optional[datetime] = None
+
+ # Статистика парсинга
+ total_lines_parsed: int = 0
+ lines_in_period: int = 0
+
+ def to_dict(self) -> dict:
+ """Сериализация в dict для хранения в Redis."""
+ return {
+ 'total_ref_clicks': self.total_ref_clicks,
+ 'unique_users_clicked': self.unique_users_clicked,
+ 'lost_referrals': [lr.to_dict() for lr in self.lost_referrals],
+ 'analysis_period_start': self.analysis_period_start.isoformat() if self.analysis_period_start else None,
+ 'analysis_period_end': self.analysis_period_end.isoformat() if self.analysis_period_end else None,
+ 'total_lines_parsed': self.total_lines_parsed,
+ 'lines_in_period': self.lines_in_period,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'DiagnosticReport':
+ """Десериализация из dict."""
+ start = data.get('analysis_period_start')
+ end = data.get('analysis_period_end')
+ if start and isinstance(start, str):
+ start = datetime.fromisoformat(start)
+ if end and isinstance(end, str):
+ end = datetime.fromisoformat(end)
+
+ lost_referrals = [
+ LostReferral.from_dict(lr) for lr in data.get('lost_referrals', [])
+ ]
+
+ return cls(
+ total_ref_clicks=data.get('total_ref_clicks', 0),
+ unique_users_clicked=data.get('unique_users_clicked', 0),
+ lost_referrals=lost_referrals,
+ analysis_period_start=start,
+ analysis_period_end=end,
+ total_lines_parsed=data.get('total_lines_parsed', 0),
+ lines_in_period=data.get('lines_in_period', 0),
+ )
+
+
+@dataclass
+class FixDetail:
+ """Детали исправления одного потерянного реферала."""
+
+ telegram_id: int
+ username: Optional[str]
+ full_name: Optional[str]
+
+ # Что сделано
+ referred_by_set: bool # Установлен referred_by_id
+ referrer_id: Optional[int] # ID реферера
+ referrer_name: Optional[str] # Имя реферера
+
+ # Бонусы
+ bonus_to_referral_kopeks: int = 0 # Бонус рефералу
+ bonus_to_referrer_kopeks: int = 0 # Бонус рефереру
+
+ # Статус
+ had_first_topup: bool = False # Было первое пополнение
+ topup_amount_kopeks: int = 0 # Сумма пополнения
+
+ # Ошибки
+ error: Optional[str] = None
+
+
+@dataclass
+class FixReport:
+ """Отчёт об исправлении потерянных рефералов."""
+
+ users_fixed: int = 0 # Исправлено referred_by_id
+ bonuses_to_referrals: int = 0 # Бонусов рефералам (копейки)
+ bonuses_to_referrers: int = 0 # Бонусов рефереам (копейки)
+ details: list[FixDetail] = field(default_factory=list)
+ errors: int = 0 # Количество ошибок
+
+
+@dataclass
+class MissingBonus:
+ """Информация о ненначисленном бонусе."""
+
+ # Реферал (приглашённый)
+ referral_id: int
+ referral_telegram_id: int
+ referral_username: Optional[str]
+ referral_full_name: Optional[str]
+
+ # Реферер (пригласивший)
+ referrer_id: int
+ referrer_telegram_id: int
+ referrer_username: Optional[str]
+ referrer_full_name: Optional[str]
+
+ # Первое пополнение
+ first_topup_amount_kopeks: int
+ first_topup_date: Optional[datetime]
+
+ # Какие бонусы не начислены
+ missing_referral_bonus: bool = False # Бонус рефералу
+ missing_referrer_bonus: bool = False # Бонус рефереру
+
+ # Суммы для начисления
+ referral_bonus_amount: int = 0
+ referrer_bonus_amount: int = 0
+
+ def to_dict(self) -> dict:
+ """Сериализация для Redis."""
+ return {
+ 'referral_id': self.referral_id,
+ 'referral_telegram_id': self.referral_telegram_id,
+ 'referral_username': self.referral_username,
+ 'referral_full_name': self.referral_full_name,
+ 'referrer_id': self.referrer_id,
+ 'referrer_telegram_id': self.referrer_telegram_id,
+ 'referrer_username': self.referrer_username,
+ 'referrer_full_name': self.referrer_full_name,
+ 'first_topup_amount_kopeks': self.first_topup_amount_kopeks,
+ 'first_topup_date': self.first_topup_date.isoformat() if self.first_topup_date else None,
+ 'missing_referral_bonus': self.missing_referral_bonus,
+ 'missing_referrer_bonus': self.missing_referrer_bonus,
+ 'referral_bonus_amount': self.referral_bonus_amount,
+ 'referrer_bonus_amount': self.referrer_bonus_amount,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'MissingBonus':
+ """Десериализация из dict."""
+ topup_date = data.get('first_topup_date')
+ if topup_date and isinstance(topup_date, str):
+ topup_date = datetime.fromisoformat(topup_date)
+ return cls(
+ referral_id=data['referral_id'],
+ referral_telegram_id=data['referral_telegram_id'],
+ referral_username=data.get('referral_username'),
+ referral_full_name=data.get('referral_full_name'),
+ referrer_id=data['referrer_id'],
+ referrer_telegram_id=data['referrer_telegram_id'],
+ referrer_username=data.get('referrer_username'),
+ referrer_full_name=data.get('referrer_full_name'),
+ first_topup_amount_kopeks=data.get('first_topup_amount_kopeks', 0),
+ first_topup_date=topup_date,
+ missing_referral_bonus=data.get('missing_referral_bonus', False),
+ missing_referrer_bonus=data.get('missing_referrer_bonus', False),
+ referral_bonus_amount=data.get('referral_bonus_amount', 0),
+ referrer_bonus_amount=data.get('referrer_bonus_amount', 0),
+ )
+
+
+@dataclass
+class MissingBonusReport:
+ """Отчёт о ненначисленных бонусах."""
+
+ total_referrals_checked: int = 0 # Всего проверено рефералов
+ referrals_with_topup: int = 0 # Рефералов с первым пополнением
+ missing_bonuses: list[MissingBonus] = field(default_factory=list)
+
+ # Суммы
+ total_missing_to_referrals: int = 0 # Всего не начислено рефералам
+ total_missing_to_referrers: int = 0 # Всего не начислено рефереерам
+
+ def to_dict(self) -> dict:
+ """Сериализация для Redis."""
+ return {
+ 'total_referrals_checked': self.total_referrals_checked,
+ 'referrals_with_topup': self.referrals_with_topup,
+ 'missing_bonuses': [mb.to_dict() for mb in self.missing_bonuses],
+ 'total_missing_to_referrals': self.total_missing_to_referrals,
+ 'total_missing_to_referrers': self.total_missing_to_referrers,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> 'MissingBonusReport':
+ """Десериализация из dict."""
+ missing_bonuses = [
+ MissingBonus.from_dict(mb) for mb in data.get('missing_bonuses', [])
+ ]
+ return cls(
+ total_referrals_checked=data.get('total_referrals_checked', 0),
+ referrals_with_topup=data.get('referrals_with_topup', 0),
+ missing_bonuses=missing_bonuses,
+ total_missing_to_referrals=data.get('total_missing_to_referrals', 0),
+ total_missing_to_referrers=data.get('total_missing_to_referrers', 0),
+ )
+
+
+class ReferralDiagnosticsService:
+ """Сервис диагностики реферальной системы."""
+
+ # Возможные пути к логам (приоритет: current > стандартный)
+ LOG_PATHS = [
+ 'logs/current/bot.log',
+ '/app/logs/current/bot.log',
+ 'logs/bot.log',
+ '/app/logs/bot.log',
+ ]
+
+ def __init__(self, log_path: str | None = None):
+ if log_path:
+ self.log_path = Path(log_path)
+ else:
+ self.log_path = self._find_log_file()
+
+ def _find_log_file(self) -> Path:
+ """Ищет существующий лог-файл, предпочитая свежие."""
+ today = datetime.now().date()
+ candidates = []
+
+ for path_str in self.LOG_PATHS:
+ path = Path(path_str)
+ if path.exists() and path.stat().st_size > 0:
+ mtime = datetime.fromtimestamp(path.stat().st_mtime).date()
+ is_fresh = mtime >= today - timedelta(days=1)
+ candidates.append((path, is_fresh, path.stat().st_mtime))
+ logger.info(f'📁 Найден лог: {path} (свежий: {is_fresh})')
+
+ candidates.sort(key=lambda x: (not x[1], -x[2]))
+
+ if candidates:
+ selected = candidates[0][0]
+ logger.info(f'✅ Выбран лог-файл: {selected}')
+ return selected
+
+ return Path('logs/current/bot.log')
+
+ @staticmethod
+ def clean_referral_code(raw_code: str) -> str:
+ """
+ Очищает реферальный код от лишних префиксов.
+
+ ref_refXXX -> refXXX (miniapp добавляет ref_)
+ refXXX -> refXXX (без изменений)
+ """
+ if raw_code.startswith('ref_ref'):
+ return raw_code[4:] # Убираем "ref_"
+ return raw_code
+
+ async def analyze_today(self, db: AsyncSession) -> DiagnosticReport:
+ """Анализирует реферальные события за сегодня."""
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+ return await self.analyze_period(db, today, tomorrow)
+
+ async def analyze_period(
+ self, db: AsyncSession, start_date: datetime, end_date: datetime
+ ) -> DiagnosticReport:
+ """Анализирует реферальные события за указанный период."""
+
+ # 1. Парсим логи — находим все переходы по реф-ссылкам
+ clicks, total_lines, lines_in_period = await self._parse_clicks(start_date, end_date)
+
+ # 2. Группируем по telegram_id (берём последний клик)
+ user_clicks: dict[int, ReferralClick] = {}
+ for click in clicks:
+ user_clicks[click.telegram_id] = click
+
+ # 3. Сверяем с БД — находим потерянных рефералов
+ lost_referrals = await self._find_lost_referrals(db, list(user_clicks.values()))
+
+ return DiagnosticReport(
+ total_ref_clicks=len(clicks),
+ unique_users_clicked=len(user_clicks),
+ lost_referrals=lost_referrals,
+ analysis_period_start=start_date,
+ analysis_period_end=end_date,
+ total_lines_parsed=total_lines,
+ lines_in_period=lines_in_period,
+ )
+
+ async def analyze_file(self, db: AsyncSession, file_path: str) -> DiagnosticReport:
+ """
+ Анализирует загруженный лог-файл на наличие потерянных рефералов.
+
+ Args:
+ db: Database session
+ file_path: Путь к загруженному файлу
+
+ Returns:
+ DiagnosticReport с результатами анализа всего файла
+ """
+ logger.info(f'📂 Начинаю анализ файла: {file_path}')
+
+ # Парсим весь файл без фильтра по дате
+ # Используем широкий диапазон дат (все время)
+ start_date = datetime(2000, 1, 1)
+ end_date = datetime(2100, 1, 1)
+
+ # Временно меняем путь к логу
+ original_log_path = self.log_path
+ self.log_path = Path(file_path)
+
+ try:
+ # skip_date_filter=True — парсим ВСЕ строки без фильтра по дате
+ clicks, total_lines, lines_in_period = await self._parse_clicks(
+ start_date, end_date, skip_date_filter=True
+ )
+
+ # Группируем по telegram_id (берём последний клик)
+ user_clicks: dict[int, ReferralClick] = {}
+ for click in clicks:
+ user_clicks[click.telegram_id] = click
+
+ # Сверяем с БД — находим потерянных рефералов
+ lost_referrals = await self._find_lost_referrals(db, list(user_clicks.values()))
+
+ logger.info(
+ f'✅ Анализ файла завершён: строк={total_lines}, '
+ f'реф-кликов={len(clicks)}, потерянных={len(lost_referrals)}'
+ )
+
+ return DiagnosticReport(
+ total_ref_clicks=len(clicks),
+ unique_users_clicked=len(user_clicks),
+ lost_referrals=lost_referrals,
+ analysis_period_start=None,
+ analysis_period_end=None,
+ total_lines_parsed=total_lines,
+ lines_in_period=lines_in_period,
+ )
+ finally:
+ # Восстанавливаем оригинальный путь
+ self.log_path = original_log_path
+
+ async def _parse_clicks(
+ self, start_date: datetime, end_date: datetime, skip_date_filter: bool = False
+ ) -> tuple[list[ReferralClick], int, int]:
+ """Парсит логи и находит все переходы по реф-ссылкам."""
+
+ clicks = []
+ total_lines = 0
+ lines_in_period = 0
+
+ if not self.log_path.exists():
+ logger.warning(f'❌ Лог-файл не найден: {self.log_path}')
+ return clicks, 0, 0
+
+ file_size = self.log_path.stat().st_size
+ logger.info(f'📂 Читаю лог-файл: {self.log_path} ({file_size / 1024 / 1024:.2f} MB)')
+
+ # Паттерн timestamp
+ timestamp_pattern = re.compile(
+ r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ - .+ - .+ - (.+)$'
+ )
+
+ # Паттерны для поиска реф-кликов
+ # /start refXXX или /start ref_refXXX
+ start_pattern = re.compile(r'📩 Сообщение от ID:(\d+).*?/start\s+(ref[\w_]+)')
+ # Сохранение payload
+ payload_pattern = re.compile(r"💾 Сохранен start payload '(ref[\w_]+)' для пользователя\s*(\d+)")
+
+ # Для быстрой фильтрации по дате (только если не пропускаем фильтр)
+ use_date_prefix = not skip_date_filter and (end_date - start_date).days <= 31
+ date_prefix = start_date.strftime('%Y-%m-%d') if use_date_prefix else None
+
+ try:
+ with open(self.log_path, 'r', encoding='utf-8', errors='ignore') as f:
+ for line in f:
+ total_lines += 1
+ line = line.strip()
+ if not line:
+ continue
+
+ # Убираем Docker-префикс
+ if ' | ' in line[:50]:
+ line = line.split(' | ', 1)[-1]
+
+ # Быстрая проверка по дате (только для коротких периодов)
+ if date_prefix and date_prefix not in line[:10]:
+ continue
+
+ # Парсим timestamp
+ match = timestamp_pattern.match(line)
+ if not match:
+ continue
+
+ timestamp_str, message = match.groups()
+ try:
+ timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
+ except ValueError:
+ continue
+
+ if not (start_date <= timestamp < end_date):
+ continue
+
+ lines_in_period += 1
+
+ # Ищем реф-клики
+ for pattern in [start_pattern, payload_pattern]:
+ event_match = pattern.search(message)
+ if event_match:
+ if pattern == start_pattern:
+ telegram_id = int(event_match.group(1))
+ raw_code = event_match.group(2)
+ else:
+ raw_code = event_match.group(1)
+ telegram_id = int(event_match.group(2))
+
+ clean_code = self.clean_referral_code(raw_code)
+
+ clicks.append(ReferralClick(
+ timestamp=timestamp,
+ telegram_id=telegram_id,
+ raw_code=raw_code,
+ clean_code=clean_code,
+ log_line=line,
+ ))
+ break
+
+ except Exception as e:
+ logger.error(f'Ошибка парсинга логов: {e}', exc_info=True)
+
+ logger.info(
+ f'📊 Парсинг: строк={total_lines}, за период={lines_in_period}, '
+ f'реф-кликов={len(clicks)}'
+ )
+ return clicks, total_lines, lines_in_period
+
+ async def _find_lost_referrals(
+ self, db: AsyncSession, clicks: list[ReferralClick]
+ ) -> list[LostReferral]:
+ """Находит потерянных рефералов — пришли по ссылке, но реферер не засчитался."""
+
+ if not clicks:
+ return []
+
+ lost = []
+ telegram_ids = [c.telegram_id for c in clicks]
+
+ # Получаем пользователей из БД
+ result = await db.execute(
+ select(User).where(User.telegram_id.in_(telegram_ids))
+ )
+ users_map = {u.telegram_id: u for u in result.scalars().all()}
+
+ # Получаем всех рефереров по кодам
+ codes = list(set(c.clean_code for c in clicks))
+ referrers_result = await db.execute(
+ select(User).where(User.referral_code.in_(codes))
+ )
+ referrers_map = {u.referral_code: u for u in referrers_result.scalars().all()}
+
+ for click in clicks:
+ user = users_map.get(click.telegram_id)
+ referrer = referrers_map.get(click.clean_code)
+
+ # Проверяем — засчитался ли реферал?
+ is_lost = False
+
+ if user is None:
+ # Пользователь не в БД — не завершил регистрацию
+ is_lost = True
+ elif user.created_at and user.created_at < click.timestamp:
+ # Пользователь был создан ДО клика по реф-ссылке
+ # Это старый пользователь, который просто зашёл по чужой ссылке
+ is_lost = False
+ logger.debug(
+ f'⏭️ Пропускаем {click.telegram_id}: создан {user.created_at} < клик {click.timestamp}'
+ )
+ elif user.referred_by_id is None:
+ # Пользователь в БД, но без реферера (и создан после клика)
+ is_lost = True
+ elif referrer and user.referred_by_id != referrer.id:
+ # Реферер другой (странный случай)
+ is_lost = True
+
+ if is_lost:
+ lost.append(LostReferral(
+ telegram_id=click.telegram_id,
+ username=user.username if user else None,
+ full_name=user.full_name if user else None,
+ referral_code=click.clean_code,
+ expected_referrer_code=click.clean_code,
+ expected_referrer_id=referrer.id if referrer else None,
+ expected_referrer_name=referrer.full_name if referrer else None,
+ click_time=click.timestamp,
+ registered=user is not None,
+ has_referrer=user.referred_by_id is not None if user else False,
+ current_referrer_id=user.referred_by_id if user else None,
+ ))
+
+ logger.info(f'🔍 Найдено потерянных рефералов: {len(lost)}')
+ return lost
+
+ async def _add_to_active_contests(
+ self,
+ db: AsyncSession,
+ referral: User,
+ referrer: User,
+ amount_kopeks: int,
+ ) -> None:
+ """
+ Добавляет восстановленного реферала в активные конкурсы.
+
+ Проверяет все активные конкурсы и добавляет событие если:
+ - Реферал зарегистрирован в период конкурса
+ - Событие ещё не было добавлено
+ """
+ from datetime import datetime
+
+ from app.database.crud.referral_contest import add_contest_event, get_contests_for_events
+
+ if not settings.is_contests_enabled():
+ return
+
+ now_utc = datetime.utcnow()
+
+ # Проверяем конкурсы по оплаченным рефералам
+ contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
+
+ for contest in contests:
+ try:
+ # Проверяем что реферал зарегистрировался В ПЕРИОД конкурса
+ user_created_at = (
+ referral.created_at
+ if referral.created_at.tzinfo is None
+ else referral.created_at.replace(tzinfo=None)
+ )
+ contest_start = (
+ contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
+ )
+ contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
+
+ if user_created_at < contest_start or user_created_at > contest_end:
+ logger.debug(
+ f'Реферал {referral.id} зарегистрирован вне периода конкурса {contest.id}'
+ )
+ continue
+
+ event = await add_contest_event(
+ db,
+ contest_id=contest.id,
+ referrer_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=amount_kopeks,
+ event_type='restored_referral',
+ )
+ if event:
+ logger.info(
+ f'🏆 Восстановленный реферал добавлен в конкурс {contest.id}: '
+ f'реферер {referrer.id}, реферал {referral.id}'
+ )
+ except Exception as exc:
+ logger.error(f'Не удалось добавить в конкурс {contest.id}: {exc}')
+
+ # Также проверяем конкурсы по регистрации (если есть)
+ reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
+
+ for contest in reg_contests:
+ try:
+ user_created_at = (
+ referral.created_at
+ if referral.created_at.tzinfo is None
+ else referral.created_at.replace(tzinfo=None)
+ )
+ contest_start = (
+ contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None)
+ )
+ contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None)
+
+ if user_created_at < contest_start or user_created_at > contest_end:
+ continue
+
+ event = await add_contest_event(
+ db,
+ contest_id=contest.id,
+ referrer_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=0,
+ event_type='restored_referral_registration',
+ )
+ if event:
+ logger.info(
+ f'🏆 Восстановленный реферал (регистрация) добавлен в конкурс {contest.id}'
+ )
+ except Exception as exc:
+ logger.error(f'Не удалось добавить в конкурс регистрации {contest.id}: {exc}')
+
+ async def fix_lost_referrals(
+ self, db: AsyncSession, lost_referrals: list[LostReferral], apply: bool = False
+ ) -> FixReport:
+ """
+ Исправляет потерянных рефералов.
+
+ Args:
+ db: Database session
+ lost_referrals: Список потерянных рефералов
+ apply: Если False — только предпросмотр, если True — применить изменения
+
+ Returns:
+ FixReport с деталями исправлений
+ """
+ report = FixReport()
+
+ if not lost_referrals:
+ logger.info('🔍 Нет потерянных рефералов для исправления')
+ return report
+
+ # Получаем всех пользователей и рефереров
+ telegram_ids = [lr.telegram_id for lr in lost_referrals]
+ result = await db.execute(select(User).where(User.telegram_id.in_(telegram_ids)))
+ users_map = {u.telegram_id: u for u in result.scalars().all()}
+
+ referrer_ids = list(set(lr.expected_referrer_id for lr in lost_referrals if lr.expected_referrer_id))
+ referrers_result = await db.execute(select(User).where(User.id.in_(referrer_ids)))
+ referrers_map = {u.id: u for u in referrers_result.scalars().all()}
+
+ for lost in lost_referrals:
+ detail = FixDetail(
+ telegram_id=lost.telegram_id,
+ username=lost.username,
+ full_name=lost.full_name,
+ referred_by_set=False,
+ referrer_id=lost.expected_referrer_id,
+ referrer_name=lost.expected_referrer_name,
+ )
+
+ try:
+ user = users_map.get(lost.telegram_id)
+ if not user:
+ detail.error = 'Пользователь не найден в БД'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ referrer = referrers_map.get(lost.expected_referrer_id) if lost.expected_referrer_id else None
+ if not referrer:
+ detail.error = 'Реферер не найден'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ # 1. Устанавливаем referred_by_id
+ if user.referred_by_id != referrer.id:
+ if apply:
+ user.referred_by_id = referrer.id
+ logger.info(f'✅ Установлен referred_by_id={referrer.id} для пользователя {user.telegram_id}')
+ detail.referred_by_set = True
+ report.users_fixed += 1
+
+ # 2. Проверяем первое пополнение
+ # Ищем первое пополнение пользователя
+ from app.database.models import Transaction, TransactionType
+
+ first_topup_result = await db.execute(
+ select(Transaction)
+ .where(Transaction.user_id == user.id, Transaction.type == TransactionType.DEPOSIT.value)
+ .order_by(Transaction.created_at.asc())
+ .limit(1)
+ )
+ first_topup = first_topup_result.scalar_one_or_none()
+
+ if first_topup and first_topup.amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
+ detail.had_first_topup = True
+ detail.topup_amount_kopeks = first_topup.amount_kopeks
+
+ # Проверяем, не начисляли ли уже бонусы
+ existing_bonus_result = await db.execute(
+ select(ReferralEarning)
+ .where(
+ ReferralEarning.user_id == referrer.id,
+ ReferralEarning.referral_id == user.id,
+ ReferralEarning.reason == 'referral_first_topup',
+ )
+ .limit(1)
+ )
+ existing_bonus = existing_bonus_result.scalar_one_or_none()
+
+ if not existing_bonus:
+ # 3. Начисляем бонус рефералу (приглашённому)
+ # Не проверяем has_made_first_topup — это восстановление потерянного реферала,
+ # он мог пополнить баланс, но бонус не получил т.к. не было referred_by_id
+ if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
+ detail.bonus_to_referral_kopeks = settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
+ report.bonuses_to_referrals += settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
+
+ if apply:
+ await add_user_balance(
+ db,
+ user,
+ settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
+ 'Восстановленный бонус за первое пополнение (потерянный реферал)',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+ user.has_made_first_topup = True
+ logger.info(
+ f'💰 Начислен бонус рефералу {user.telegram_id}: '
+ f'{settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100}₽'
+ )
+
+ # 4. Начисляем бонус рефереру
+ from app.utils.user_utils import get_effective_referral_commission_percent
+
+ commission_percent = get_effective_referral_commission_percent(referrer)
+ commission_amount = int(first_topup.amount_kopeks * commission_percent / 100)
+ inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
+
+ if inviter_bonus > 0:
+ detail.bonus_to_referrer_kopeks = inviter_bonus
+ report.bonuses_to_referrers += inviter_bonus
+
+ if apply:
+ await add_user_balance(
+ db,
+ referrer,
+ inviter_bonus,
+ f'Восстановленный бонус за реферала {user.full_name or user.username or user.telegram_id}',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+
+ # Создаём запись ReferralEarning
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=user.id,
+ amount_kopeks=inviter_bonus,
+ reason='referral_first_topup',
+ )
+
+ logger.info(
+ f'💰 Начислен бонус рефереру {referrer.telegram_id or referrer.id}: '
+ f'{inviter_bonus / 100}₽'
+ )
+
+ # Добавляем в активные конкурсы рефералов
+ await self._add_to_active_contests(
+ db, user, referrer, first_topup.amount_kopeks
+ )
+ else:
+ detail.error = 'Бонусы уже начислены ранее'
+
+ report.details.append(detail)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка исправления реферала {lost.telegram_id}: {e}', exc_info=True)
+ detail.error = str(e)
+ report.errors += 1
+ report.details.append(detail)
+
+ if apply:
+ await db.commit()
+ logger.info(
+ f'✅ Исправлено рефералов: {report.users_fixed}, '
+ f'начислено бонусов: {report.bonuses_to_referrals / 100}₽ + {report.bonuses_to_referrers / 100}₽'
+ )
+ else:
+ logger.info(f'📋 Предпросмотр: {report.users_fixed} рефералов будут исправлены')
+
+ return report
+
+ async def check_missing_bonuses(self, db: AsyncSession) -> MissingBonusReport:
+ """
+ Проверяет по БД: всем ли рефералам и рефереерам начислены бонусы.
+
+ Находит пользователей которые:
+ 1. Имеют referred_by_id (пришли по реф-ссылке)
+ 2. Сделали первое пополнение >= минимума
+ 3. Но бонусы не были начислены (нет ReferralEarning)
+
+ Returns:
+ MissingBonusReport со списком ненначисленных бонусов
+ """
+ from app.database.models import Transaction, TransactionType
+ from app.utils.user_utils import get_effective_referral_commission_percent
+
+ report = MissingBonusReport()
+
+ # 1. Находим всех рефералов (у кого есть referred_by_id)
+ referrals_result = await db.execute(
+ select(User).where(User.referred_by_id.isnot(None))
+ )
+ referrals = referrals_result.scalars().all()
+ report.total_referrals_checked = len(referrals)
+
+ if not referrals:
+ logger.info('📊 Нет рефералов для проверки')
+ return report
+
+ # 2. Собираем ID рефереров
+ referrer_ids = list(set(r.referred_by_id for r in referrals))
+ referrers_result = await db.execute(select(User).where(User.id.in_(referrer_ids)))
+ referrers_map = {u.id: u for u in referrers_result.scalars().all()}
+
+ # 3. Получаем все ReferralEarning для проверки
+ referral_ids = [r.id for r in referrals]
+ earnings_result = await db.execute(
+ select(ReferralEarning).where(
+ ReferralEarning.referral_id.in_(referral_ids),
+ ReferralEarning.reason == 'referral_first_topup',
+ )
+ )
+ # Множество пар (referrer_id, referral_id) где бонус уже начислен
+ existing_earnings = {(e.user_id, e.referral_id) for e in earnings_result.scalars().all()}
+
+ # 4. Проверяем каждого реферала
+ for referral in referrals:
+ referrer = referrers_map.get(referral.referred_by_id)
+ if not referrer:
+ continue
+
+ # Ищем первое пополнение
+ first_topup_result = await db.execute(
+ select(Transaction)
+ .where(
+ Transaction.user_id == referral.id,
+ Transaction.type == TransactionType.DEPOSIT.value,
+ )
+ .order_by(Transaction.created_at.asc())
+ .limit(1)
+ )
+ first_topup = first_topup_result.scalar_one_or_none()
+
+ # Если нет пополнения или меньше минимума — пропускаем
+ if not first_topup or first_topup.amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
+ continue
+
+ report.referrals_with_topup += 1
+
+ # Проверяем начислен ли бонус
+ bonus_exists = (referrer.id, referral.id) in existing_earnings
+
+ if bonus_exists:
+ # Бонусы уже начислены
+ continue
+
+ # Бонусы НЕ начислены — добавляем в отчёт
+ commission_percent = get_effective_referral_commission_percent(referrer)
+ commission_amount = int(first_topup.amount_kopeks * commission_percent / 100)
+ referrer_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
+
+ missing = MissingBonus(
+ referral_id=referral.id,
+ referral_telegram_id=referral.telegram_id,
+ referral_username=referral.username,
+ referral_full_name=referral.full_name,
+ referrer_id=referrer.id,
+ referrer_telegram_id=referrer.telegram_id,
+ referrer_username=referrer.username,
+ referrer_full_name=referrer.full_name,
+ first_topup_amount_kopeks=first_topup.amount_kopeks,
+ first_topup_date=first_topup.created_at,
+ missing_referral_bonus=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0,
+ missing_referrer_bonus=referrer_bonus > 0,
+ referral_bonus_amount=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
+ referrer_bonus_amount=referrer_bonus,
+ )
+
+ report.missing_bonuses.append(missing)
+ report.total_missing_to_referrals += missing.referral_bonus_amount
+ report.total_missing_to_referrers += missing.referrer_bonus_amount
+
+ logger.info(
+ f'📊 Проверка бонусов: {report.total_referrals_checked} рефералов, '
+ f'{report.referrals_with_topup} с пополнением, '
+ f'{len(report.missing_bonuses)} без бонусов'
+ )
+
+ return report
+
+ async def fix_missing_bonuses(
+ self, db: AsyncSession, missing_bonuses: list[MissingBonus], apply: bool = False
+ ) -> FixReport:
+ """
+ Начисляет пропущенные бонусы.
+
+ Args:
+ db: Database session
+ missing_bonuses: Список пропущенных бонусов
+ apply: Если False — только предпросмотр
+
+ Returns:
+ FixReport с деталями
+ """
+ from app.database.models import TransactionType
+
+ report = FixReport()
+
+ if not missing_bonuses:
+ return report
+
+ # Загружаем пользователей
+ referral_ids = [mb.referral_id for mb in missing_bonuses]
+ referrer_ids = [mb.referrer_id for mb in missing_bonuses]
+
+ users_result = await db.execute(
+ select(User).where(User.id.in_(referral_ids + referrer_ids))
+ )
+ users_map = {u.id: u for u in users_result.scalars().all()}
+
+ for missing in missing_bonuses:
+ referral = users_map.get(missing.referral_id)
+ referrer = users_map.get(missing.referrer_id)
+
+ detail = FixDetail(
+ telegram_id=missing.referral_telegram_id,
+ username=missing.referral_username,
+ full_name=missing.referral_full_name,
+ referred_by_set=False, # referred_by уже установлен
+ referrer_id=missing.referrer_id,
+ referrer_name=missing.referrer_full_name,
+ had_first_topup=True,
+ topup_amount_kopeks=missing.first_topup_amount_kopeks,
+ )
+
+ if not referral or not referrer:
+ detail.error = 'Пользователь не найден'
+ report.errors += 1
+ report.details.append(detail)
+ continue
+
+ try:
+ # Начисляем бонус рефералу
+ if missing.missing_referral_bonus and missing.referral_bonus_amount > 0:
+ detail.bonus_to_referral_kopeks = missing.referral_bonus_amount
+ report.bonuses_to_referrals += missing.referral_bonus_amount
+
+ if apply:
+ from app.database.models import TransactionType
+
+ await add_user_balance(
+ db,
+ referral,
+ missing.referral_bonus_amount,
+ 'Восстановленный бонус за первое пополнение',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+ referral.has_made_first_topup = True
+ logger.info(
+ f'💰 Начислен бонус рефералу {referral.telegram_id}: '
+ f'{missing.referral_bonus_amount / 100}₽'
+ )
+
+ # Начисляем бонус рефереру
+ if missing.missing_referrer_bonus and missing.referrer_bonus_amount > 0:
+ detail.bonus_to_referrer_kopeks = missing.referrer_bonus_amount
+ report.bonuses_to_referrers += missing.referrer_bonus_amount
+
+ if apply:
+ await add_user_balance(
+ db,
+ referrer,
+ missing.referrer_bonus_amount,
+ f'Восстановленный бонус за реферала {referral.full_name or referral.username or referral.telegram_id}',
+ create_transaction=True,
+ transaction_type=TransactionType.REFERRAL_REWARD,
+ )
+
+ # Создаём ReferralEarning чтобы не начислять повторно
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=referral.id,
+ amount_kopeks=missing.referrer_bonus_amount,
+ reason='referral_first_topup',
+ )
+ logger.info(
+ f'💰 Начислен бонус рефереру {referrer.telegram_id}: '
+ f'{missing.referrer_bonus_amount / 100}₽'
+ )
+
+ # Добавляем в активные конкурсы рефералов
+ await self._add_to_active_contests(
+ db, referral, referrer, missing.first_topup_amount_kopeks
+ )
+
+ report.users_fixed += 1
+ report.details.append(detail)
+
+ except Exception as e:
+ logger.error(f'❌ Ошибка начисления бонуса: {e}', exc_info=True)
+ detail.error = str(e)
+ report.errors += 1
+ report.details.append(detail)
+
+ if apply:
+ await db.commit()
+ logger.info(
+ f'✅ Начислено бонусов: {report.bonuses_to_referrals / 100}₽ рефералам + '
+ f'{report.bonuses_to_referrers / 100}₽ рефереерам'
+ )
+
+ return report
+
+
+# Глобальный экземпляр сервиса
+referral_diagnostics_service = ReferralDiagnosticsService()
diff --git a/app/states.py b/app/states.py
index 351880d0..b29c9a64 100644
--- a/app/states.py
+++ b/app/states.py
@@ -118,6 +118,9 @@ class AdminStates(StatesGroup):
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
+ # Массовое создание виртуальных участников (массовка)
+ adding_mass_virtual_count = State() # Сколько призраков создать
+ adding_mass_virtual_referrals = State() # По сколько рефералов у каждого
editing_daily_contest_field = State()
editing_daily_contest_value = State()
@@ -132,6 +135,10 @@ class AdminStates(StatesGroup):
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
+ # Диагностика рефералов
+ referral_diagnostics_period = State()
+ waiting_for_log_file = State()
+
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
diff --git a/tests/services/test_referral_diagnostics.py b/tests/services/test_referral_diagnostics.py
new file mode 100644
index 00000000..afcb3744
--- /dev/null
+++ b/tests/services/test_referral_diagnostics.py
@@ -0,0 +1,150 @@
+"""
+Тесты для сервиса диагностики реферальной системы.
+"""
+
+import tempfile
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.models import User
+from app.services.referral_diagnostics_service import ReferralDiagnosticsService
+
+
+@pytest.fixture
+def temp_log_file():
+ """Создаёт временный лог-файл для тестов."""
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
+ yield Path(f.name)
+ # Cleanup
+ Path(f.name).unlink(missing_ok=True)
+
+
+@pytest.fixture
+def sample_log_content():
+ """Пример содержимого лог-файла с реферальными событиями."""
+ today = datetime.now().strftime('%Y-%m-%d')
+ return f"""
+{today} 10:00:00,123 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+{today} 10:00:05,456 - app.handlers.start - INFO - ✅ Реферальный код ABC123 применен для пользователя 123456789
+{today} 10:00:10,789 - app.services.referral_service - INFO - ✅ Реферальная регистрация обработана для 123456789
+{today} 10:00:15,012 - app.services.referral_service - INFO - 💰 Реферал 123456789 получил бонус
+
+{today} 11:00:00,345 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+{today} 11:00:05,678 - app.handlers.start - INFO - ✅ Реферальный код XYZ999 применен для пользователя 987654321
+
+{today} 12:00:00,901 - app.handlers.start - INFO - 🔎 Найден реферальный код:
+
+{today} 13:00:00,234 - unrelated module - INFO - Some other log message
+"""
+
+
+@pytest.mark.asyncio
+async def test_parse_logs_basic(temp_log_file, sample_log_content):
+ """Тест базового парсинга логов."""
+ # Записываем тестовые данные в файл
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ events = await service._parse_logs(today, tomorrow)
+
+ # Проверяем что нашлись все события
+ assert len(events) >= 6, f"Expected at least 6 events, found {len(events)}"
+
+ # Проверяем типы событий
+ event_types = [e.event_type for e in events]
+ assert 'code_found' in event_types
+ assert 'code_applied' in event_types
+ assert 'registration_processed' in event_types
+ assert 'bonus_given' in event_types
+
+
+@pytest.mark.asyncio
+async def test_analyze_period_with_issues(temp_log_file, sample_log_content):
+ """Тест анализа с проблемными случаями."""
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ # Используем None вместо db для базового теста парсинга
+ from unittest.mock import AsyncMock
+ mock_db = AsyncMock()
+ mock_db.execute.return_value.scalar_one_or_none.return_value = None
+
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ # Проверяем статистику
+ # Примечание: code_found не имеет telegram_id, поэтому total_link_clicks будет 0
+ # Это нормально - мы считаем только события с telegram_id
+ assert report.total_codes_applied >= 1, "Should have applied codes"
+
+ # Проверяем что нашлись проблемные случаи
+ # (987654321 применил код, но не завершил регистрацию)
+ assert 987654321 in report.users_applied_no_registration, \
+ f"Expected 987654321 in problems, got: {report.users_applied_no_registration}"
+
+
+@pytest.mark.asyncio
+async def test_empty_log_file(temp_log_file):
+ """Тест работы с пустым лог-файлом."""
+ temp_log_file.write_text("")
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ from unittest.mock import AsyncMock
+ mock_db = AsyncMock()
+
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ # Проверяем что отчёт пустой
+ assert report.total_link_clicks == 0
+ assert report.total_codes_applied == 0
+ assert report.total_registrations == 0
+ assert len(report.events) == 0
+
+
+@pytest.mark.asyncio
+async def test_nonexistent_log_file():
+ """Тест работы с несуществующим лог-файлом."""
+ service = ReferralDiagnosticsService(log_path='/nonexistent/path/to/log.log')
+
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today + timedelta(days=1)
+
+ from unittest.mock import AsyncMock
+ mock_db = AsyncMock()
+
+ # Не должно быть исключений
+ report = await service.analyze_period(mock_db, today, tomorrow)
+
+ assert report.total_link_clicks == 0
+ assert len(report.events) == 0
+
+
+@pytest.mark.asyncio
+async def test_analyze_today(temp_log_file, sample_log_content):
+ """Тест метода analyze_today."""
+ temp_log_file.write_text(sample_log_content)
+
+ service = ReferralDiagnosticsService(log_path=str(temp_log_file))
+
+ from unittest.mock import AsyncMock
+ mock_db = AsyncMock()
+
+ report = await service.analyze_today(mock_db)
+
+ # Проверяем что период установлен корректно
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ assert report.analysis_period_start.date() == today.date()