diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 997cf902..f2e0af1a 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,164 @@ 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 +1447,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/payments.py b/app/handlers/admin/payments.py index b5b65c06..950e6a37 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str: return 'CryptoBot' if method == PaymentMethod.TELEGRAM_STARS: return 'Telegram Stars' + if method == PaymentMethod.KASSA_AI: + return settings.get_kassa_ai_display_name() + if method == PaymentMethod.FREEKASSA: + return settings.get_freekassa_display_name() return method.value @@ -144,6 +148,18 @@ def _status_info( } return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) + if record.method == PaymentMethod.KASSA_AI: + mapping = { + 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'created': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'error': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), + } + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) + return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown') @@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool: if record.method == PaymentMethod.CRYPTOBOT: return status in {'active'} if record.method == PaymentMethod.FREEKASSA: - return status in {'pending', ''} + return status in {'pending', 'created', ''} + if record.method == PaymentMethod.KASSA_AI: + return status in {'pending', 'created', 'processing', ''} return False @@ -184,6 +202,7 @@ def _build_list_keyboard( page: int, total_pages: int, language: str, + has_checkable: bool = False, ) -> InlineKeyboardMarkup: buttons: list[list[InlineKeyboardButton]] = [] texts = get_texts(language) @@ -204,6 +223,28 @@ def _build_list_keyboard( ] ) + # Кнопка "Проверить все" если есть что проверять + if has_checkable: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'), + callback_data='admin_payments_check_all', + ) + ] + ) + + # Кнопка экспорта если есть платежи + if records: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'), + callback_data='admin_payments_export', + ) + ] + ) + if total_pages > 1: navigation_row: list[InlineKeyboardButton] = [] if page > 1: @@ -485,11 +526,22 @@ async def show_payments_overview( lines = [header, '', description] + # Проверяем есть ли платежи для массовой проверки + checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid] + has_checkable = len(checkable_records) > 0 + if page_records: for idx, record in enumerate(page_records, start=start_index + 1): lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language)) lines.append('') lines.append(notice) + if has_checkable: + lines.append('') + lines.append( + texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format( + count=len(checkable_records) + ) + ) else: empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.') lines.append('') @@ -500,6 +552,7 @@ async def show_payments_overview( page=page, total_pages=total_pages, language=db_user.language, + has_checkable=has_checkable, ) await callback.message.edit_text( @@ -550,28 +603,42 @@ async def manual_check_payment( db_user: User, db: AsyncSession, ) -> None: + import logging + + logger = logging.getLogger(__name__) + logger.info('manual_check_payment called: %s', callback.data) + parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_') if not parsed: + logger.warning('Failed to parse: %s', callback.data) await callback.answer('❌ Invalid payment reference', show_alert=True) return method, payment_id = parsed + logger.info('Checking payment: method=%s, id=%s', method, payment_id) + record = await get_payment_record(db, method, payment_id) texts = get_texts(db_user.language) if not record: + logger.warning('Payment not found: method=%s, id=%s', method, payment_id) await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True) return + logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid) + if not _is_checkable(record): + logger.info('Payment not checkable: method=%s, status=%s', method, record.status) await callback.answer( texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'), show_alert=True, ) return + logger.info('Running manual check...') payment_service = PaymentService(callback.bot) updated = await run_manual_check(db, method, payment_id, payment_service) + logger.info('Check result: updated=%s', updated is not None) if not updated: await callback.answer( @@ -597,7 +664,189 @@ async def manual_check_payment( await callback.answer(message, show_alert=True) +@admin_required +@error_handler +async def check_all_payments( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + """Массовая проверка всех ожидающих платежей.""" + import logging + + logger = logging.getLogger(__name__) + logger.info('check_all_payments called') + + texts = get_texts(db_user.language) + + # Получаем все ожидающие платежи + records = await list_recent_pending_payments(db) + logger.info('Found %d total records', len(records)) + + checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid] + logger.info('Found %d checkable records', len(checkable_records)) + + if not checkable_records: + await callback.answer( + texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'), + show_alert=True, + ) + return + + await callback.answer( + texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)), + ) + + payment_service = PaymentService(callback.bot) + checked = 0 + confirmed = 0 + failed = 0 + + for record in checkable_records: + try: + logger.info('Checking %s payment id=%s', record.method.value, record.local_id) + updated = await run_manual_check(db, record.method, record.local_id, payment_service) + checked += 1 + logger.info('Check result: is_paid=%s', updated.is_paid if updated else None) + if updated and updated.is_paid and not record.is_paid: + confirmed += 1 + except Exception as e: + logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True) + failed += 1 + + logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed) + + # Показываем результат + result_lines = [ + texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 Результат проверки'), + '', + texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked), + texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed), + ] + if failed: + result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed)) + + # Перезагружаем список платежей + records = await list_recent_pending_payments(db) + total = len(records) + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page_records = records[:PAGE_SIZE] + checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid] + + result_lines.append('') + result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 Top-up verification')) + + if page_records: + result_lines.append('') + for idx, record in enumerate(page_records, start=1): + result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language)) + result_lines.append('') + + keyboard = _build_list_keyboard( + page_records, + page=1, + total_pages=total_pages, + language=db_user.language, + has_checkable=len(checkable_records) > 0, + ) + + logger.info('Updating message with results...') + try: + await callback.message.edit_text( + '\n'.join(result_lines), + parse_mode='HTML', + reply_markup=keyboard, + ) + logger.info('Message updated successfully') + except Exception as e: + logger.error('Failed to update message: %s', e, exc_info=True) + + +@admin_required +@error_handler +async def export_payments( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + """Экспорт данных платежей в JSON файл.""" + import json + + from aiogram.types import BufferedInputFile + + texts = get_texts(db_user.language) + + records = await list_recent_pending_payments(db) + + if not records: + await callback.answer( + texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'), + show_alert=True, + ) + return + + # Формируем данные для экспорта + export_data = [] + for record in records: + payment = record.payment + user = record.user + + payment_data = { + 'id': record.local_id, + 'method': record.method.value, + 'method_display': _method_display(record.method), + 'identifier': record.identifier, + 'amount_kopeks': record.amount_kopeks, + 'amount_rubles': record.amount_kopeks / 100, + 'status': record.status, + 'is_paid': record.is_paid, + 'created_at': record.created_at.isoformat() if record.created_at else None, + 'expires_at': record.expires_at.isoformat() if record.expires_at else None, + 'user': { + 'id': user.id, + 'telegram_id': user.telegram_id, + 'username': user.username, + 'full_name': user.full_name, + }, + } + + # Добавляем специфичные поля в зависимости от метода + if hasattr(payment, 'order_id'): + payment_data['order_id'] = payment.order_id + if hasattr(payment, 'payment_url'): + payment_data['payment_url'] = payment.payment_url + if hasattr(payment, 'callback_payload'): + payment_data['callback_payload'] = payment.callback_payload + + export_data.append(payment_data) + + # Создаём JSON файл + json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str) + file_bytes = json_content.encode('utf-8') + + # Отправляем файл + from datetime import datetime + + filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' + + await callback.message.answer_document( + document=BufferedInputFile(file_bytes, filename=filename), + caption=texts.t( + 'ADMIN_PAYMENTS_EXPORT_CAPTION', + '📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}', + ).format( + count=len(export_data), + paid=sum(1 for r in export_data if r['is_paid']), + pending=sum(1 for r in export_data if not r['is_paid']), + ), + ) + + await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен')) + + def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all') + dp.callback_query.register(export_payments, F.data == 'admin_payments_export') dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_')) dp.callback_query.register( show_payment_details, diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index 1f5ab589..0ba96478 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,812 @@ 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 ( + 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 ( + 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 tempfile + from pathlib import Path + + 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 = Path(file_name).suffix.lower() + + if file_ext not in ['.log', '.txt']: + await message.answer( + f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.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Максимальный размер: 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 = str(Path(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'Ошибка: {e!s}\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'❌ Ошибка при анализе файла: {e!s}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')] + ] + ), + ) + + finally: + # Удаляем временный файл + if temp_file_path and Path(temp_file_path).exists(): + try: + Path(temp_file_path).unlink() + 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/admin/users.py b/app/handlers/admin/users.py index 6ba6c31e..c6cdc4ba 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -65,11 +65,8 @@ class UserFilterType(Enum): """Типы фильтрации пользователей.""" BALANCE = 'balance' - TRAFFIC = 'traffic' - ACTIVITY = 'activity' - SPENDING = 'spending' - PURCHASES = 'purchases' CAMPAIGN = 'campaign' + POTENTIAL_CUSTOMERS = 'potential_customers' @dataclass @@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = { pagination_prefix='admin_users_balance_list', order_param='order_by_balance', ), - UserFilterType.TRAFFIC: UserFilterConfig( - fsm_state=AdminStates.viewing_user_from_traffic_list, - title='👥 Список пользователей по использованному трафику', - empty_message='📶 Пользователи с трафиком не найдены', - pagination_prefix='admin_users_traffic_list', - order_param='order_by_traffic', - ), - UserFilterType.ACTIVITY: UserFilterConfig( - fsm_state=AdminStates.viewing_user_from_last_activity_list, - title='👥 Пользователи по активности', - empty_message='🕒 Пользователи с активностью не найдены', - pagination_prefix='admin_users_activity_list', - order_param='order_by_last_activity', - ), - UserFilterType.SPENDING: UserFilterConfig( - fsm_state=AdminStates.viewing_user_from_spending_list, - title='👥 Пользователи по сумме трат', - empty_message='💳 Пользователи с тратами не найдены', - pagination_prefix='admin_users_spending_list', - order_param='order_by_total_spent', - ), - UserFilterType.PURCHASES: UserFilterConfig( - fsm_state=AdminStates.viewing_user_from_purchases_list, - title='👥 Пользователи по количеству покупок', - empty_message='🛒 Пользователи с покупками не найдены', - pagination_prefix='admin_users_purchases_list', - order_param='order_by_purchase_count', - ), UserFilterType.CAMPAIGN: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_campaign_list, title='👥 Пользователи по кампании регистрации', @@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = { pagination_prefix='admin_users_campaign_list', order_param='', # использует специальный метод ), + UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig( + fsm_state=AdminStates.viewing_user_from_potential_customers_list, + title='👥 Потенциальные клиенты', + empty_message='💰 Потенциальные клиенты не найдены', + pagination_prefix='admin_users_potential_customers_list', + order_param='', # использует специальный метод + ), } @@ -173,34 +149,6 @@ def _build_user_button_text( days_left = (user.subscription.end_date - datetime.utcnow()).days button_text += f' | 📅 {days_left}д' - elif filter_type == UserFilterType.TRAFFIC: - if user.subscription: - sub = user.subscription - used = sub.traffic_used_gb or 0.0 - if sub.traffic_limit_gb and sub.traffic_limit_gb > 0: - limit_display = f'{sub.traffic_limit_gb}' - else: - limit_display = '♾️' - traffic_display = f'{used:.1f}/{limit_display} ГБ' - else: - traffic_display = 'нет подписки' - button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}' - if user.balance_kopeks > 0: - button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' - - elif filter_type == UserFilterType.ACTIVITY: - activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно' - button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}' - - elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES): - stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {} - total_spent = stats.get('total_spent', 0) - purchases = stats.get('purchase_count', 0) - if filter_type == UserFilterType.SPENDING: - button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}' - else: - button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}' - elif filter_type == UserFilterType.CAMPAIGN: info = extra_data.get(user.id, {}) if extra_data else {} campaign_name = info.get('campaign_name') or 'Без кампании' @@ -219,18 +167,6 @@ def _build_user_button_text( button_text = f'{status_emoji} {sub_emoji} {short_name}' if user.balance_kopeks > 0: button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' - elif filter_type == UserFilterType.TRAFFIC: - if user.subscription: - sub = user.subscription - used = sub.traffic_used_gb or 0.0 - if sub.traffic_limit_gb and sub.traffic_limit_gb > 0: - limit_display = f'{sub.traffic_limit_gb}' - else: - limit_display = '♾️' - traffic_display = f'{used:.1f}/{limit_display} ГБ' - else: - traffic_display = 'нет' - button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}' else: button_text = f'{status_emoji} {short_name}' @@ -280,10 +216,6 @@ async def _show_users_list_filtered( await callback.answer() return - # Для spending/purchases нужны дополнительные данные - if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES): - extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users]) - # Формируем текст заголовка text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n' text += 'Нажмите на пользователя для управления:' @@ -576,38 +508,122 @@ async def show_users_ready_to_renew( @admin_required @error_handler -async def show_users_list_by_traffic( +async def show_potential_customers( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): - """Список пользователей, отсортированный по использованному трафику (убывание).""" - await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page) + """Показывает пользователей без активной подписки с балансом >= месячной цены.""" + await state.set_state(AdminStates.viewing_user_from_potential_customers_list) + texts = get_texts(db_user.language) + from app.config import PERIOD_PRICES -@admin_required -@error_handler -async def show_users_list_by_last_activity( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 -): - """Список пользователей, отсортированный по последней активности.""" - await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page) + monthly_price = PERIOD_PRICES.get(30, 99000) + user_service = UserService() + users_data = await user_service.get_potential_customers( + db, + min_balance_kopeks=monthly_price, + page=page, + limit=10, + ) -@admin_required -@error_handler -async def show_users_list_by_spending( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 -): - """Список пользователей, отсортированный по сумме трат (убывание).""" - await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page) + amount_text = settings.format_price(monthly_price) + header = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE', + '💰 Потенциальные клиенты', + ) + description = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC', + 'Пользователи без активной подписки с балансом {amount} или больше.', + ).format(amount=amount_text) + if not users_data['users']: + empty_text = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY', + 'Сейчас нет пользователей, которые подходят под этот фильтр.', + ) + await callback.message.edit_text( + f'{header}\n\n{description}\n\n{empty_text}', + reply_markup=get_admin_users_keyboard(db_user.language), + ) + await callback.answer() + return -@admin_required -@error_handler -async def show_users_list_by_purchases( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 -): - """Список пользователей, отсортированный по количеству покупок (убывание).""" - await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page) + text = f'{header}\n\n{description}\n\n' + text += 'Нажмите на пользователя для управления:' + + keyboard = [] + + for user in users_data['users']: + subscription = user.subscription + status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫' + subscription_emoji = '❌' + + if subscription: + if subscription.is_trial: + subscription_emoji = '🎁' + elif subscription.is_active: + subscription_emoji = '💎' + else: + subscription_emoji = '⏰' + + button_text = ( + f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}' + ) + + if len(button_text) > 60: + short_name = user.full_name + if len(short_name) > 20: + short_name = short_name[:17] + '...' + button_text = ( + f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}' + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f'admin_user_manage_{user.id}', + ) + ] + ) + + if users_data['total_pages'] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data['current_page'], + users_data['total_pages'], + 'admin_users_potential_customers_list', + 'admin_users_potential_customers_filter', + db_user.language, + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend( + [ + [ + types.InlineKeyboardButton( + text='🔍 Поиск', + callback_data='admin_users_search', + ), + types.InlineKeyboardButton( + text='📊 Статистика', + callback_data='admin_users_stats', + ), + ], + [ + types.InlineKeyboardButton( + text='⬅️ Назад', + callback_data='admin_users', + ) + ], + ] + ) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() @admin_required @@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination( await show_users_list_by_balance(callback, db_user, db, state, 1) -@admin_required -@error_handler -async def handle_users_traffic_list_pagination( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext -): - try: - callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) - await show_users_list_by_traffic(callback, db_user, db, state, page) - except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') - await show_users_list_by_traffic(callback, db_user, db, state, 1) - - -@admin_required -@error_handler -async def handle_users_activity_list_pagination( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext -): - try: - callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) - await show_users_list_by_last_activity(callback, db_user, db, state, page) - except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') - await show_users_list_by_last_activity(callback, db_user, db, state, 1) - - -@admin_required -@error_handler -async def handle_users_spending_list_pagination( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext -): - try: - callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) - await show_users_list_by_spending(callback, db_user, db, state, page) - except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') - await show_users_list_by_spending(callback, db_user, db, state, 1) - - -@admin_required -@error_handler -async def handle_users_purchases_list_pagination( - callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext -): - try: - callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) - await show_users_list_by_purchases(callback, db_user, db, state, page) - except (ValueError, IndexError) as e: - logger.error(f'Ошибка парсинга номера страницы: {e}') - await show_users_list_by_purchases(callback, db_user, db, state, 1) - - @admin_required @error_handler async def handle_users_ready_to_renew_pagination( @@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination( await show_users_ready_to_renew(callback, db_user, db, state, 1) +@admin_required +@error_handler +async def handle_potential_customers_pagination( + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext +): + try: + page = int(callback.data.split('_')[-1]) + await show_potential_customers(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f'Ошибка парсинга номера страницы: {e}') + await show_potential_customers(callback, db_user, db, state, 1) + + @admin_required @error_handler async def handle_users_campaign_list_pagination( @@ -1310,18 +1283,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: current_state = await state.get_state() if current_state == AdminStates.viewing_user_from_balance_list: back_callback = 'admin_users_balance_filter' - elif current_state == AdminStates.viewing_user_from_traffic_list: - back_callback = 'admin_users_traffic_filter' - elif current_state == AdminStates.viewing_user_from_last_activity_list: - back_callback = 'admin_users_activity_filter' - elif current_state == AdminStates.viewing_user_from_spending_list: - back_callback = 'admin_users_spending_filter' - elif current_state == AdminStates.viewing_user_from_purchases_list: - back_callback = 'admin_users_purchases_filter' elif current_state == AdminStates.viewing_user_from_campaign_list: back_callback = 'admin_users_campaign_filter' elif current_state == AdminStates.viewing_user_from_ready_to_renew_list: back_callback = 'admin_users_ready_to_renew_filter' + elif current_state == AdminStates.viewing_user_from_potential_customers_list: + back_callback = 'admin_users_potential_customers_filter' # Базовая клавиатура профиля kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) @@ -5483,26 +5450,14 @@ def register_handlers(dp: Dispatcher): handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_') ) - dp.callback_query.register( - handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_') - ) - - dp.callback_query.register( - handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_') - ) - - dp.callback_query.register( - handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_') - ) - - dp.callback_query.register( - handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_') - ) - dp.callback_query.register( handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_') ) + dp.callback_query.register( + handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_') + ) + dp.callback_query.register( handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_') ) @@ -5659,14 +5614,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter') - dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter') - - dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter') - - dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter') - - dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter') - dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter') + dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter') + dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter') diff --git a/app/handlers/start.py b/app/handlers/start.py index 3d941b77..f3189fde 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -309,24 +309,27 @@ 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) - 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 + + # ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent + # pending_start_payload обрабатывается отдельно ниже + 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 +1170,12 @@ 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 +1463,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: @@ -1725,6 +1738,7 @@ async def required_sub_channel_check( 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, @@ -1780,16 +1794,31 @@ async def required_sub_channel_check( 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 + logger.info( + '📣 CHANNEL CHECK: Кампания %s восстановлена из payload', + campaign.id, + ) + else: + state_data['referral_code'] = pending_start_payload + 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")}', ) await state.set_data(state_data) @@ -1829,6 +1858,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) @@ -1911,6 +1946,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/keyboards/admin.py b/app/keyboards/admin.py index 4a1a17d9..e0295c33 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -342,36 +342,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark callback_data='admin_users_balance_filter', ) ], - [ - InlineKeyboardButton( - text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'), - callback_data='admin_users_traffic_filter', - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'), - callback_data='admin_users_activity_filter', - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'), - callback_data='admin_users_spending_filter', - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'), - callback_data='admin_users_purchases_filter', - ) - ], [ InlineKeyboardButton( text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'), callback_data='admin_users_ready_to_renew_filter', ) ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'), + callback_data='admin_users_potential_customers_filter', + ) + ], [ InlineKeyboardButton( text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'), diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index d73ca6ff..491254ef 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -807,6 +807,10 @@ "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению", "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.", "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.", + "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты", + "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты", + "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.", + "ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.", "ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании", "ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок", "ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат", diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index b4b252c4..5d780c11 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -396,10 +396,10 @@ class ChannelCheckerMiddleware(BaseMiddleware): if not user or not user.subscription: return - # НЕ реактивируем подписку заблокированных пользователей + # НЕ реактивируем подписку заблокированным пользователям if user.status == UserStatus.BLOCKED.value: logger.info( - '🚫 Пропуск реактивации подписки для заблокированного пользователя %s', + '🚫 Пропуск реактивации для заблокированного пользователя %s', telegram_id, ) return diff --git a/app/services/kassa_ai_service.py b/app/services/kassa_ai_service.py index 007c1328..b219791f 100644 --- a/app/services/kassa_ai_service.py +++ b/app/services/kassa_ai_service.py @@ -246,7 +246,7 @@ class KassaAiService: } params['signature'] = self._generate_hmac_signature(params) - logger.debug(f'KassaAI get_order_status: order_id={order_id}') + logger.info(f'KassaAI get_order_status: order_id={order_id}') try: async with ( @@ -259,7 +259,7 @@ class KassaAiService: ) as response, ): text = await response.text() - logger.debug(f'KassaAI get_order_status response: {text}') + logger.info(f'KassaAI get_order_status response: {text}') return await response.json() except aiohttp.ClientError as e: logger.exception(f'KassaAI API connection error: {e}') diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 90380bcc..1cc3818d 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -69,8 +69,13 @@ class KassaAiPaymentMixin: ) return None - # Генерируем уникальный order_id - order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}' + # Получаем telegram_id пользователя для order_id + payment_module = import_module('app.services.payment_service') + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + + # Генерируем уникальный order_id с telegram_id для удобного поиска + order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}' amount_rubles = amount_kopeks / 100 currency = settings.KASSA_AI_CURRENCY @@ -487,3 +492,91 @@ class KassaAiPaymentMixin: except Exception as e: logger.exception('KassaAI: ошибка проверки статуса: %s', e) return None + + async def get_kassa_ai_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> dict[str, Any] | None: + """ + Проверяет статус платежа KassaAI по локальному ID через API. + Если платёж оплачен — автоматически начисляет баланс. + """ + logger.info('KassaAI: checking payment status for id=%s', local_payment_id) + kassa_ai_crud = import_module('app.database.crud.kassa_ai') + + payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id) + if not payment: + logger.warning('KassaAI payment not found: id=%s', local_payment_id) + return None + + if payment.is_paid: + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + + if not settings.KASSA_AI_API_KEY: + return { + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, + } + + try: + # Запрашиваем статус заказа в KassaAI (api.fk.life) + response = await kassa_ai_service.get_order_status(payment.order_id) + + # KassaAI возвращает список заказов (как Freekassa) + orders = response.get('orders', []) + target_order = None + + # Ищем наш заказ в списке + for order in orders: + order_key = str(order.get('merchant_order_id') or order.get('paymentId')) + if order_key == str(payment.order_id): + target_order = order + break + + if target_order: + # Статус 1 = Оплачен (как в Freekassa) + kai_status = int(target_order.get('status', 0)) + + if kai_status == 1: + logger.info('KassaAI payment %s confirmed via API', payment.order_id) + + callback_payload = { + 'check_source': 'api', + 'kai_order_data': target_order, + } + + # ID заказа на стороне KassaAI + kai_intid = str(target_order.get('fk_order_id') or target_order.get('id')) + + # Обновляем статус + payment = await kassa_ai_crud.update_kassa_ai_payment_status( + db=db, + payment=payment, + status='success', + is_paid=True, + kassa_ai_order_id=kai_intid, + payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None, + callback_payload=callback_payload, + ) + + # Финализируем (начисляем баланс) + await self._finalize_kassa_ai_payment( + db, + payment, + intid=kai_intid, + trigger='api_check', + ) + except Exception as e: + logger.error('Error checking KassaAI payment status: %s', e) + + return { + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, + } diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index f06f637e..87c6feb0 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -164,42 +164,78 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None: """Initialize payment method configs if they don't exist yet. Called on startup to seed defaults from env vars. + Also adds any missing methods that were added after initial setup. """ - count_result = await db.execute(select(func.count()).select_from(PaymentMethodConfig)) - count = count_result.scalar() or 0 + # Get existing method IDs + existing_result = await db.execute(select(PaymentMethodConfig.method_id)) + existing_method_ids = set(existing_result.scalars().all()) - if count > 0: - return # Already initialized + if not existing_method_ids: + # First-time initialization + logger.info('Initializing payment method configurations from env vars...') + defaults = _get_method_defaults() - logger.info('Initializing payment method configurations from env vars...') + for idx, method_id in enumerate(DEFAULT_METHOD_ORDER): + method_def = defaults.get(method_id, {}) + is_configured = method_def.get('is_configured', False) + sub_options = None + available = method_def.get('available_sub_options') + if available: + # Enable all sub-options by default + sub_options = {opt['id']: True for opt in available} + config = PaymentMethodConfig( + method_id=method_id, + sort_order=idx, + is_enabled=is_configured, + display_name=None, + sub_options=sub_options, + min_amount_kopeks=None, + max_amount_kopeks=None, + user_type_filter='all', + first_topup_filter='any', + promo_group_filter_mode='all', + ) + db.add(config) + + await db.commit() + logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).') + return + + # Add missing methods (for cases when new methods are added to code) defaults = _get_method_defaults() + missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids] - for idx, method_id in enumerate(DEFAULT_METHOD_ORDER): - method_def = defaults.get(method_id, {}) - is_configured = method_def.get('is_configured', False) - sub_options = None - available = method_def.get('available_sub_options') - if available: - # Enable all sub-options by default - sub_options = {opt['id']: True for opt in available} + if missing_methods: + logger.info(f'Adding missing payment methods: {missing_methods}') + # Get max sort_order to append new methods at the end + max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order))) + max_order = max_order_result.scalar() or 0 - config = PaymentMethodConfig( - method_id=method_id, - sort_order=idx, - is_enabled=is_configured, - display_name=None, - sub_options=sub_options, - min_amount_kopeks=None, - max_amount_kopeks=None, - user_type_filter='all', - first_topup_filter='any', - promo_group_filter_mode='all', - ) - db.add(config) + for idx, method_id in enumerate(missing_methods, start=max_order + 1): + method_def = defaults.get(method_id, {}) + is_configured = method_def.get('is_configured', False) + sub_options = None + available = method_def.get('available_sub_options') + if available: + sub_options = {opt['id']: True for opt in available} - await db.commit() - logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).') + config = PaymentMethodConfig( + method_id=method_id, + sort_order=idx, + is_enabled=is_configured, + display_name=None, + sub_options=sub_options, + min_amount_kopeks=None, + max_amount_kopeks=None, + user_type_filter='all', + first_topup_filter='any', + promo_group_filter_mode='all', + ) + db.add(config) + + await db.commit() + logger.info(f'Added {len(missing_methods)} missing payment method(s).') # ============ CRUD ============ diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index 0b432306..9d94fe7d 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -71,6 +71,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.PLATEGA, PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, + PaymentMethod.KASSA_AI, } ) @@ -87,6 +88,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( # WATA removed - API returns 429 "Use webhook – polling is rate-limited". # Payments are processed via webhook (wata_webhook.py). PaymentMethod.FREEKASSA, + PaymentMethod.KASSA_AI, } ) @@ -955,6 +957,9 @@ async def run_manual_check( elif method == PaymentMethod.FREEKASSA: result = await payment_service.get_freekassa_payment_status(db, local_payment_id) payment = result.get('payment') if result else None + elif method == PaymentMethod.KASSA_AI: + result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id) + payment = result.get('payment') if result else None else: logger.warning('Manual check requested for unsupported method %s', method) return None diff --git a/app/services/referral_diagnostics_service.py b/app/services/referral_diagnostics_service.py new file mode 100644 index 00000000..d7320f1f --- /dev/null +++ b/app/services/referral_diagnostics_service.py @@ -0,0 +1,1063 @@ +""" +Сервис диагностики реферальной системы по логам. + +Анализирует логи бота для выявления проблем с реферальной системой: +- Переходы по реф-ссылкам +- Сверка с БД — засчитался ли реферал +- Выявление потерянных рефералов +""" + +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, 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}, реф-кликов={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({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({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({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}: {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}: {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/services/user_service.py b/app/services/user_service.py index bce7ac74..db64ff95 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from typing import Any from aiogram import Bot, types -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -345,6 +345,72 @@ class UserService: 'total_count': 0, } + async def get_potential_customers( + self, + db: AsyncSession, + min_balance_kopeks: int, + page: int = 1, + limit: int = 10, + ) -> dict[str, Any]: + """Возвращает пользователей без активной подписки с достаточным балансом.""" + try: + offset = (page - 1) * limit + + # Фильтры: нет активной подписки И баланс >= порога + base_filters = [ + User.balance_kopeks >= min_balance_kopeks, + ] + + # Основной запрос с LEFT JOIN для поддержки пользователей без подписки + query = ( + select(User) + .options(selectinload(User.subscription)) + .outerjoin(Subscription, Subscription.user_id == User.id) + .where( + *base_filters, + or_( + User.subscription == None, + ~Subscription.status.in_(['active', 'trial']), + ), + ) + .order_by(User.balance_kopeks.desc(), User.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(query) + users = result.scalars().unique().all() + + # Запрос для подсчета общего количества + count_query = ( + select(func.count(User.id)) + .outerjoin(Subscription, Subscription.user_id == User.id) + .where( + *base_filters, + or_( + User.subscription == None, + ~Subscription.status.in_(['active', 'trial']), + ), + ) + ) + total_count = (await db.execute(count_query)).scalar() or 0 + total_pages = (total_count + limit - 1) // limit if total_count else 0 + + return { + 'users': users, + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, + } + + except Exception as e: + logger.error(f'Ошибка получения потенциальных клиентов: {e}') + return { + 'users': [], + 'current_page': 1, + 'total_pages': 1, + 'total_count': 0, + } + async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]: try: return await get_users_spending_stats(db, user_ids) diff --git a/app/states.py b/app/states.py index 351880d0..34794bd3 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() @@ -173,12 +180,9 @@ class AdminStates(StatesGroup): # Состояния для отслеживания источника перехода viewing_user_from_balance_list = State() - viewing_user_from_traffic_list = State() - viewing_user_from_last_activity_list = State() - viewing_user_from_spending_list = State() - viewing_user_from_purchases_list = State() viewing_user_from_campaign_list = State() viewing_user_from_ready_to_renew_list = State() + viewing_user_from_potential_customers_list = State() # Состояния для управления тарифами creating_tariff_name = State() diff --git a/tests/services/test_referral_diagnostics.py b/tests/services/test_referral_diagnostics.py new file mode 100644 index 00000000..1298d67c --- /dev/null +++ b/tests/services/test_referral_diagnostics.py @@ -0,0 +1,153 @@ +""" +Тесты для сервиса диагностики реферальной системы. +""" + +import tempfile +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +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()