Подготовка к релизу

This commit is contained in:
2025-10-30 18:28:26 +03:00
parent 69c0162c6b
commit 4981db90a0
191 changed files with 45557 additions and 3651 deletions
+461
View File
@@ -0,0 +1,461 @@
"""
Сервис для обработки донатов через различные платежные системы.
"""
import asyncio
import logging
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime, timedelta
from dataclasses import dataclass
from core.payment_models import (
PaymentIntent, PaymentEvent, PaymentStatus, TransactionType,
DonationResponse, PaymentValidationResult
)
from core.payment_exceptions import (
PaymentError, PaymentProviderError, PaymentValidationError,
PaymentSecurityError, PaymentDuplicateError, PaymentNotFoundError
)
from services.payment_provider import (
PaymentProvider, StripePaymentProvider, YooKassaPaymentProvider, SBPaymentProvider
)
from database.payment_repository import PaymentRepository, TransactionRepository
from services.user_service import UserService
from services.notification_service import NotificationService
@dataclass
class PaymentConfig:
"""Конфигурация платежного провайдера"""
enabled: bool
api_key: Optional[str] = None
webhook_secret: Optional[str] = None
shop_id: Optional[str] = None
secret_key: Optional[str] = None
bank_id: Optional[str] = None
class DonationService:
"""
Основной сервис для обработки донатов.
Отвечает за:
- Создание платежей через различные провайдеры
- Валидацию платежных данных
- Обработку webhook уведомлений
- Управление статусами платежей
- Интеграцию с пользователями и уведомлениями
"""
def __init__(self, payment_repo: PaymentRepository,
transaction_repo: TransactionRepository,
user_service: UserService,
notification_service: NotificationService,
payment_configs: Dict[str, PaymentConfig]):
"""
Инициализация сервиса донатов.
Args:
payment_repo: Репозиторий платежей
transaction_repo: Репозиторий транзакций
user_service: Сервис пользователей
notification_service: Сервис уведомлений
payment_configs: Конфигурации платежных провайдеров
"""
self.payment_repo = payment_repo
self.transaction_repo = transaction_repo
self.user_service = user_service
self.notification_service = notification_service
self.payment_configs = payment_configs
self.logger = logging.getLogger(__name__)
# Инициализация провайдеров
self.providers: Dict[str, PaymentProvider] = {}
self._initialize_providers()
# Инициализация rate limiter для защиты от злоупотреблений
from core.payment_security import PaymentRateLimiter
self.rate_limiter = PaymentRateLimiter(
max_payments_per_hour=10, # Максимум 10 платежей в час на пользователя
max_amount_per_hour=50000.0 # Максимум 50000 руб в час
)
# Настройки валидации
self.min_amount = 10.0
self.max_amount = 10000.0
self.allowed_currencies = ['RUB', 'USD', 'EUR']
# Настройки безопасности
self.duplicate_check_window_minutes = 30
def _initialize_providers(self):
"""Инициализация платежных провайдеров"""
# Stripe
stripe_config = self.payment_configs.get('stripe')
if stripe_config and stripe_config.enabled:
self.providers['stripe'] = StripePaymentProvider(
api_key=stripe_config.api_key,
webhook_secret=stripe_config.webhook_secret
)
# YooKassa
yookassa_config = self.payment_configs.get('yookassa')
if yookassa_config and yookassa_config.enabled:
self.providers['yookassa'] = YooKassaPaymentProvider(
shop_id=yookassa_config.shop_id,
secret_key=yookassa_config.secret_key
)
# СБП (заглушка)
sbp_config = self.payment_configs.get('sbp')
if sbp_config and sbp_config.enabled:
self.providers['sbp'] = SBPaymentProvider(
api_key=sbp_config.api_key,
bank_id=sbp_config.bank_id,
webhook_secret=sbp_config.webhook_secret
)
async def create_donation(self, user_id: int, amount: float,
provider_name: str = 'stripe',
metadata: Optional[Dict[str, Any]] = None) -> DonationResponse:
"""
Создание доната.
Args:
user_id: ID пользователя
amount: Сумма доната
provider_name: Название провайдера
metadata: Дополнительные данные
Returns:
DonationResponse: Ответ с URL оплаты и ID платежа
Raises:
PaymentValidationError: Ошибка валидации
PaymentProviderError: Ошибка провайдера
"""
try:
self.logger.info(f"Creating donation: user_id={user_id}, amount={amount}, provider={provider_name}")
# Валидация входных данных
validation_result = self._validate_donation_data(user_id, amount, provider_name)
if not validation_result.is_valid:
raise PaymentValidationError(f"Validation failed: {', '.join(validation_result.errors)}")
# Проверка rate limit
rate_limit_result = self.rate_limiter.check_rate_limit(user_id, amount)
if not rate_limit_result['allowed']:
raise PaymentValidationError(
f"Rate limit exceeded. Try again after {rate_limit_result['reset_time']}"
)
# Проверка на дубликаты
if self._check_duplicate_donation(user_id, amount):
raise PaymentDuplicateError("Duplicate donation detected")
# Получение провайдера
provider = self._get_provider(provider_name)
# Подготовка метаданных
if metadata is None:
metadata = {}
metadata.update({
'user_id': str(user_id),
'created_at': datetime.now().isoformat(),
'service': 'telegram_bot_donation'
})
# Создание платежа через провайдера
payment_intent = provider.create_payment(amount, 'RUB', user_id, metadata)
# Сохранение платежа в БД
payment_record = self.payment_repo.create_payment({
'user_id': user_id,
'amount': amount,
'currency': 'RUB',
'provider': provider_name,
'external_id': payment_intent.id,
'status': 'pending',
'metadata': metadata,
'created_at': datetime.now(),
'updated_at': datetime.now()
})
self.logger.info(f"Donation created: payment_id={payment_record['id']}, external_id={payment_intent.id}")
# Создание транзакции
self.transaction_repo.create_transaction({
'payment_id': payment_record['id'],
'type': 'payment',
'amount': amount,
'status': 'pending',
'created_at': datetime.now()
})
# Регистрация платежа в rate limiter для учета лимитов
self.rate_limiter.record_payment(user_id, amount)
return DonationResponse(
payment_url=payment_intent.url,
payment_id=payment_record['id'],
provider=provider_name,
amount=amount,
currency='RUB'
)
except (PaymentValidationError, PaymentDuplicateError, PaymentProviderError):
raise
except Exception as e:
self.logger.error(f"Unexpected error creating donation: {e}", exc_info=True)
raise PaymentError(f"Failed to create donation: {e}")
async def process_payment_webhook(self, provider_name: str, webhook_data: Dict[str, Any],
signature: str) -> bool:
"""
Обработка webhook уведомления от платежного провайдера.
Args:
provider_name: Название провайдера
webhook_data: Данные webhook
signature: Подпись для валидации
Returns:
bool: Успешно ли обработано
Raises:
PaymentSecurityError: Ошибка безопасности
PaymentProviderError: Ошибка провайдера
"""
try:
self.logger.info(f"Processing webhook: provider={provider_name}")
# Получение провайдера
provider = self._get_provider(provider_name)
# Валидация подписи
if not provider.validate_webhook(webhook_data, signature):
raise PaymentSecurityError("Invalid webhook signature")
# Обработка события платежа
payment_event = provider.process_webhook(webhook_data)
# Обработка события
await self._handle_payment_event(payment_event)
return True
except (PaymentSecurityError, PaymentProviderError):
raise
except Exception as e:
self.logger.error(f"Error processing webhook: {e}", exc_info=True)
raise PaymentProviderError(f"Webhook processing failed: {e}")
async def _handle_payment_event(self, event: PaymentEvent):
"""
Обработка события платежа.
Args:
event: Событие платежа
"""
try:
self.logger.info(f"Handling payment event: {event.type} for payment {event.payment_id}")
# Определение типа события
if event.type == 'payment.succeeded':
await self._process_successful_payment(event)
elif event.type == 'payment.failed':
await self._process_failed_payment(event)
elif event.type == 'payment.cancelled':
await self._process_cancelled_payment(event)
elif event.type == 'payment.pending':
await self._process_pending_payment(event)
else:
self.logger.warning(f"Unknown payment event type: {event.type}")
except Exception as e:
self.logger.error(f"Error handling payment event: {e}", exc_info=True)
# Не прерываем обработку, логируем ошибку
async def _process_successful_payment(self, event: PaymentEvent):
"""Обработка успешного платежа"""
try:
# Обновление статуса платежа
success = self.payment_repo.update_payment_status_by_external_id(
event.payment_id, 'succeeded',
processed_at=datetime.now()
)
if not success:
self.logger.error(f"Failed to update payment status for {event.payment_id}")
return
# Получение данных платежа
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
if not payment:
self.logger.error(f"Payment not found: {event.payment_id}")
return
# Обновление баланса пользователя
await self._update_user_balance(payment['user_id'], event.amount)
# Обновление транзакции
transactions = self.transaction_repo.get_transactions_by_payment(payment['id'])
if transactions:
self.transaction_repo.update_transaction_status(
transactions[0]['id'], 'succeeded'
)
# Уведомление пользователя
if self.notification_service:
await self.notification_service.notify_payment_success(
payment['user_id'], event.amount
)
self.logger.info(f"Successfully processed payment: {event.payment_id}, amount: {event.amount}")
except Exception as e:
self.logger.error(f"Error processing successful payment: {e}", exc_info=True)
async def _process_failed_payment(self, event: PaymentEvent):
"""Обработка неудачного платежа"""
try:
# Обновление статуса платежа
self.payment_repo.update_payment_status_by_external_id(
event.payment_id, 'failed',
processed_at=datetime.now(),
error_message=event.metadata.get('error', 'Payment failed')
)
# Получение данных платежа
payment = self.payment_repo.get_payment_by_external_id(event.payment_id)
if payment and self.notification_service:
await self.notification_service.notify_payment_failed(
payment['user_id'],
event.metadata.get('error', 'Unknown error')
)
self.logger.info(f"Processed failed payment: {event.payment_id}")
except Exception as e:
self.logger.error(f"Error processing failed payment: {e}", exc_info=True)
async def _process_cancelled_payment(self, event: PaymentEvent):
"""Обработка отмененного платежа"""
try:
# Обновление статуса платежа
self.payment_repo.update_payment_status_by_external_id(
event.payment_id, 'cancelled',
processed_at=datetime.now()
)
self.logger.info(f"Processed cancelled payment: {event.payment_id}")
except Exception as e:
self.logger.error(f"Error processing cancelled payment: {e}", exc_info=True)
async def _process_pending_payment(self, event: PaymentEvent):
"""Обработка платежа в ожидании"""
try:
# Обновление статуса платежа
self.payment_repo.update_payment_status_by_external_id(
event.payment_id, 'processing'
)
self.logger.info(f"Processed pending payment: {event.payment_id}")
except Exception as e:
self.logger.error(f"Error processing pending payment: {e}", exc_info=True)
async def _update_user_balance(self, user_id: int, amount: float):
"""Обновление баланса пользователя после успешного платежа"""
try:
# Добавление доната в систему пользователей
await self.user_service.add_donation(user_id, amount)
self.logger.info(f"Updated user balance: user_id={user_id}, amount={amount}")
except Exception as e:
self.logger.error(f"Error updating user balance: {e}", exc_info=True)
def _validate_donation_data(self, user_id: int, amount: float, provider_name: str) -> PaymentValidationResult:
"""Валидация данных доната"""
errors = []
warnings = []
# Проверка user_id
if not isinstance(user_id, int) or user_id <= 0:
errors.append("Invalid user ID")
# Проверка суммы
if not isinstance(amount, (int, float)) or amount <= 0:
errors.append("Amount must be positive number")
elif amount < self.min_amount:
errors.append(f"Minimum amount is {self.min_amount}")
elif amount > self.max_amount:
errors.append(f"Maximum amount is {self.max_amount}")
# Проверка провайдера
if provider_name not in self.providers:
errors.append(f"Unsupported provider: {provider_name}")
elif not self.payment_configs.get(provider_name, PaymentConfig(False)).enabled:
errors.append(f"Provider {provider_name} is disabled")
return PaymentValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings
)
def _check_duplicate_donation(self, user_id: int, amount: float) -> bool:
"""Проверка на дубликат доната"""
return self.payment_repo.check_duplicate_payment(
"", amount, user_id, self.duplicate_check_window_minutes
)
def _get_provider(self, provider_name: str) -> PaymentProvider:
"""Получение провайдера по имени"""
if provider_name not in self.providers:
raise PaymentProviderError(f"Provider {provider_name} not found")
return self.providers[provider_name]
def get_payment_status(self, payment_id: int) -> Optional[Dict[str, Any]]:
"""Получение статуса платежа"""
return self.payment_repo.get_payment_by_id(payment_id)
def get_user_payments(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
"""Получение платежей пользователя"""
return self.payment_repo.get_payments_by_user(user_id, limit)
def get_payment_statistics(self, date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None) -> Dict[str, Any]:
"""Получение статистики платежей"""
return self.payment_repo.get_payment_statistics(date_from, date_to)
def cancel_payment(self, payment_id: int, user_id: int) -> bool:
"""Отмена платежа"""
try:
# Получение данных платежа
payment = self.payment_repo.get_payment_by_id(payment_id)
if not payment:
raise PaymentNotFoundError(f"Payment {payment_id} not found")
if payment['user_id'] != user_id:
raise PaymentSecurityError("Unauthorized payment cancellation")
if payment['status'] not in ['pending', 'processing']:
return False # Нельзя отменить завершенный платеж
# Получение провайдера и отмена платежа
provider = self._get_provider(payment['provider'])
success = provider.cancel_payment(payment['external_id'])
if success:
self.payment_repo.update_payment_status(payment_id, 'cancelled')
return True
return False
except Exception as e:
self.logger.error(f"Error cancelling payment: {e}", exc_info=True)
return False