1f0fef114b
- 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
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
import asyncio
|
||
import hashlib
|
||
import json
|
||
from typing import Any
|
||
|
||
import aiohttp
|
||
import structlog
|
||
|
||
from app.config import settings
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
class MulenPayService:
|
||
"""Интеграция с Mulen Pay API."""
|
||
|
||
def __init__(self) -> None:
|
||
self.api_key = settings.MULENPAY_API_KEY
|
||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||
self.base_url = settings.MULENPAY_BASE_URL.rstrip('/')
|
||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||
self._max_retries = 3
|
||
self._retry_delay = 0.5
|
||
self._retryable_statuses = {500, 502, 503, 504}
|
||
|
||
@property
|
||
def is_configured(self) -> bool:
|
||
return bool(settings.is_mulenpay_enabled() and self.api_key and self.shop_id and self.secret_key)
|
||
|
||
async def _request(
|
||
self,
|
||
method: str,
|
||
endpoint: str,
|
||
*,
|
||
json_data: dict[str, Any] | None = None,
|
||
params: dict[str, Any] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
if not self.is_configured:
|
||
logger.error('MulenPay service is not configured')
|
||
return None
|
||
|
||
url = f'{self.base_url}{endpoint}'
|
||
headers = {
|
||
'Authorization': f'Bearer {self.api_key}',
|
||
'Content-Type': 'application/json',
|
||
}
|
||
|
||
last_error: BaseException | None = None
|
||
|
||
for attempt in range(1, self._max_retries + 1):
|
||
try:
|
||
async with (
|
||
aiohttp.ClientSession(timeout=self._timeout) as session,
|
||
session.request(
|
||
method,
|
||
url,
|
||
headers=headers,
|
||
json=json_data,
|
||
params=params,
|
||
) as response,
|
||
):
|
||
data, raw_text = await self._deserialize_response(response)
|
||
|
||
if response.status >= 400:
|
||
logger.error(
|
||
'MulenPay API error', response_status=response.status, endpoint=endpoint, raw_text=raw_text
|
||
)
|
||
if response.status in self._retryable_statuses and attempt < self._max_retries:
|
||
await self._sleep_with_backoff(attempt)
|
||
continue
|
||
return None
|
||
|
||
if data is None:
|
||
if raw_text:
|
||
logger.warning(
|
||
'MulenPay returned unexpected payload for', endpoint=endpoint, raw_text=raw_text
|
||
)
|
||
return None
|
||
|
||
return data
|
||
except asyncio.CancelledError:
|
||
logger.debug('MulenPay request cancelled', method=method, endpoint=endpoint)
|
||
raise
|
||
except TimeoutError as error:
|
||
last_error = error
|
||
logger.warning(
|
||
'MulenPay request timeout attempt /',
|
||
method=method,
|
||
endpoint=endpoint,
|
||
attempt=attempt,
|
||
max_retries=self._max_retries,
|
||
)
|
||
except aiohttp.ClientError as error:
|
||
last_error = error
|
||
logger.warning(
|
||
'MulenPay client error attempt /',
|
||
method=method,
|
||
endpoint=endpoint,
|
||
attempt=attempt,
|
||
max_retries=self._max_retries,
|
||
error=error,
|
||
)
|
||
except Exception as error: # pragma: no cover - safety
|
||
logger.error('Unexpected MulenPay error', error=error, exc_info=True)
|
||
return None
|
||
|
||
if attempt < self._max_retries:
|
||
await self._sleep_with_backoff(attempt)
|
||
|
||
if isinstance(last_error, asyncio.TimeoutError):
|
||
logger.error(
|
||
'MulenPay request timed out after attempts',
|
||
max_retries=self._max_retries,
|
||
method=method,
|
||
endpoint=endpoint,
|
||
)
|
||
elif last_error is not None:
|
||
logger.error(
|
||
'MulenPay request failed after attempts',
|
||
max_retries=self._max_retries,
|
||
method=method,
|
||
endpoint=endpoint,
|
||
last_error=last_error,
|
||
)
|
||
|
||
return None
|
||
|
||
async def _sleep_with_backoff(self, attempt: int) -> None:
|
||
await asyncio.sleep(self._retry_delay * attempt)
|
||
|
||
async def _deserialize_response(self, response: aiohttp.ClientResponse) -> tuple[dict[str, Any] | None, str]:
|
||
raw_text = await response.text()
|
||
if not raw_text:
|
||
return None, ''
|
||
|
||
content_type = response.headers.get('Content-Type', '')
|
||
if 'json' in content_type.lower() or not content_type:
|
||
try:
|
||
return json.loads(raw_text), raw_text
|
||
except json.JSONDecodeError as error:
|
||
logger.error('Failed to decode MulenPay JSON response', url=response.url, error=error)
|
||
return None, raw_text
|
||
|
||
return None, raw_text
|
||
|
||
@staticmethod
|
||
def _format_amount(amount_kopeks: int) -> str:
|
||
return f'{amount_kopeks / 100:.2f}'
|
||
|
||
def _build_signature(self, currency: str, amount_str: str) -> str:
|
||
raw_string = f'{currency}{amount_str}{self.shop_id}{self.secret_key}'.encode()
|
||
return hashlib.sha1(raw_string).hexdigest()
|
||
|
||
async def create_payment(
|
||
self,
|
||
*,
|
||
amount_kopeks: int,
|
||
description: str,
|
||
uuid: str,
|
||
items: list,
|
||
language: str = 'ru',
|
||
subscribe: str | None = None,
|
||
hold_time: int | None = None,
|
||
website_url: str | None = None,
|
||
) -> dict[str, Any] | None:
|
||
if not self.is_configured:
|
||
logger.error('MulenPay service is not configured')
|
||
return None
|
||
|
||
amount_str = self._format_amount(amount_kopeks)
|
||
currency = 'rub'
|
||
payload = {
|
||
'currency': currency,
|
||
'amount': amount_str,
|
||
'uuid': uuid,
|
||
'shopId': self.shop_id,
|
||
'description': description,
|
||
'items': items,
|
||
'language': language,
|
||
'sign': self._build_signature(currency, amount_str),
|
||
}
|
||
|
||
if subscribe:
|
||
payload['subscribe'] = subscribe
|
||
if hold_time is not None:
|
||
payload['holdTime'] = hold_time
|
||
if website_url:
|
||
payload['website_url'] = website_url
|
||
|
||
response = await self._request('POST', '/v2/payments', json_data=payload)
|
||
if not response or not response.get('success'):
|
||
logger.error('Failed to create MulenPay payment', response=response)
|
||
return None
|
||
|
||
return response
|
||
|
||
async def get_payment(self, payment_id: int) -> dict[str, Any] | None:
|
||
return await self._request('GET', f'/v2/payments/{payment_id}')
|
||
|
||
async def list_payments(
|
||
self,
|
||
*,
|
||
offset: int = 0,
|
||
limit: int = 100,
|
||
uuid: str | None = None,
|
||
status: int | None = None,
|
||
) -> dict[str, Any] | None:
|
||
params = {
|
||
'offset': max(0, offset),
|
||
'limit': max(1, min(limit, 1000)),
|
||
}
|
||
if uuid:
|
||
params['uuid'] = uuid
|
||
if status is not None:
|
||
params['status'] = status
|
||
return await self._request('GET', '/v2/payments', params=params)
|