mirror of
https://github.com/FerraSoft/bottohelp.git
synced 2026-08-06 21:55:03 +00:00
Подготовка к релизу
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Модуль безопасности для платежных операций.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class PaymentSecurity:
|
||||
"""
|
||||
Класс для обеспечения безопасности платежных операций.
|
||||
|
||||
Отвечает за:
|
||||
- Валидацию webhook подписей
|
||||
- Предотвращение дублирования платежей
|
||||
- Защиту от фрода
|
||||
- Шифрование чувствительных данных
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
@staticmethod
|
||||
def validate_stripe_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи Stripe webhook.
|
||||
|
||||
Args:
|
||||
payload: Тело запроса
|
||||
signature: Подпись из заголовка
|
||||
secret: Секретный ключ webhook
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
# Stripe отправляет подпись в формате t=123456,v1=abc123
|
||||
timestamp = None
|
||||
signatures = []
|
||||
|
||||
for part in signature.split(','):
|
||||
key, value = part.split('=', 1)
|
||||
if key == 't':
|
||||
timestamp = value
|
||||
elif key.startswith('v1'):
|
||||
signatures.append(value)
|
||||
|
||||
if not timestamp or not signatures:
|
||||
return False
|
||||
|
||||
# Создание ожидаемой подписи
|
||||
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Проверка соответствия
|
||||
for sig in signatures:
|
||||
if hmac.compare_digest(sig, expected_signature):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"Stripe signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_yookassa_signature(request_body: str, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи YooKassa webhook.
|
||||
|
||||
Args:
|
||||
request_body: Тело запроса как строка
|
||||
signature: Подпись из заголовка
|
||||
secret: Секретный ключ
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
request_body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"YooKassa signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_sbp_signature(request_body: str, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Валидация подписи СБП webhook согласно спецификации СБП.
|
||||
|
||||
СБП использует HMAC-SHA256 с телом запроса и секретным ключом.
|
||||
Формат подписи: base64-encoded HMAC-SHA256 hash.
|
||||
|
||||
Args:
|
||||
request_body: Тело запроса как строка (JSON)
|
||||
signature: Подпись из заголовка (base64)
|
||||
secret: Секретный ключ СБП
|
||||
|
||||
Returns:
|
||||
bool: Валидна ли подпись
|
||||
"""
|
||||
try:
|
||||
# СБП передает подпись в base64 формате
|
||||
# Вычисляем ожидаемую подпись
|
||||
expected_hmac = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
request_body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
)
|
||||
|
||||
# Кодируем в base64 как ожидает СБП
|
||||
import base64
|
||||
expected_signature = base64.b64encode(expected_hmac.digest()).decode('utf-8')
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"SBP signature validation error: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_idempotency_key(payment_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Генерация ключа идемпотентности для предотвращения дубликатов.
|
||||
|
||||
Args:
|
||||
payment_data: Данные платежа
|
||||
|
||||
Returns:
|
||||
str: Ключ идемпотентности
|
||||
"""
|
||||
# Создание уникального ключа на основе user_id, amount и timestamp
|
||||
key_data = f"{payment_data['user_id']}:{payment_data['amount']}:{int(datetime.now().timestamp())}"
|
||||
return hashlib.sha256(key_data.encode()).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def check_idempotency_key(key: str, ttl_seconds: int = 3600) -> bool:
|
||||
"""
|
||||
Проверка ключа идемпотентности в кэше.
|
||||
|
||||
Args:
|
||||
key: Ключ идемпотентности
|
||||
ttl_seconds: Время жизни ключа
|
||||
|
||||
Returns:
|
||||
bool: True если ключ уже использовался
|
||||
"""
|
||||
# В реальной реализации здесь был бы Redis или другой кэш
|
||||
# Пока просто логируем
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Checking idempotency key: {key}")
|
||||
return False # Для тестирования всегда возвращаем False
|
||||
|
||||
@staticmethod
|
||||
def sanitize_payment_data(payment_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Очистка платежных данных от чувствительной информации.
|
||||
|
||||
Args:
|
||||
payment_data: Исходные данные платежа
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Очищенные данные
|
||||
"""
|
||||
sanitized = payment_data.copy()
|
||||
|
||||
# Список полей для удаления
|
||||
sensitive_fields = [
|
||||
'card_number', 'cvv', 'cardholder_name',
|
||||
'password', 'secret_key', 'api_key',
|
||||
'webhook_secret', 'private_key'
|
||||
]
|
||||
|
||||
# Удаление чувствительных полей
|
||||
for field in sensitive_fields:
|
||||
if field in sanitized:
|
||||
sanitized[field] = '***'
|
||||
|
||||
# Маскировка частичных данных
|
||||
if 'card_number' in payment_data:
|
||||
card = payment_data['card_number']
|
||||
if len(card) > 4:
|
||||
sanitized['card_number'] = f"**** **** **** {card[-4:]}"
|
||||
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def validate_amount_range(amount: float, min_amount: float = 1.0,
|
||||
max_amount: float = 100000.0) -> bool:
|
||||
"""
|
||||
Проверка суммы платежа на допустимый диапазон.
|
||||
|
||||
Args:
|
||||
amount: Сумма платежа
|
||||
min_amount: Минимальная сумма
|
||||
max_amount: Максимальная сумма
|
||||
|
||||
Returns:
|
||||
bool: Входит ли сумма в допустимый диапазон
|
||||
"""
|
||||
return min_amount <= amount <= max_amount
|
||||
|
||||
@staticmethod
|
||||
def validate_currency(currency: str, allowed_currencies: list = None) -> bool:
|
||||
"""
|
||||
Проверка валюты платежа.
|
||||
|
||||
Args:
|
||||
currency: Код валюты
|
||||
allowed_currencies: Список разрешенных валют
|
||||
|
||||
Returns:
|
||||
bool: Разрешена ли валюта
|
||||
"""
|
||||
if allowed_currencies is None:
|
||||
allowed_currencies = ['RUB', 'USD', 'EUR']
|
||||
|
||||
return currency.upper() in allowed_currencies
|
||||
|
||||
@staticmethod
|
||||
def detect_suspicious_activity(payment_data: Dict[str, Any],
|
||||
recent_payments: list) -> Dict[str, Any]:
|
||||
"""
|
||||
Обнаружение подозрительной активности.
|
||||
|
||||
Args:
|
||||
payment_data: Данные текущего платежа
|
||||
recent_payments: Список недавних платежей пользователя
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результаты анализа
|
||||
"""
|
||||
result = {
|
||||
'is_suspicious': False,
|
||||
'risk_level': 'low',
|
||||
'reasons': []
|
||||
}
|
||||
|
||||
# Проверка на слишком частые платежи
|
||||
recent_count = len([p for p in recent_payments
|
||||
if (datetime.now() - datetime.fromisoformat(p['created_at'])).seconds < 300])
|
||||
if recent_count > 5:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'medium'
|
||||
result['reasons'].append('too_many_recent_payments')
|
||||
|
||||
# Проверка на необычно большую сумму
|
||||
amount = payment_data['amount']
|
||||
avg_amount = sum(p['amount'] for p in recent_payments) / len(recent_payments) if recent_payments else 0
|
||||
if amount > avg_amount * 10 and avg_amount > 0:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'high'
|
||||
result['reasons'].append('unusually_large_amount')
|
||||
|
||||
# Проверка на круглые суммы (часто используется в тестах фрода)
|
||||
if amount in [1000, 5000, 10000, 50000]:
|
||||
result['is_suspicious'] = True
|
||||
result['risk_level'] = 'low'
|
||||
result['reasons'].append('round_amount_suspicious')
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def encrypt_sensitive_data(data: str, key: str) -> str:
|
||||
"""
|
||||
Шифрование чувствительных данных с использованием Fernet (AES 128).
|
||||
|
||||
Args:
|
||||
data: Данные для шифрования
|
||||
key: Ключ шифрования (должен быть 32 байта для Fernet)
|
||||
|
||||
Returns:
|
||||
str: Зашифрованные данные (base64)
|
||||
|
||||
Raises:
|
||||
PaymentSecurityError: Ошибка шифрования
|
||||
"""
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import base64
|
||||
|
||||
# Создаем ключ подходящего размера если необходимо
|
||||
if len(key) != 32:
|
||||
# Используем SHA256 для генерации 32-байтового ключа
|
||||
key = hashlib.sha256(key.encode('utf-8')).digest()
|
||||
|
||||
# Создаем Fernet ключ (base64-encoded 32-byte key)
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
fernet = Fernet(fernet_key)
|
||||
|
||||
# Шифруем данные
|
||||
encrypted = fernet.encrypt(data.encode('utf-8'))
|
||||
return encrypted.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("cryptography library not installed, encryption unavailable")
|
||||
raise PaymentSecurityError("Encryption library not available")
|
||||
except Exception as e:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Encryption error: {e}")
|
||||
raise PaymentSecurityError(f"Failed to encrypt data: {e}")
|
||||
|
||||
@staticmethod
|
||||
def decrypt_sensitive_data(encrypted_data: str, key: str) -> str:
|
||||
"""
|
||||
Расшифровка чувствительных данных с использованием Fernet (AES 128).
|
||||
|
||||
Args:
|
||||
encrypted_data: Зашифрованные данные (base64)
|
||||
key: Ключ шифрования
|
||||
|
||||
Returns:
|
||||
str: Расшифрованные данные
|
||||
|
||||
Raises:
|
||||
PaymentSecurityError: Ошибка расшифровки
|
||||
"""
|
||||
try:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import base64
|
||||
|
||||
# Создаем ключ подходящего размера если необходимо
|
||||
if len(key) != 32:
|
||||
# Используем SHA256 для генерации 32-байтового ключа
|
||||
key = hashlib.sha256(key.encode('utf-8')).digest()
|
||||
|
||||
# Создаем Fernet ключ (base64-encoded 32-byte key)
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
fernet = Fernet(fernet_key)
|
||||
|
||||
# Расшифровываем данные
|
||||
decrypted = fernet.decrypt(encrypted_data.encode('utf-8'))
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("cryptography library not installed, decryption unavailable")
|
||||
raise PaymentSecurityError("Decryption library not available")
|
||||
except InvalidToken:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error("Invalid encryption token or key")
|
||||
raise PaymentSecurityError("Invalid encryption token")
|
||||
except Exception as e:
|
||||
from core.payment_exceptions import PaymentSecurityError
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Decryption error: {e}")
|
||||
raise PaymentSecurityError(f"Failed to decrypt data: {e}")
|
||||
|
||||
@staticmethod
|
||||
def validate_ffmpeg_command(command: list) -> bool:
|
||||
"""
|
||||
Проверка безопасности команды ffmpeg с дополнительными мерами защиты.
|
||||
|
||||
Args:
|
||||
command: Список аргументов команды ffmpeg
|
||||
|
||||
Returns:
|
||||
bool: Безопасна ли команда
|
||||
"""
|
||||
if not command or command[0] != 'ffmpeg':
|
||||
return False
|
||||
|
||||
# Запрещенные опции и паттерны
|
||||
dangerous_patterns = [
|
||||
r'-f\s+srt', # SRT формат может быть опасным
|
||||
r'-i\s+.*\.\.', # Path traversal
|
||||
r'-i\s+.*\|', # Command injection
|
||||
r'-i\s+.*;', # Command injection
|
||||
r'-i\s+.*\$', # Shell variables
|
||||
r'-i\s+.*`', # Command substitution
|
||||
r'-y\s+.*\.\.', # Path traversal в output
|
||||
r'-y\s+.*\|', # Command injection в output
|
||||
r'-y\s+.*;', # Command injection в output
|
||||
r'-y\s+.*\$', # Shell variables в output
|
||||
r'-y\s+.*`', # Command substitution в output
|
||||
r'-i\s+.*\.\.', # Path traversal в input
|
||||
r'-i\s+.*\|', # Command injection в input
|
||||
r'-i\s+.*;', # Command injection в input
|
||||
r'-i\s+.*\$', # Shell variables в input
|
||||
r'-i\s+.*`', # Command substitution в input
|
||||
r'-vf\s+.*\|', # Command injection в video filters
|
||||
r'-vf\s+.*;', # Command injection в video filters
|
||||
r'-af\s+.*\|', # Command injection в audio filters
|
||||
r'-af\s+.*;', # Command injection в audio filters
|
||||
r'-filter_complex\s+.*\|', # Command injection в complex filters
|
||||
r'-filter_complex\s+.*;', # Command injection в complex filters
|
||||
]
|
||||
|
||||
command_str = ' '.join(command)
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, command_str, re.IGNORECASE):
|
||||
logging.getLogger(__name__).warning(f"Dangerous ffmpeg pattern detected: {pattern}")
|
||||
return False
|
||||
|
||||
# Проверка на допустимые входные форматы
|
||||
allowed_input_formats = ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv', 'mpg', 'mpeg', 'mp3', 'wav', 'aac']
|
||||
input_files = [arg for arg in command if not arg.startswith('-') and '.' in arg and not arg.endswith('/')]
|
||||
|
||||
for file in input_files:
|
||||
ext = file.split('.')[-1].lower()
|
||||
if ext not in allowed_input_formats:
|
||||
logging.getLogger(__name__).warning(f"Unsupported input format: {ext}")
|
||||
return False
|
||||
|
||||
# Проверка на допустимые выходные форматы
|
||||
allowed_output_formats = ['mp4', 'avi', 'mov', 'mkv', 'webm', 'gif', 'jpg', 'png', 'mp3', 'wav', 'aac']
|
||||
if '-f' in command:
|
||||
format_idx = command.index('-f') + 1
|
||||
if format_idx < len(command):
|
||||
output_format = command[format_idx].lower()
|
||||
if output_format not in allowed_output_formats:
|
||||
logging.getLogger(__name__).warning(f"Unsupported output format: {output_format}")
|
||||
return False
|
||||
|
||||
# Проверка на наличие выходного файла
|
||||
if '-y' not in command:
|
||||
logging.getLogger(__name__).warning("Output file not specified with -y flag")
|
||||
return False
|
||||
|
||||
# Проверка на разумные ограничения
|
||||
if len(command) > 50: # Слишком много аргументов
|
||||
logging.getLogger(__name__).warning("Too many ffmpeg arguments")
|
||||
return False
|
||||
|
||||
# Проверка на запрещенные кодеки и фильтры
|
||||
dangerous_codecs = ['libx264', 'libx265', 'h264', 'hevc'] # Требуют проверки
|
||||
for arg in command:
|
||||
if any(codec in arg for codec in dangerous_codecs):
|
||||
logging.getLogger(__name__).warning(f"Potentially dangerous codec: {arg}")
|
||||
# Пока разрешаем, но логируем для мониторинга
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def sanitize_ffmpeg_output_path(output_path: str) -> Optional[str]:
|
||||
"""
|
||||
Санитизация пути вывода ffmpeg.
|
||||
|
||||
Args:
|
||||
output_path: Исходный путь вывода
|
||||
|
||||
Returns:
|
||||
Optional[str]: Санитизированный путь или None если небезопасный
|
||||
"""
|
||||
# Проверка на path traversal
|
||||
if '..' in output_path or output_path.startswith('/'):
|
||||
logging.getLogger(__name__).warning(f"Potentially dangerous output path: {output_path}")
|
||||
return None
|
||||
|
||||
# Проверка на специальные символы
|
||||
if any(char in output_path for char in ['|', ';', '$', '`', '&', '<', '>']):
|
||||
logging.getLogger(__name__).warning(f"Special characters in output path: {output_path}")
|
||||
return None
|
||||
|
||||
# Ограничение на допустимые расширения
|
||||
allowed_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.gif', '.jpg', '.png']
|
||||
if not any(output_path.endswith(ext) for ext in allowed_extensions):
|
||||
logging.getLogger(__name__).warning(f"Unsupported output extension: {output_path}")
|
||||
return None
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def secure_ffmpeg_execute(command: list, timeout_seconds: int = 300) -> tuple[bool, str]:
|
||||
"""
|
||||
Безопасное выполнение команды ffmpeg с дополнительными проверками.
|
||||
|
||||
Args:
|
||||
command: Список аргументов команды ffmpeg
|
||||
timeout_seconds: Максимальное время выполнения
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (успешно ли выполнено, сообщение об ошибке или пустая строка)
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Валидация команды
|
||||
if not PaymentSecurity.validate_ffmpeg_command(command):
|
||||
return False, "Command validation failed"
|
||||
|
||||
try:
|
||||
# Дополнительная валидация пути вывода
|
||||
if '-y' in command:
|
||||
output_idx = command.index('-y') + 1
|
||||
if output_idx < len(command):
|
||||
output_path = command[output_idx]
|
||||
sanitized_path = PaymentSecurity.sanitize_ffmpeg_output_path(output_path)
|
||||
if sanitized_path is None:
|
||||
return False, "Output path validation failed"
|
||||
command[output_idx] = sanitized_path
|
||||
|
||||
# Выполнение с таймаутом и ограничениями
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
# Ограничения безопасности
|
||||
preexec_fn=None if subprocess.os.name == 'nt' else subprocess.os.setpgrp,
|
||||
env={
|
||||
'PATH': subprocess.os.environ.get('PATH', ''),
|
||||
# Убираем потенциально опасные переменные окружения
|
||||
'LD_LIBRARY_PATH': '',
|
||||
'LD_PRELOAD': '',
|
||||
}
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"FFmpeg command executed successfully")
|
||||
return True, ""
|
||||
else:
|
||||
error_msg = f"FFmpeg failed with code {result.returncode}: {result.stderr}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = f"FFmpeg command timed out after {timeout_seconds} seconds"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg execution error: {e}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
class PaymentRateLimiter:
|
||||
"""
|
||||
Ограничитель частоты платежей для предотвращения злоупотреблений.
|
||||
"""
|
||||
|
||||
def __init__(self, max_payments_per_hour: int = 10, max_amount_per_hour: float = 50000):
|
||||
"""
|
||||
Инициализация ограничителя.
|
||||
|
||||
Args:
|
||||
max_payments_per_hour: Максимальное количество платежей в час
|
||||
max_amount_per_hour: Максимальная сумма платежей в час
|
||||
"""
|
||||
self.max_payments_per_hour = max_payments_per_hour
|
||||
self.max_amount_per_hour = max_amount_per_hour
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# В реальной реализации здесь был бы Redis для хранения счетчиков
|
||||
# Пока используем in-memory словарь для демонстрации
|
||||
self._counters = {} # user_id -> {'payments': [], 'amounts': []}
|
||||
|
||||
def check_rate_limit(self, user_id: int, amount: float) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверка ограничений частоты платежей.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма платежа
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результат проверки
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - 3600 # 1 час в секундах
|
||||
|
||||
if user_id not in self._counters:
|
||||
self._counters[user_id] = {'payments': [], 'amounts': []}
|
||||
|
||||
user_counters = self._counters[user_id]
|
||||
|
||||
# Очистка старых записей
|
||||
user_counters['payments'] = [t for t in user_counters['payments'] if t > window_start]
|
||||
user_counters['amounts'] = [(t, a) for t, a in user_counters['amounts'] if t > window_start]
|
||||
|
||||
# Подсчет текущих значений
|
||||
current_payments = len(user_counters['payments'])
|
||||
current_amount = sum(a for _, a in user_counters['amounts'])
|
||||
|
||||
# Проверка лимитов
|
||||
payments_allowed = current_payments < self.max_payments_per_hour
|
||||
amount_allowed = (current_amount + amount) <= self.max_amount_per_hour
|
||||
allowed = payments_allowed and amount_allowed
|
||||
|
||||
remaining_payments = max(0, self.max_payments_per_hour - current_payments)
|
||||
remaining_amount = max(0, self.max_amount_per_hour - current_amount - amount)
|
||||
|
||||
if not allowed:
|
||||
self.logger.warning(f"Rate limit exceeded for user {user_id}: "
|
||||
f"payments {current_payments}/{self.max_payments_per_hour}, "
|
||||
f"amount {current_amount:.2f}/{self.max_amount_per_hour}")
|
||||
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'remaining_payments': remaining_payments,
|
||||
'remaining_amount': remaining_amount,
|
||||
'reset_time': datetime.fromtimestamp(current_time + 3600)
|
||||
}
|
||||
|
||||
def record_payment(self, user_id: int, amount: float):
|
||||
"""
|
||||
Запись платежа для учета в ограничениях.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
amount: Сумма платежа
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
if user_id not in self._counters:
|
||||
self._counters[user_id] = {'payments': [], 'amounts': []}
|
||||
|
||||
user_counters = self._counters[user_id]
|
||||
|
||||
# Добавление новой записи
|
||||
user_counters['payments'].append(current_time)
|
||||
user_counters['amounts'].append((current_time, amount))
|
||||
|
||||
# Очистка записей старше 1 часа
|
||||
window_start = current_time - 3600
|
||||
user_counters['payments'] = [t for t in user_counters['payments'] if t > window_start]
|
||||
user_counters['amounts'] = [(t, a) for t, a in user_counters['amounts'] if t > window_start]
|
||||
|
||||
self.logger.debug(f"Recorded payment for user {user_id}: amount {amount}")
|
||||
Reference in New Issue
Block a user