Files
2026-03-26 10:34:14 +03:00

369 lines
15 KiB
Python

import json
from typing import Any
import redis.asyncio as redis
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
class UserCartService:
"""
Сервис для работы с корзиной пользователя через Redis.
Использует ленивую инициализацию Redis-клиента для graceful fallback
при недоступности Redis.
"""
def __init__(self):
self._redis_client: redis.Redis | None = None
self._initialized: bool = False
def _get_redis_client(self) -> redis.Redis | None:
"""Ленивая инициализация Redis клиента."""
if self._initialized:
return self._redis_client
try:
self._redis_client = redis.from_url(settings.REDIS_URL)
self._initialized = True
logger.debug('Redis клиент для корзины инициализирован')
except Exception as e:
logger.warning('Не удалось подключиться к Redis для корзины', error=e)
self._redis_client = None
self._initialized = True
return self._redis_client
async def save_user_cart(self, user_id: int, cart_data: dict[str, Any], ttl: int | None = None) -> bool:
"""
Сохранить корзину пользователя в Redis.
When ``cart_data`` contains a ``subscription_id``, the data is
**also** written to the per-subscription key
(``user_cart:{user_id}:sub:{subscription_id}``) so that multiple
subscriptions of the same user do not overwrite each other's carts.
The global key is still written for backward compatibility with code
that reads only the global cart (UI handlers, menu indicators, etc.).
Args:
user_id: ID пользователя
cart_data: Данные корзины (параметры подписки)
ttl: Время жизни ключа в секундах (по умолчанию из settings.CART_TTL_SECONDS)
Returns:
bool: Успешность сохранения
"""
client = self._get_redis_client()
if client is None:
logger.warning('🛒 Redis недоступен, корзина пользователя НЕ сохранена', user_id=user_id)
return False
try:
key = f'user_cart:{user_id}'
json_data = json.dumps(cart_data, ensure_ascii=False)
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
await client.setex(key, effective_ttl, json_data)
cart_mode = cart_data.get('cart_mode', 'unknown')
logger.info(
'🛒 Корзина пользователя сохранена в Redis (mode=, ttl=s)',
user_id=user_id,
cart_mode=cart_mode,
effective_ttl=effective_ttl,
)
# Dual-write to per-subscription key when subscription_id is known.
# This ensures multi-subscription users don't lose carts when a
# second subscription's cart overwrites the global key.
subscription_id = cart_data.get('subscription_id')
if subscription_id is not None:
try:
sub_id = int(subscription_id)
sub_key = self._subscription_cart_key(user_id, sub_id)
await client.setex(sub_key, effective_ttl, json_data)
logger.debug(
'Корзина также сохранена по per-subscription ключу',
user_id=user_id,
subscription_id=sub_id,
)
except (TypeError, ValueError):
pass # Non-integer subscription_id -- skip per-sub key
return True
except Exception as e:
logger.error('🛒 Ошибка сохранения корзины пользователя', user_id=user_id, error=e)
return False
async def get_user_cart(self, user_id: int) -> dict[str, Any] | None:
"""
Получить корзину пользователя из Redis.
Args:
user_id: ID пользователя
Returns:
dict: Данные корзины или None
"""
client = self._get_redis_client()
if client is None:
return None
try:
key = f'user_cart:{user_id}'
json_data = await client.get(key)
if json_data:
cart_data = json.loads(json_data)
logger.debug('Корзина пользователя загружена из Redis', user_id=user_id)
return cart_data
return None
except Exception as e:
logger.error('Ошибка получения корзины пользователя', user_id=user_id, error=e)
return None
async def delete_user_cart(self, user_id: int) -> bool:
"""
Удалить корзину пользователя из Redis.
Also deletes the corresponding per-subscription key if the global
cart contained a ``subscription_id`` (cleanup after dual-write).
Args:
user_id: ID пользователя
Returns:
bool: Успешность удаления
"""
client = self._get_redis_client()
if client is None:
return False
try:
key = f'user_cart:{user_id}'
# Read the global cart first to find associated per-subscription key
raw_data = await client.get(key)
subscription_id: int | None = None
if raw_data:
try:
data = json.loads(raw_data)
sub_id_raw = data.get('subscription_id')
if sub_id_raw is not None:
subscription_id = int(sub_id_raw)
except (json.JSONDecodeError, TypeError, ValueError):
pass
result = await client.delete(key)
# Also clean up the per-subscription key if it exists
if subscription_id is not None:
sub_key = self._subscription_cart_key(user_id, subscription_id)
await client.delete(sub_key)
if result:
logger.debug('Корзина пользователя удалена из Redis', user_id=user_id)
return bool(result)
except Exception as e:
logger.error('Ошибка удаления корзины пользователя', user_id=user_id, error=e)
return False
async def delete_global_cart_only(self, user_id: int) -> bool:
"""Delete ONLY the global ``user_cart:{user_id}`` key.
Unlike ``delete_user_cart``, this does NOT cascade into the
per-subscription key. Used when the per-subscription key was
already handled separately and we just need to remove the stale
global entry.
"""
client = self._get_redis_client()
if client is None:
return False
try:
key = f'user_cart:{user_id}'
result = await client.delete(key)
return bool(result)
except Exception as e:
logger.error('Ошибка удаления глобальной корзины пользователя', user_id=user_id, error=e)
return False
async def has_user_cart(self, user_id: int) -> bool:
"""
Проверить наличие корзины у пользователя.
Args:
user_id: ID пользователя
Returns:
bool: Наличие корзины
"""
client = self._get_redis_client()
if client is None:
logger.warning('🛒 Redis недоступен, проверка корзины пользователя невозможна', user_id=user_id)
return False
try:
key = f'user_cart:{user_id}'
exists = await client.exists(key)
result = bool(exists)
logger.info(
'🛒 Проверка корзины пользователя', user_id=user_id, value='найдена' if result else 'не найдена'
)
return result
except Exception as e:
logger.error('🛒 Ошибка проверки наличия корзины пользователя', user_id=user_id, error=e)
return False
# ---- Per-subscription cart methods (multi-tariff safe) ----
@staticmethod
def _subscription_cart_key(user_id: int, subscription_id: int) -> str:
return f'user_cart:{user_id}:sub:{subscription_id}'
async def save_subscription_cart(
self,
user_id: int,
subscription_id: int,
cart_data: dict[str, Any],
ttl: int | None = None,
) -> bool:
"""Save a per-subscription cart to Redis.
Uses key ``user_cart:{user_id}:sub:{subscription_id}`` so that
multiple subscriptions of the same user can have independent carts
without overwriting each other.
"""
client = self._get_redis_client()
if client is None:
logger.warning(
'Redis недоступен, корзина подписки НЕ сохранена',
user_id=user_id,
subscription_id=subscription_id,
)
return False
try:
key = self._subscription_cart_key(user_id, subscription_id)
json_data = json.dumps(cart_data, ensure_ascii=False)
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
await client.setex(key, effective_ttl, json_data)
cart_mode = cart_data.get('cart_mode', 'unknown')
logger.info(
'Корзина подписки сохранена в Redis',
user_id=user_id,
subscription_id=subscription_id,
cart_mode=cart_mode,
effective_ttl=effective_ttl,
)
return True
except Exception as e:
logger.error(
'Ошибка сохранения корзины подписки',
user_id=user_id,
subscription_id=subscription_id,
error=e,
)
return False
async def get_subscription_cart(self, user_id: int, subscription_id: int) -> dict[str, Any] | None:
"""Get per-subscription cart from Redis."""
client = self._get_redis_client()
if client is None:
return None
try:
key = self._subscription_cart_key(user_id, subscription_id)
json_data = await client.get(key)
if json_data:
cart_data = json.loads(json_data)
logger.debug(
'Корзина подписки загружена из Redis',
user_id=user_id,
subscription_id=subscription_id,
)
return cart_data
return None
except Exception as e:
logger.error(
'Ошибка получения корзины подписки',
user_id=user_id,
subscription_id=subscription_id,
error=e,
)
return None
async def delete_subscription_cart(self, user_id: int, subscription_id: int) -> bool:
"""Delete per-subscription cart from Redis."""
client = self._get_redis_client()
if client is None:
return False
try:
key = self._subscription_cart_key(user_id, subscription_id)
result = await client.delete(key)
if result:
logger.debug(
'Корзина подписки удалена из Redis',
user_id=user_id,
subscription_id=subscription_id,
)
return bool(result)
except Exception as e:
logger.error(
'Ошибка удаления корзины подписки',
user_id=user_id,
subscription_id=subscription_id,
error=e,
)
return False
async def get_all_subscription_carts(self, user_id: int) -> list[dict[str, Any]]:
"""Return all per-subscription carts for a given user.
Scans Redis keys matching ``user_cart:{user_id}:sub:*`` and returns
their parsed JSON payloads. Falls back to the global cart (if it
contains a ``subscription_id``) so that carts saved by the old code
path are still picked up.
"""
client = self._get_redis_client()
if client is None:
return []
results: list[dict[str, Any]] = []
prefix = f'user_cart:{user_id}:sub:'
try:
cursor: int | bytes = 0
while True:
cursor, keys = await client.scan(cursor=cursor, match=f'{prefix}*', count=50)
for raw_key in keys:
key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
raw_data = await client.get(key)
if raw_data:
try:
results.append(json.loads(raw_data))
except (json.JSONDecodeError, TypeError):
logger.warning('Невалидный JSON в корзине подписки', key=key)
if not cursor or cursor == 0 or cursor == b'0':
break
except Exception as e:
logger.error('Ошибка сканирования корзин подписок', user_id=user_id, error=e)
# Fallback: include global cart if it has subscription_id and is not
# already covered by a per-subscription key (backward compat).
try:
global_cart = await self.get_user_cart(user_id)
if global_cart and global_cart.get('subscription_id') is not None:
sub_id = global_cart['subscription_id']
already_covered = any(c.get('subscription_id') == sub_id for c in results)
if not already_covered:
results.append(global_cart)
except Exception as e:
logger.warning('Ошибка чтения глобальной корзины при сканировании', user_id=user_id, error=e)
return results
# Глобальный экземпляр сервиса (инициализация Redis отложена)
user_cart_service = UserCartService()