From e0d667df2814801cf70ca93ace4d5ce7f5dec73e Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 30 Jan 2026 09:35:17 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D1=80=D0=B5=D1=84=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B!=20=D1=84=D0=B8=D1=88=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D1=81=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC!=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=20=D0=B8=20=D0=BD=D0=B0=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/admin/contests.py | 166 ++- app/handlers/admin/referrals.py | 812 +++++++++++++ app/handlers/start.py | 85 +- app/services/referral_diagnostics_service.py | 1094 ++++++++++++++++++ app/states.py | 7 + tests/services/test_referral_diagnostics.py | 150 +++ 6 files changed, 2288 insertions(+), 26 deletions(-) create mode 100644 app/services/referral_diagnostics_service.py create mode 100644 tests/services/test_referral_diagnostics.py 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()