From 58588e37d0432b183a51324cb2de26716ba62d36 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Tue, 14 Oct 2025 09:02:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F!=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/menu.py | 17 ++ app/handlers/subscription/purchase.py | 67 +++++-- app/keyboards/inline.py | 34 +++- app/services/admin_notification_service.py | 67 +++++-- app/services/payment/cryptobot.py | 25 +++ app/services/payment/mulenpay.py | 25 +++ app/services/payment/pal24.py | 25 +++ app/services/payment/stars.py | 26 +++ app/services/payment/yookassa.py | 49 ++++- app/services/tribute_service.py | 1 + app/services/user_cart_service.py | 112 +++++++++++ docs/persistent_cart_system.md | 75 +++++++ tests/test_subscription_cart_integration.py | 210 ++++++++++++++++++++ tests/test_user_cart_service.py | 145 ++++++++++++++ 14 files changed, 841 insertions(+), 37 deletions(-) create mode 100644 app/services/user_cart_service.py create mode 100644 docs/persistent_cart_system.md create mode 100644 tests/test_subscription_cart_integration.py create mode 100644 tests/test_user_cart_service.py diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a1b6fe71..a87f602f 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import ( from app.utils.photo_message import edit_or_answer_photo from app.services.support_settings_service import SupportSettingsService from app.services.main_menu_button_service import MainMenuButtonService +from app.services.user_cart_service import user_cart_service from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, @@ -162,6 +163,13 @@ async def show_main_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + + # Проверяем наличие сохраненной корзины в Redis + try: + has_saved_cart = await user_cart_service.has_user_cart(db_user.id) + except Exception as e: + logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") + has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) is_moderator = (not is_admin) and SupportSettingsService.is_moderator( @@ -190,6 +198,7 @@ async def show_main_menu( balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине custom_buttons=custom_buttons, ), parse_mode="HTML", @@ -893,6 +902,13 @@ async def handle_back_to_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + + # Проверяем наличие сохраненной корзины в Redis + try: + has_saved_cart = await user_cart_service.has_user_cart(db_user.id) + except Exception as e: + logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") + has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) is_moderator = (not is_admin) and SupportSettingsService.is_moderator( @@ -921,6 +937,7 @@ async def handle_back_to_menu( balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, show_resume_checkout=show_resume_checkout, + has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине custom_buttons=custom_buttons, ), parse_mode="HTML", diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index c24a5d6f..67771981 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -46,6 +46,7 @@ from app.keyboards.inline import ( get_subscription_confirm_keyboard_with_cart, get_insufficient_balance_keyboard_with_cart ) +from app.services.user_cart_service import user_cart_service from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveService @@ -563,13 +564,16 @@ async def save_cart_and_redirect_to_topup( texts = get_texts(db_user.language) data = await state.get_data() - await state.set_state(SubscriptionStates.cart_saved_for_topup) - await state.update_data({ + # Сохраняем данные корзины в Redis + cart_data = { **data, 'saved_cart': True, 'missing_amount': missing_amount, - 'return_to_cart': True - }) + 'return_to_cart': True, + 'user_id': db_user.id + } + + await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( f"💰 Недостаточно средств для оформления подписки\n\n" @@ -591,14 +595,15 @@ async def return_to_saved_cart( db_user: User, db: AsyncSession ): - data = await state.get_data() - texts = get_texts(db_user.language) - - if not data.get('saved_cart'): + # Получаем данные корзины из Redis + cart_data = await user_cart_service.get_user_cart(db_user.id) + + if not cart_data: await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True) return - total_price = data.get('total_price', 0) + texts = get_texts(db_user.language) + total_price = cart_data.get('total_price', 0) if db_user.balance_kopeks < total_price: missing_amount = total_price - db_user.balance_kopeks @@ -617,35 +622,38 @@ async def return_to_saved_cart( countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) + months_in_period = calculate_months_from_days(cart_data['period_days']) + period_display = format_period_description(cart_data['period_days'], db_user.language) for country in countries: - if country['uuid'] in data['countries']: + if country['uuid'] in cart_data['countries']: selected_countries_names.append(country['name']) if settings.is_traffic_fixed(): - traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ" + traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ" else: - traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ" + traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ" summary_text = ( "🛒 Восстановленная корзина\n\n" f"📅 Период: {period_display}\n" f"📊 Трафик: {traffic_display}\n" f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" + f"📱 Устройства: {cart_data['devices']}\n\n" f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" "Подтверждаете покупку?" ) + # Устанавливаем данные в FSM для продолжения процесса + await state.set_data(cart_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language), parse_mode="HTML" ) - await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer("✅ Корзина восстановлена!") async def handle_extend_subscription( @@ -964,11 +972,24 @@ async def confirm_extend_subscription( missing=texts.format_price(missing_kopeks), ) + # Подготовим данные для сохранения в корзину + cart_data = { + 'period_days': days, + 'total_price': price, + 'user_id': db_user.id, + 'saved_cart': True, + 'missing_amount': missing_kopeks, + 'return_to_cart': True + } + + await user_cart_service.save_user_cart(db_user.id, cart_data) + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, amount_kopeks=missing_kopeks, + has_saved_cart=True # Указываем, что есть сохраненная корзина ), parse_mode="HTML", ) @@ -1481,12 +1502,24 @@ async def confirm_purchase( missing=texts.format_price(missing_kopeks), ) + # Сохраняем данные корзины в Redis перед переходом к пополнению + cart_data = { + **data, + 'saved_cart': True, + 'missing_amount': missing_kopeks, + 'return_to_cart': True, + 'user_id': db_user.id + } + + await user_cart_service.save_user_cart(db_user.id, cart_data) + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, amount_kopeks=missing_kopeks, + has_saved_cart=True # Указываем, что есть сохраненная корзина ), parse_mode="HTML", ) @@ -1937,7 +1970,9 @@ async def clear_saved_cart( db_user: User, db: AsyncSession ): + # Очищаем как FSM, так и Redis await state.clear() + await user_cart_service.delete_user_cart(db_user.id) from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 62a1fd5f..3402415e 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -231,6 +231,7 @@ def get_main_menu_keyboard( balance_kopeks: int = 0, subscription=None, show_resume_checkout: bool = False, + has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине *, is_moderator: bool = False, custom_buttons: Optional[list[InlineKeyboardButton]] = None, @@ -343,7 +344,7 @@ def get_main_menu_keyboard( else: keyboard.append([subscription_buttons[0]]) - if show_resume_checkout: + if show_resume_checkout or has_saved_cart: keyboard.append([ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, @@ -630,6 +631,7 @@ def get_insufficient_balance_keyboard( language: str = DEFAULT_LANGUAGE, resume_callback: str | None = None, amount_kopeks: int | None = None, + has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -650,14 +652,23 @@ def get_insufficient_balance_keyboard( ) back_row_index = len(keyboard.inline_keyboard) - 1 - if resume_callback: + # Если есть сохраненная корзина, добавляем кнопку возврата к оформлению + if has_saved_cart: + return_row = [ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ] + insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) + keyboard.inline_keyboard.insert(insert_index, return_row) + elif resume_callback: return_row = [ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data=resume_callback, ) ] - insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) keyboard.inline_keyboard.insert(insert_index, return_row) @@ -763,6 +774,7 @@ def get_payment_methods_keyboard_with_cart( language: str = "ru", amount_kopeks: int = 0, ) -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = get_payment_methods_keyboard(amount_kopeks, language) # Добавляем кнопку "Очистить корзину" @@ -773,9 +785,18 @@ def get_payment_methods_keyboard_with_cart( ) ]) + # Добавляем кнопку возврата к оформлению подписки + keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад" + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout" + ) + ]) + return keyboard def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text="✅ Подтвердить покупку", @@ -786,8 +807,8 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK callback_data="clear_saved_cart" )], [InlineKeyboardButton( - text="🔙 Назад", - callback_data="back_to_menu" + text=texts.BACK, + callback_data="subscription_config_back" # Изменили на возврат к настройке )] ]) @@ -795,11 +816,14 @@ def get_insufficient_balance_keyboard_with_cart( language: str = "ru", amount_kopeks: int = 0, ) -> InlineKeyboardMarkup: + # Используем обновленную версию с флагом has_saved_cart=True keyboard = get_insufficient_balance_keyboard( language, amount_kopeks=amount_kopeks, + has_saved_cart=True, ) + # Добавляем кнопку очистки корзины в начало keyboard.inline_keyboard.insert( 0, [ diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 65a728ae..b53ba141 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -408,6 +408,16 @@ class AdminNotificationService: return refreshed_user, refreshed_transaction, subscription, promo_group + def _is_lazy_loading_error(self, error: Exception) -> bool: + message = str(error).lower() + return ( + isinstance(error, MissingGreenlet) + or "greenlet_spawn" in message + or "await_only" in message + or "missinggreenlet" in message + ) + + async def send_balance_topup_notification( self, user: User, @@ -423,7 +433,10 @@ class AdminNotificationService: if not self._is_enabled(): return False + logger.info("Начинаем отправку уведомления о пополнении баланса") + try: + logger.info("Пытаемся создать сообщение уведомления") message = self._build_balance_topup_message( user, transaction, @@ -433,21 +446,49 @@ class AdminNotificationService: subscription=subscription, promo_group=promo_group, ) - except MissingGreenlet as missing_greenlet: - if db is None: + logger.info("Сообщение уведомления создано успешно") + except Exception as error: + logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}") + if not self._is_lazy_loading_error(error): logger.error( - "Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s", - missing_greenlet, + "Ошибка подготовки уведомления о пополнении: %s", + error, + exc_info=True, ) return False + if db is None: + logger.error( + "Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s", + error, + exc_info=True, + ) + return False + + logger.warning( + "Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s", + error, + ) + try: + logger.info("Пытаемся перезагрузить данные для уведомления") ( user, transaction, subscription, promo_group, ) = await self._reload_topup_notification_entities(db, user, transaction) + logger.info("Данные успешно перезагружены") + except Exception as reload_error: + logger.error( + "Ошибка повторной загрузки данных для уведомления о пополнении: %s", + reload_error, + exc_info=True, + ) + return False + + try: + logger.info("Пытаемся создать сообщение после перезагрузки данных") message = self._build_balance_topup_message( user, transaction, @@ -457,24 +498,22 @@ class AdminNotificationService: subscription=subscription, promo_group=promo_group, ) - except Exception as reload_error: + logger.info("Сообщение успешно создано после перезагрузки данных") + except Exception as rebuild_error: logger.error( - "Ошибка повторной загрузки данных для уведомления о пополнении: %s", - reload_error, + "Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s", + rebuild_error, exc_info=True, ) return False - except Exception as e: - logger.error( - f"Ошибка подготовки уведомления о пополнении: {e}", - exc_info=True, - ) - return False try: return await self._send_message(message) except Exception as e: - logger.error(f"Ошибка отправки уведомления о пополнении: {e}") + logger.error( + f"Ошибка отправки уведомления о пополнении: {e}", + exc_info=True, + ) return False async def send_subscription_extension_notification( diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 69d1eb60..a115dece 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -290,6 +290,31 @@ class CryptoBotPaymentMixin: error, ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + try: + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление о возможности вернуться к оформлению + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n" + "Нажмите \"Вернуться к оформлению подписки\" в главном меню, " + "чтобы продолжить с теми же параметрами." + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=cart_message + ) + logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}") + except Exception as e: + logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + return True except Exception as error: diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 7b1bd286..46922475 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -286,6 +286,31 @@ class MulenPayPaymentMixin: error, ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + try: + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление о возможности вернуться к оформлению + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n" + "Нажмите \"Вернуться к оформлению подписки\" в главном меню, " + "чтобы продолжить с теми же параметрами." + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=cart_message + ) + logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}") + except Exception as e: + logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + logger.info( "✅ Обработан MulenPay платеж %s для пользователя %s", payment.uuid, diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 18a584c0..fb63a649 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -353,6 +353,31 @@ class Pal24PaymentMixin: error, ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + try: + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление о возможности вернуться к оформлению + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n" + "Нажмите \"Вернуться к оформлению подписки\" в главном меню, " + "чтобы продолжить с теми же параметрами." + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=cart_message + ) + logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}") + except Exception as e: + logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + logger.info( "✅ Обработан Pal24 платеж %s для пользователя %s", payment.bill_id, diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 7822e86d..cb565ec9 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -206,6 +206,7 @@ class TelegramStarsMixin: logger.error( "Ошибка отправки уведомления о пополнении Stars: %s", error, + exc_info=True ) if getattr(self, "bot", None): @@ -236,6 +237,31 @@ class TelegramStarsMixin: error, ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + try: + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление о возможности вернуться к оформлению + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n" + "Нажмите \"Вернуться к оформлению подписки\" в главном меню, " + "чтобы продолжить с теми же параметрами." + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=cart_message + ) + logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}") + except Exception as e: + logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + logger.info( "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s", user_id, diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 96c5e148..3a6e3774 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -268,6 +268,7 @@ class YooKassaPaymentMixin: await db.refresh(user) + # Отправляем уведомления админам if getattr(self, "bot", None): try: from app.services.admin_notification_service import ( @@ -285,26 +286,70 @@ class YooKassaPaymentMixin: promo_group=promo_group, db=db, ) + logger.info("Уведомление админам о пополнении отправлено успешно") except Exception as error: logger.error( "Ошибка отправки уведомления админам о YooKassa пополнении: %s", error, + exc_info=True # Добавляем полный стек вызовов для отладки ) + # Отправляем уведомление пользователю if getattr(self, "bot", None): try: + # Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой await self._send_payment_success_notification( user.telegram_id, payment.amount_kopeks, - user=user, + user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам db=db, payment_method_title="Банковская карта (YooKassa)", ) + logger.info("Уведомление пользователю о платеже отправлено успешно") except Exception as error: logger.error( - "Ошибка отправки уведомления о платеже: %s", error + "Ошибка отправки уведомления о платеже: %s", + error, + exc_info=True # Добавляем полный стек вызовов для отладки ) + # Проверяем наличие сохраненной корзины для возврата к оформлению подписки + # ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях + logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}") + from app.services.user_cart_service import user_cart_service + try: + has_saved_cart = await user_cart_service.has_user_cart(user.id) + logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}") + if has_saved_cart and getattr(self, "bot", None): + # Если у пользователя есть сохраненная корзина, + # отправляем ему уведомление о возможности вернуться к оформлению + from app.localization.texts import get_texts + + texts = get_texts(user.language) + cart_message = texts.t( + "BALANCE_TOPUP_CART_REMINDER", + "💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n" + "Нажмите \"Вернуться к оформлению подписки\" в главном меню, " + "чтобы продолжить с теми же параметрами." + ) + + await self.bot.send_message( + chat_id=user.telegram_id, + text=cart_message + ) + logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}") + else: + logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен") + except Exception as e: + logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + + logger.info( + "Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽", + payment.yookassa_payment_id, + payment.user_id, + payment.amount_kopeks / 100, + ) + logger.info( "Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽", payment.yookassa_payment_id, diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 3298fc57..22214279 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -167,6 +167,7 @@ class TributeService: referrer_info=referrer_info, subscription=subscription, promo_group=promo_group, + db=session, ) except Exception as e: logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") diff --git a/app/services/user_cart_service.py b/app/services/user_cart_service.py new file mode 100644 index 00000000..9024d98a --- /dev/null +++ b/app/services/user_cart_service.py @@ -0,0 +1,112 @@ +import json +import logging +from typing import Optional, Dict, Any +from datetime import timedelta + +import redis.asyncio as redis + +from app.config import settings + +logger = logging.getLogger(__name__) + +class UserCartService: + """ + Сервис для работы с корзиной пользователя через Redis + """ + + def __init__(self): + self.redis_client = None + self._setup_redis() + + def _setup_redis(self): + """Инициализация Redis клиента""" + try: + self.redis_client = redis.from_url(settings.REDIS_URL) + except Exception as e: + logger.error(f"Ошибка подключения к Redis: {e}") + raise + + async def save_user_cart(self, user_id: int, cart_data: Dict[str, Any], ttl: int = 3600) -> bool: + """ + Сохранить корзину пользователя в Redis + + Args: + user_id: ID пользователя + cart_data: Данные корзины (параметры подписки) + ttl: Время жизни ключа в секундах (по умолчанию 1 час) + + Returns: + bool: Успешность сохранения + """ + try: + key = f"user_cart:{user_id}" + json_data = json.dumps(cart_data, ensure_ascii=False) + await self.redis_client.setex(key, ttl, json_data) + logger.info(f"Корзина пользователя {user_id} сохранена в Redis") + return True + except Exception as e: + logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}") + return False + + async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]: + """ + Получить корзину пользователя из Redis + + Args: + user_id: ID пользователя + + Returns: + dict: Данные корзины или None + """ + try: + key = f"user_cart:{user_id}" + json_data = await self.redis_client.get(key) + if json_data: + cart_data = json.loads(json_data) + logger.info(f"Корзина пользователя {user_id} загружена из Redis") + return cart_data + return None + except Exception as e: + logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}") + return None + + async def delete_user_cart(self, user_id: int) -> bool: + """ + Удалить корзину пользователя из Redis + + Args: + user_id: ID пользователя + + Returns: + bool: Успешность удаления + """ + try: + key = f"user_cart:{user_id}" + result = await self.redis_client.delete(key) + if result: + logger.info(f"Корзина пользователя {user_id} удалена из Redis") + return bool(result) + except Exception as e: + logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}") + return False + + async def has_user_cart(self, user_id: int) -> bool: + """ + Проверить наличие корзины у пользователя + + Args: + user_id: ID пользователя + + Returns: + bool: Наличие корзины + """ + try: + key = f"user_cart:{user_id}" + exists = await self.redis_client.exists(key) + return bool(exists) + except Exception as e: + logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}") + return False + +# Глобальный экземпляр сервиса +user_cart_service = UserCartService() \ No newline at end of file diff --git a/docs/persistent_cart_system.md b/docs/persistent_cart_system.md new file mode 100644 index 00000000..9d4f6b1a --- /dev/null +++ b/docs/persistent_cart_system.md @@ -0,0 +1,75 @@ +# Сквозная корзина (Persistent Cart) для Remnawave Bedolaga Telegram Bot + +## Общее описание + +Система сквозной корзины позволяет пользователям продолжить оформление подписки после пополнения баланса, не теряя уже выбранные параметры (период, трафик, серверы, устройства и т.д.). + +## Архитектура + +### 1. Сервис корзины (UserCartService) + +Расположение: `app/services/user_cart_service.py` + +Использует Redis для хранения данных корзины между сессиями пользователя. + +#### Основные методы: +- `save_user_cart(user_id, cart_data, ttl)` - сохраняет корзину пользователя +- `get_user_cart(user_id)` - возвращает данные корзины пользователя +- `delete_user_cart(user_id)` - удаляет корзину пользователя +- `has_user_cart(user_id)` - проверяет наличие корзины у пользователя + +### 2. Обновленные обработчики подписки + +Расположение: `app/handlers/subscription/purchase.py` + +#### Основные функции: +- `save_cart_and_redirect_to_topup` - сохраняет текущую корзину в Redis при недостатке средств и перенаправляет к пополнению +- `return_to_saved_cart` - восстанавливает параметры подписки из Redis и продолжает процесс оформления +- `clear_saved_cart` - очищает сохраненную корзину + +### 3. Обновленные клавиатуры + +Расположение: `app/keyboards/inline.py` + +#### Основные изменения: +- `get_insufficient_balance_keyboard` - добавлена поддержка флага `has_saved_cart` для отображения кнопки возврата к оформлению +- `get_insufficient_balance_keyboard_with_cart` - обновлена для использования флага `has_saved_cart` +- `get_main_menu_keyboard` - добавлен параметр `has_saved_cart` для отображения кнопки возврата к оформлению + +### 4. Интеграция с главным меню + +Расположение: `app/handlers/menu.py` + +Функция `show_main_menu` теперь проверяет наличие сохраненной корзины и отображает соответствующую кнопку. + +## Использование + +### Сохранение корзины + +Когда пользователь не может завершить покупку из-за недостатка средств, его параметры автоматически сохраняются в Redis с TTL 1 час. + +### Восстановление корзины + +При пополнении баланса пользователь может вернуться к оформлению подписки, нажав на кнопку "Вернуться к оформлению подписки" или через главное меню, если у него есть сохраненная корзина. + +### Очистка корзины + +Корзина автоматически очищается после успешного оформления подписки или по запросу пользователя. + +## Тестирование + +Тесты расположены в: +- `tests/test_user_cart_service.py` - модульные тесты сервиса корзины +- `tests/test_subscription_cart_integration.py` - интеграционные тесты + +## Безопасность + +- Данные корзины хранятся в Redis с ограниченным временем жизни (TTL) +- Используется идентификатор пользователя для изоляции данных +- Корзина автоматически очищается после успешной покупки + +## Масштабируемость + +- Использование Redis позволяет системе масштабироваться на несколько инстансов бота +- TTL автоматически очищает старые данные +- Нагрузка на Redis минимальна благодаря короткому времени хранения данных \ No newline at end of file diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py new file mode 100644 index 00000000..245de491 --- /dev/null +++ b/tests/test_subscription_cart_integration.py @@ -0,0 +1,210 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, User as TgUser, Message +from sqlalchemy.ext.asyncio import AsyncSession +from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart +from app.database.models import User, Subscription + +@pytest.fixture +def mock_callback_query(): + callback = AsyncMock(spec=CallbackQuery) + callback.message = AsyncMock(spec=Message) + callback.message.edit_text = AsyncMock() + callback.answer = AsyncMock() + callback.data = "subscription_confirm" + return callback + +@pytest.fixture +def mock_user(): + user = AsyncMock(spec=User) + user.id = 12345 + user.telegram_id = 12345 + user.language = "ru" + user.balance_kopeks = 10000 + user.subscription = None + user.has_had_paid_subscription = False + return user + +@pytest.fixture +def mock_db(): + db = AsyncMock(spec=AsyncSession) + return db + +@pytest.fixture +def mock_state(): + state = AsyncMock(spec=FSMContext) + state.get_data = AsyncMock(return_value={ + 'period_days': 30, + 'countries': ['ru'], + 'devices': 2, + 'traffic_gb': 10, + 'total_price': 50000 + }) + state.set_data = AsyncMock() + state.update_data = AsyncMock() + state.set_state = AsyncMock() + state.clear = AsyncMock() + return state + +@pytest.mark.asyncio +async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db): + """Тест сохранения корзины и перенаправления к пополнению""" + # Мокаем все зависимости + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, \ + patch('app.localization.texts.get_texts') as mock_get_texts: + + # Подготовим моки + mock_cart_service.save_user_cart = AsyncMock(return_value=True) + mock_keyboard = AsyncMock() + mock_keyboard_func.return_value = mock_keyboard + + # Подготовим тексты + mock_texts = AsyncMock() + mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_get_texts.return_value = mock_texts + + missing_amount = 40000 # 50000 - 10000 = 40000 + + # Вызываем функцию + await save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, missing_amount) + + # Проверяем, что данные были сохранены в корзину + mock_cart_service.save_user_cart.assert_called_once() + args, kwargs = mock_cart_service.save_user_cart.call_args + saved_user_id, saved_cart_data = args + + assert saved_user_id == mock_user.id + assert saved_cart_data['period_days'] == 30 + assert saved_cart_data['countries'] == ['ru'] + assert saved_cart_data['devices'] == 2 + assert saved_cart_data['traffic_gb'] == 10 + assert saved_cart_data['total_price'] == 50000 + assert saved_cart_data['saved_cart'] is True + assert saved_cart_data['missing_amount'] == missing_amount + assert saved_cart_data['return_to_cart'] is True + assert saved_cart_data['user_id'] == mock_user.id + + # Проверяем, что сообщение было отредактировано + mock_callback_query.message.edit_text.assert_called_once() + + # В этой функции нет вызова callback.answer() + # mock_callback_query.answer не должен быть вызван + mock_callback_query.answer.assert_not_called() + +@pytest.mark.asyncio +async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db): + """Тест возврата к сохраненной корзине с достаточным балансом""" + # Подготовим данные корзины + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 20, + 'total_price': 30000, # Меньше, чем баланс пользователя (50000) + 'saved_cart': True, + 'user_id': mock_user.id + } + + # Мокаем все зависимости + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ + patch('app.localization.texts.get_texts') as mock_get_texts, \ + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func: + + # Подготовим моки + mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] + mock_format_period.return_value = "30 дней" + mock_keyboard = AsyncMock() + mock_keyboard_func.return_value = mock_keyboard + + # Подготовим тексты + mock_texts = AsyncMock() + mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_get_texts.return_value = mock_texts + + # Увеличиваем баланс пользователя, чтобы его хватило + mock_user.balance_kopeks = 50000 + + # Вызываем функцию + await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) + + # Проверяем, что данные были загружены из корзины и установлены в FSM + mock_state.set_data.assert_called_once_with(cart_data) + + # Проверяем, что сообщение было отредактировано + mock_callback_query.message.edit_text.assert_called_once() + + # В успешном сценарии вызывается callback.answer() + mock_callback_query.answer.assert_called_once() + +@pytest.mark.asyncio +async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db): + """Тест возврата к сохраненной корзине с недостаточным балансом""" + # Подготовим данные корзины + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 20, + 'total_price': 50000, # Больше, чем баланс пользователя (10000) + 'saved_cart': True, + 'user_id': mock_user.id + } + + # Мокаем все зависимости + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.localization.texts.get_texts') as mock_get_texts, \ + patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func: + + # Подготовим моки + mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_keyboard = AsyncMock() + mock_keyboard_func.return_value = mock_keyboard + + # Подготовим тексты + mock_texts = AsyncMock() + mock_texts.format_price = lambda x: f"{x/100} ₽" + mock_texts.t = lambda key, default: default + mock_get_texts.return_value = mock_texts + + # Баланс пользователя меньше стоимости подписки + mock_user.balance_kopeks = 10000 + + # Вызываем функцию + await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) + + # Проверяем, что FSM не был изменен (данные не установлены) + mock_state.set_data.assert_not_called() + + # Проверяем, что сообщение было отредактировано с сообщением о недостатке средств + mock_callback_query.message.edit_text.assert_called_once() + + # В этой функции в сценарии недостатка средств вызова callback.answer() не происходит + # (ответ отправляется через return до вызова callback.answer()) + mock_callback_query.answer.assert_not_called() + +@pytest.mark.asyncio +async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): + """Тест очистки сохраненной корзины""" + # Мокаем все зависимости + with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ + patch('app.handlers.menu.show_main_menu') as mock_show_main_menu: + + mock_cart_service.delete_user_cart = AsyncMock(return_value=True) + mock_show_main_menu.return_value = AsyncMock() + + # Вызываем функцию + await clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) + + # Проверяем, что корзина удалена из сервиса + mock_cart_service.delete_user_cart.assert_called_once_with(mock_user.id) + + # Проверяем, что FSM очищен + mock_state.clear.assert_called_once() + + # Проверяем, что вызван answer + mock_callback_query.answer.assert_called_once() \ No newline at end of file diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py new file mode 100644 index 00000000..8affbc87 --- /dev/null +++ b/tests/test_user_cart_service.py @@ -0,0 +1,145 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from app.services.user_cart_service import UserCartService +from app.config import settings + +# Мок для Redis клиента +class MockRedis: + def __init__(self): + self.storage = {} + + async def setex(self, key, ttl, value): + self.storage[key] = value + return True + + async def get(self, key): + return self.storage.get(key) + + async def delete(self, key): + if key in self.storage: + del self.storage[key] + return 1 + return 0 + + async def exists(self, key): + return 1 if key in self.storage else 0 + +@pytest.fixture +def mock_redis(): + return MockRedis() + +@pytest.fixture +def user_cart_service(mock_redis): + service = UserCartService() + service.redis_client = mock_redis + return service + +@pytest.mark.asyncio +async def test_save_user_cart(user_cart_service, mock_redis): + """Тест сохранения корзины пользователя""" + user_id = 12345 + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 10, + 'total_price': 50000 + } + + result = await user_cart_service.save_user_cart(user_id, cart_data) + + assert result is True + assert f"user_cart:{user_id}" in mock_redis.storage + assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"]) + +@pytest.mark.asyncio +async def test_get_user_cart(user_cart_service, mock_redis): + """Тест получения корзины пользователя""" + user_id = 12345 + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 10, + 'total_price': 50000 + } + + # Сохраняем данные + await user_cart_service.save_user_cart(user_id, cart_data) + + # Получаем данные + result = await user_cart_service.get_user_cart(user_id) + + assert result == cart_data + +@pytest.mark.asyncio +async def test_get_user_cart_not_found(user_cart_service): + """Тест получения несуществующей корзины пользователя""" + user_id = 99999 + + result = await user_cart_service.get_user_cart(user_id) + + assert result is None + +@pytest.mark.asyncio +async def test_delete_user_cart(user_cart_service, mock_redis): + """Тест удаления корзины пользователя""" + user_id = 12345 + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 10, + 'total_price': 50000 + } + + # Сохраняем данные + await user_cart_service.save_user_cart(user_id, cart_data) + assert f"user_cart:{user_id}" in mock_redis.storage + + # Удаляем данные + result = await user_cart_service.delete_user_cart(user_id) + + assert result is True + assert f"user_cart:{user_id}" not in mock_redis.storage + +@pytest.mark.asyncio +async def test_delete_user_cart_not_found(user_cart_service): + """Тест удаления несуществующей корзины пользователя""" + user_id = 99999 + + result = await user_cart_service.delete_user_cart(user_id) + + assert result is False + +@pytest.mark.asyncio +async def test_has_user_cart(user_cart_service, mock_redis): + """Тест проверки наличия корзины пользователя""" + user_id = 12345 + cart_data = { + 'period_days': 30, + 'countries': ['ru', 'us'], + 'devices': 3, + 'traffic_gb': 10, + 'total_price': 50000 + } + + # Проверяем, что корзины нет + result = await user_cart_service.has_user_cart(user_id) + assert result is False + + # Сохраняем данные + await user_cart_service.save_user_cart(user_id, cart_data) + + # Проверяем, что корзина есть + result = await user_cart_service.has_user_cart(user_id) + assert result is True + +@pytest.mark.asyncio +async def test_has_user_cart_not_found(user_cart_service): + """Тест проверки отсутствия корзины пользователя""" + user_id = 99999 + + result = await user_cart_service.has_user_cart(user_id) + + assert result is False \ No newline at end of file