Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса.

This commit is contained in:
gy9vin
2025-10-14 09:02:16 +03:00
parent badf44a9e7
commit 58588e37d0
14 changed files with 841 additions and 37 deletions
+17
View File
@@ -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",
+51 -16
View File
@@ -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)
+29 -5
View File
@@ -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,
[
+53 -14
View File
@@ -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(
+25
View File
@@ -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:
+25
View File
@@ -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,
+25
View File
@@ -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,
+26
View File
@@ -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,
+47 -2
View File
@@ -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,
+1
View File
@@ -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}")
+112
View File
@@ -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()
+75
View File
@@ -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 минимальна благодаря короткому времени хранения данных
+210
View File
@@ -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()
+145
View File
@@ -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