Files
remnawave-bedolaga-telegram…/app/utils/cache.py
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

345 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import timedelta
from typing import Any
import redis.asyncio as redis
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
class CacheService:
def __init__(self):
self.redis_client: redis.Redis | None = None
self._connected = False
async def connect(self):
try:
self.redis_client = redis.from_url(settings.REDIS_URL)
await self.redis_client.ping()
self._connected = True
logger.info('✅ Подключение к Redis кешу установлено')
except Exception as e:
logger.warning('⚠️ Не удалось подключиться к Redis', error=e)
self._connected = False
async def disconnect(self):
if self.redis_client:
await self.redis_client.close()
self._connected = False
async def get(self, key: str) -> Any | None:
if not self._connected:
return None
try:
value = await self.redis_client.get(key)
if value:
return json.loads(value)
return None
except Exception as e:
logger.error('Ошибка получения из кеша', key=key, error=e)
return None
async def set(self, key: str, value: Any, expire: int | timedelta = None) -> bool:
if not self._connected:
return False
try:
serialized_value = json.dumps(value, default=str)
if isinstance(expire, timedelta):
expire = int(expire.total_seconds())
await self.redis_client.set(key, serialized_value, ex=expire)
return True
except Exception as e:
logger.error('Ошибка записи в кеш', key=key, error=e)
return False
async def setnx(self, key: str, value: Any, expire: int | timedelta = None) -> bool:
"""Атомарная операция SET IF NOT EXISTS.
Устанавливает значение только если ключ не существует.
Возвращает True если значение было установлено, False если ключ уже существовал.
"""
if not self._connected:
return False
try:
serialized_value = json.dumps(value, default=str)
if isinstance(expire, timedelta):
expire = int(expire.total_seconds())
# SET с NX возвращает True если установлено, None если ключ существует
result = await self.redis_client.set(key, serialized_value, ex=expire, nx=True)
return result is True
except Exception as e:
logger.error('Ошибка setnx в кеш', key=key, error=e)
return False
async def delete(self, key: str) -> bool:
if not self._connected:
return False
try:
deleted = await self.redis_client.delete(key)
return deleted > 0
except Exception as e:
logger.error('Ошибка удаления из кеша', key=key, error=e)
return False
async def delete_pattern(self, pattern: str) -> int:
if not self._connected:
return 0
try:
keys = await self.redis_client.keys(pattern)
if not keys:
return 0
deleted = await self.redis_client.delete(*keys)
return int(deleted)
except Exception as e:
logger.error('Ошибка удаления ключей по шаблону', pattern=pattern, error=e)
return 0
async def exists(self, key: str) -> bool:
if not self._connected:
return False
try:
return await self.redis_client.exists(key)
except Exception as e:
logger.error('Ошибка проверки существования в кеше', key=key, error=e)
return False
async def expire(self, key: str, seconds: int) -> bool:
if not self._connected:
return False
try:
return await self.redis_client.expire(key, seconds)
except Exception as e:
logger.error('Ошибка установки TTL для', key=key, error=e)
return False
async def get_keys(self, pattern: str = '*') -> list:
if not self._connected:
return []
try:
keys = await self.redis_client.keys(pattern)
return [key.decode() if isinstance(key, bytes) else key for key in keys]
except Exception as e:
logger.error('Ошибка получения ключей по паттерну', pattern=pattern, error=e)
return []
async def flush_all(self) -> bool:
if not self._connected:
return False
try:
await self.redis_client.flushall()
logger.info('🗑️ Кеш полностью очищен')
return True
except Exception as e:
logger.error('Ошибка очистки кеша', error=e)
return False
async def increment(self, key: str, amount: int = 1) -> int | None:
if not self._connected:
return None
try:
return await self.redis_client.incrby(key, amount)
except Exception as e:
logger.error('Ошибка инкремента', key=key, error=e)
return None
async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool:
if not self._connected:
return False
try:
await self.redis_client.hset(name, mapping=mapping)
if expire:
await self.redis_client.expire(name, expire)
return True
except Exception as e:
logger.error('Ошибка записи хеша', name=name, error=e)
return False
async def get_hash(self, name: str, key: str = None) -> dict | str | None:
if not self._connected:
return None
try:
if key:
value = await self.redis_client.hget(name, key)
return value.decode() if value else None
hash_data = await self.redis_client.hgetall(name)
return {k.decode(): v.decode() for k, v in hash_data.items()}
except Exception as e:
logger.error('Ошибка получения хеша', name=name, error=e)
return None
async def lpush(self, key: str, value: Any) -> bool:
"""Добавить элемент в начало списка (очереди)."""
if not self._connected:
return False
try:
serialized = json.dumps(value, default=str)
await self.redis_client.lpush(key, serialized)
return True
except Exception as e:
logger.error('Ошибка добавления в очередь', key=key, error=e)
return False
async def rpop(self, key: str) -> Any | None:
"""Извлечь элемент из конца списка (FIFO очередь)."""
if not self._connected:
return None
try:
value = await self.redis_client.rpop(key)
if value:
return json.loads(value)
return None
except Exception as e:
logger.error('Ошибка извлечения из очереди', key=key, error=e)
return None
async def llen(self, key: str) -> int:
"""Получить длину списка (очереди)."""
if not self._connected:
return 0
try:
return await self.redis_client.llen(key)
except Exception as e:
logger.error('Ошибка получения длины очереди', key=key, error=e)
return 0
async def lrange(self, key: str, start: int = 0, end: int = -1) -> list:
"""Получить элементы списка без удаления."""
if not self._connected:
return []
try:
items = await self.redis_client.lrange(key, start, end)
return [json.loads(item) for item in items]
except Exception as e:
logger.error('Ошибка чтения очереди', key=key, error=e)
return []
cache = CacheService()
def cache_key(*parts) -> str:
return ':'.join(str(part) for part in parts)
async def cached_function(key: str, expire: int = 300):
def decorator(func):
async def wrapper(*args, **kwargs):
cache_result = await cache.get(key)
if cache_result is not None:
return cache_result
result = await func(*args, **kwargs)
await cache.set(key, result, expire)
return result
return wrapper
return decorator
class UserCache:
@staticmethod
async def get_user_data(user_id: int) -> dict | None:
key = cache_key('user', user_id)
return await cache.get(key)
@staticmethod
async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool:
key = cache_key('user', user_id)
return await cache.set(key, data, expire)
@staticmethod
async def delete_user_data(user_id: int) -> bool:
key = cache_key('user', user_id)
return await cache.delete(key)
@staticmethod
async def get_user_session(user_id: int, session_key: str) -> Any | None:
key = cache_key('session', user_id, session_key)
return await cache.get(key)
@staticmethod
async def set_user_session(user_id: int, session_key: str, data: Any, expire: int = 1800) -> bool:
key = cache_key('session', user_id, session_key)
return await cache.set(key, data, expire)
@staticmethod
async def delete_user_session(user_id: int, session_key: str) -> bool:
key = cache_key('session', user_id, session_key)
return await cache.delete(key)
class SystemCache:
@staticmethod
async def get_system_stats() -> dict | None:
return await cache.get('system:stats')
@staticmethod
async def set_system_stats(stats: dict, expire: int = 300) -> bool:
return await cache.set('system:stats', stats, expire)
@staticmethod
async def get_nodes_status() -> list | None:
return await cache.get('remnawave:nodes')
@staticmethod
async def set_nodes_status(nodes: list, expire: int = 60) -> bool:
return await cache.set('remnawave:nodes', nodes, expire)
@staticmethod
async def get_daily_stats(date: str) -> dict | None:
key = cache_key('stats', 'daily', date)
return await cache.get(key)
@staticmethod
async def set_daily_stats(date: str, stats: dict) -> bool:
key = cache_key('stats', 'daily', date)
return await cache.set(key, stats, 86400) # 24 часа
class RateLimitCache:
@staticmethod
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
key = cache_key('rate_limit', user_id, action)
current = await cache.get(key)
if current is None:
await cache.set(key, 1, window)
return False
if current >= limit:
return True
await cache.increment(key)
return False
@staticmethod
async def reset_rate_limit(user_id: int, action: str) -> bool:
key = cache_key('rate_limit', user_id, action)
return await cache.delete(key)