Files
g2s-aggregator/app/services/aggregator.py
T
Раис Юсупалиев 3f7c6dc631
Deploy / deploy (push) Failing after 14m35s
Рефактор
2026-06-26 19:29:11 +03:00

849 lines
30 KiB
Python

"""Aggregator service orchestrating providers, cache and domain rules."""
import asyncio
import hashlib
import json
from collections.abc import Callable, Iterable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from decimal import Decimal
from typing import Protocol
from uuid import uuid4
import structlog
from app.adapters.address_suggestions.base import (
AddressSuggestionClientError,
AddressSuggestionProvider,
AddressSuggestionRequestError,
)
from app.adapters.delivery_providers.base import (
DeliveryProvider,
ProviderClientError,
ProviderRequestError,
)
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
should_apply_tbank_payment_status,
)
from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest,
calculate_expected_payment_amount_kopecks,
filter_and_sort_prices,
is_init_payment_price_valid,
normalize_delivery_request,
)
from app.repositories.order import OrderData
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.cities import cities_map
from app.schemas.request import (
AddressSuggestRequest,
DeliveryCalculationRequest,
SuggestAddressRequest,
)
from app.schemas.response import AddressSuggestion, DeliveryPrice
logger = structlog.get_logger(__name__)
class AggregatorServiceError(RuntimeError):
"""Base exception for AggregatorService failures."""
class InvalidDeliveryRequestError(AggregatorServiceError):
"""Raised when provider rejects delivery request as invalid."""
class UnsupportedAddressSuggestionCountryError(AggregatorServiceError):
"""Raised when address suggestions are not configured for a country."""
class InvalidAddressSuggestRequestError(AggregatorServiceError):
"""Raised when provider rejects address suggestion payload."""
class AddressSuggestionsUnavailableError(AggregatorServiceError):
"""Raised when address suggestions cannot be completed."""
class InvalidInitPaymentRequestError(AggregatorServiceError):
"""Raised when provider rejects payment init payload as invalid."""
class InitPaymentUnavailableError(AggregatorServiceError):
"""Raised when payment initialization cannot be completed."""
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
"""Raised when a TBank payment notification is invalid."""
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
"""Raised when a TBank payment notification cannot be processed."""
class PriceCacheProtocol(Protocol):
async def get(self, key: str) -> object | None: ...
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
class PaymentAdapterProtocol(Protocol):
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
def verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None: ...
class PaymentPriceValidationAdapterProtocol(Protocol):
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> object: ...
class OrderRepositoryProtocol(Protocol):
def session(self) -> AbstractAsyncContextManager[object]: ...
async def create_order(self, session: object, order_data: OrderData) -> object: ...
async def get_order_by_order_uuid(
self,
session: object,
order_uuid: str,
) -> object | None: ...
async def mark_payment_status(
self,
session: object,
order_uuid: str,
status: str,
payment_id: int,
) -> object | None: ...
async def mark_provider_order_registered(
self,
session: object,
order_uuid: str,
provider_order_id: str,
) -> object | None: ...
class EmailSenderProtocol(Protocol):
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
) -> None: ...
class FilterAndSortPricesFn(Protocol):
def __call__(
self,
prices: Iterable[object],
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
parcel_type: object | None = None,
) -> list[object]: ...
class AggregatorService:
def __init__(
self,
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None,
payment_price_validation_adapters: (
Mapping[str, PaymentPriceValidationAdapterProtocol] | None
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapters: (
Mapping[str, OrderRegistrationAdapterProtocol] | None
) = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
) -> None:
self._providers = tuple(providers)
self._cache = cache
self._payment_adapter = payment_adapter
self._payment_price_validation_adapters = dict(
payment_price_validation_adapters or {}
)
self._order_repository = order_repository
self._order_registration_adapters = dict(order_registration_adapters or {})
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn
self._order_uuid_factory = order_uuid_factory
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
provider.name: provider for provider in address_suggestion_providers
}
self._address_suggestion_country_to_provider = (
address_suggestion_country_to_provider or {}
)
async def get_all_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
normalized_request = normalize_delivery_request(
request, weight_round_scale=self._weight_round_scale
)
provider_request = self._to_provider_request(normalized_request)
provider_results = await asyncio.gather(
*(
self._get_provider_prices(
provider=provider,
request=provider_request,
cache_key=self._build_cache_key(
provider_name=provider.name,
request=normalized_request,
),
)
for provider in self._providers
),
return_exceptions=True,
)
successful_results = [
price
for provider_prices in provider_results
if isinstance(provider_prices, list)
for price in provider_prices
]
provider_errors = [
error
for error in provider_results
if isinstance(error, Exception)
]
if (
not successful_results
and provider_errors
and any(isinstance(error, ProviderRequestError) for error in provider_errors)
):
raise InvalidDeliveryRequestError(
"Delivery request is invalid for configured providers."
)
filtered_and_sorted = self._filter_and_sort_prices(
successful_results,
price_multiplier=self._provider_price_multiplier,
parcel_type=request.parcel_type,
)
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
async def suggest_addresses(
self, request: SuggestAddressRequest
) -> list[AddressSuggestion]:
provider_request = self._build_address_suggest_request(request)
provider = self._resolve_address_suggestion_provider(
provider_request.country_code
)
try:
suggestions = await provider.suggest(provider_request)
except AddressSuggestionRequestError as exc:
raise InvalidAddressSuggestRequestError(
"Address suggestion request is invalid for the configured provider."
) from exc
except AddressSuggestionClientError as exc:
raise AddressSuggestionsUnavailableError(
"Address suggestions are temporarily unavailable."
) from exc
return [self._coerce_address_suggestion(item) for item in suggestions]
async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
if self._payment_adapter is None:
raise InitPaymentUnavailableError("Payment adapter is not configured.")
order_uuid = self._order_uuid_factory()
await self._validate_init_payment_price(request, order_uuid)
try:
payment_url = await self._payment_adapter.create_payment_link(
order_uuid=order_uuid,
amount_kopecks=request.system_data.tariff.price,
)
except TBankPaymentRequestError as exc:
logger.exception(
"payment_init_rejected",
order_uuid=order_uuid,
provider_status_code=exc.status_code,
provider_error_code=exc.error_code,
provider_error_message=exc.provider_message,
provider_error_details=exc.details,
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for the configured provider."
) from exc
except TBankPaymentAdapterError as exc:
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc
except Exception as exc:
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc
await self._persist_order(
request=request, order_uuid=order_uuid, payment_url=payment_url
)
return InitPaymentResponse(payment_url=payment_url)
async def _validate_init_payment_price(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> None:
provider = request.system_data.tariff.provider
validation_adapter = self._payment_price_validation_adapters.get(provider)
if validation_adapter is None:
logger.warning(
"init_payment_price_validation_provider_not_configured",
order_uuid=order_uuid,
provider=provider,
)
raise InvalidInitPaymentRequestError(
"Payment price validation is not configured for the tariff provider."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await validation_adapter.get_payment_price(request)
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for provider price validation."
) from exc
except ProviderClientError as exc:
logger.warning(
"init_payment_price_validation_unavailable",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
except Exception as exc:
logger.exception(
"init_payment_price_validation_unexpected_error",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
if provider_price is None:
logger.warning(
"init_payment_price_validation_tariff_not_found",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InvalidInitPaymentRequestError(
"Provider did not return the requested tariff for payment validation."
)
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=self._provider_price_multiplier,
)
if not is_init_payment_price_valid(
requested_price,
provider_price,
price_multiplier=self._provider_price_multiplier,
):
logger.warning(
"init_payment_price_mismatch",
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks,
provider_currency=getattr(provider_price, "currency", None),
provider_price=str(getattr(provider_price, "price", None)),
)
raise InvalidInitPaymentRequestError(
"Payment amount does not match provider validated delivery price."
)
async def handle_tbank_payment_notification(
self,
notification: TBankPaymentNotification,
) -> str:
if self._payment_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Payment adapter is not configured."
)
try:
self._payment_adapter.verify_payment_notification(notification)
except TBankPaymentNotificationTokenError as exc:
logger.warning(
"tbank_notification_token_invalid",
order_id=notification.OrderId,
)
raise InvalidTBankPaymentNotificationError(
"TBank payment notification token is invalid."
) from exc
except TBankPaymentAdapterError as exc:
raise TBankPaymentNotificationProcessingError(
"TBank payment notification verification failed."
) from exc
action = resolve_tbank_payment_notification_action(
status=notification.Status,
success=notification.Success,
error_code=notification.ErrorCode,
)
logger.info(
"tbank_notification_action_resolved",
order_id=notification.OrderId,
status=notification.Status,
action=action.name,
)
order = await self._load_order_and_mark_payment_status(notification)
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
provider = self._resolve_order_provider(order)
if self._has_existing_registration(order, provider):
logger.info(
"tbank_notification_registration_skipped_duplicate",
order_id=notification.OrderId,
provider=provider,
)
return "OK"
registration_result = await self._register_provider_order(
order, order_uuid=notification.OrderId, provider=provider
)
await self._save_order_registration(
order_uuid=notification.OrderId,
provider=provider,
result=registration_result,
)
logger.info(
"tbank_notification_order_registered",
order_id=notification.OrderId,
provider=provider,
)
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
)
return "OK"
async def _send_payment_confirmation_email(
self,
order: object,
*,
order_uuid: str,
) -> None:
if self._email_sender is None or self._order_repository is None:
return
if getattr(order, "payment_email_sent_at", None) is not None:
return
account_email = getattr(order, "account_email", None)
if account_email is None:
return
try:
await self._email_sender.send_email(
to=account_email,
subject=f"Оплата по заказу {order_uuid} принята",
body=(
f"Здравствуйте!\n\n"
f"Оплата по заказу {order_uuid} успешно принята.\n"
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
f"Спасибо за заказ!"
),
)
async with self._order_repository.session() as session:
await self._order_repository.record_payment_email_sent(
session,
order_uuid=order_uuid,
sent_at=datetime.now(timezone.utc),
)
logger.info(
"payment_confirmation_email_sent",
order_uuid=order_uuid,
account_email=account_email,
)
except Exception:
logger.warning(
"payment_confirmation_email_failed",
order_uuid=order_uuid,
account_email=account_email,
exc_info=True,
)
async def _load_order_and_mark_payment_status(
self,
notification: TBankPaymentNotification,
) -> object:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
try:
async with self._order_repository.session() as session:
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
# чтобы внеочередной статус не затирал уже записанный (lost update).
order = await self._order_repository.get_order_by_order_uuid(
session,
notification.OrderId,
for_update=True,
)
if order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
if should_apply_tbank_payment_status(
order.payment_status, notification.Status
):
await self._order_repository.mark_payment_status(
session,
notification.OrderId,
notification.Status,
notification.PaymentId,
)
else:
logger.info(
"tbank_payment_status_downgrade_skipped",
order_uuid=notification.OrderId,
current_status=order.payment_status,
incoming_status=notification.Status,
)
return order
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"tbank_payment_notification_order_update_failed",
order_uuid=notification.OrderId,
payment_status=notification.Status,
tbank_payment_id=notification.PaymentId,
)
raise TBankPaymentNotificationProcessingError(
"TBank payment notification order update failed."
) from exc
@staticmethod
def _resolve_order_provider(order: object) -> str:
provider = getattr(order, "provider", None)
if isinstance(provider, str) and provider:
return provider
request = AggregatorService._to_init_payment_request_from_order(order)
return request.system_data.tariff.provider
@staticmethod
def _has_existing_registration(order: object, provider: str) -> bool:
return bool(getattr(order, "provider_order_id", None))
@staticmethod
def _extract_registration_id(result: object) -> str:
for attr in ("order_uuid", "order_number"):
value = getattr(result, attr, None)
if isinstance(value, str) and value:
return value
raise TBankPaymentNotificationProcessingError(
"Order registration result is missing an identifier."
)
async def _register_provider_order(
self, order: object, *, order_uuid: str, provider: str
) -> object:
registration_adapter = self._order_registration_adapters.get(provider)
if registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Order registration adapter is not configured for the provider."
)
try:
request = self._to_init_payment_request_from_order(order)
return await registration_adapter.register_order(request, order_uuid)
except Exception as exc:
logger.exception(
"provider_order_registration_failed",
provider=provider,
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"Provider order registration failed."
) from exc
async def _save_order_registration(
self,
*,
order_uuid: str,
provider: str,
result: object,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
registration_id = self._extract_registration_id(result)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_provider_order_registered(
session,
order_uuid,
registration_id,
)
if order is None:
logger.warning(
"order_registration_order_not_found",
provider=provider,
order_uuid=order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving registration identifier."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"order_registration_persistence_failed",
provider=provider,
order_uuid=order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"Order registration persistence failed."
) from exc
async def _persist_order(
self,
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> None:
if self._order_repository is None:
return
try:
async with self._order_repository.session() as session:
await self._order_repository.create_order(
session,
self._to_order_data(
request=request,
order_uuid=order_uuid,
payment_url=payment_url,
),
)
except Exception:
logger.exception(
"order_persistence_failed",
order_uuid=order_uuid,
)
@staticmethod
def _to_order_data(
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> OrderData:
return OrderData(
order_uuid=order_uuid,
payment_url=payment_url,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
provider=request.system_data.tariff.provider,
account_email=request.account_email,
payload=request.model_dump(mode="json", by_alias=True),
)
@staticmethod
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
payload = getattr(order, "payload")
return InitPaymentRequest.model_validate(payload)
async def _get_provider_prices(
self,
*,
provider: DeliveryProvider,
request: DeliveryCalculationRequest,
cache_key: str,
) -> list[DeliveryPrice]:
cached_prices = await self._get_cached_prices(cache_key)
if cached_prices is not None:
return cached_prices
fresh_prices = await provider.get_prices(request)
await self._set_cached_prices(
cache_key,
fresh_prices,
ttl=getattr(provider, "cache_ttl_seconds", None),
)
return fresh_prices
async def _get_cached_prices(self, cache_key: str) -> list[DeliveryPrice] | None:
if self._cache is None:
return None
try:
payload = await self._cache.get(cache_key)
except Exception:
return None
if payload is None:
return None
try:
return self._coerce_delivery_prices(payload)
except Exception:
return None
async def _set_cached_prices(
self,
cache_key: str,
payload: list[DeliveryPrice],
*,
ttl: int | None,
) -> None:
if self._cache is None:
return
try:
await self._cache.set(cache_key, payload, ttl=ttl)
except Exception:
return
@staticmethod
def _to_provider_request(
request: NormalizedDeliveryRequest,
) -> DeliveryCalculationRequest:
return DeliveryCalculationRequest(
entity=request.entity,
from_city=request.from_city,
to_city=request.to_city,
weight_kg=request.weight_kg,
length_cm=request.length_cm,
width_cm=request.width_cm,
height_cm=request.height_cm,
)
@staticmethod
def _build_cache_key(provider_name: str, request: NormalizedDeliveryRequest) -> str:
cache_payload = {
"provider": provider_name,
"entity": request.entity,
"from_city": request.from_city,
"to_city": request.to_city,
"weight_kg": str(request.weight_kg),
"length_cm": str(request.length_cm),
"width_cm": str(request.width_cm),
"height_cm": str(request.height_cm),
}
serialized_payload = json.dumps(
cache_payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
cache_hash = hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest()
return f"delivery-price:{provider_name}:{cache_hash}"
@staticmethod
def _coerce_delivery_price(value: object) -> DeliveryPrice:
return DeliveryPrice.model_validate(value, from_attributes=True)
@classmethod
def _coerce_delivery_prices(cls, value: object) -> list[DeliveryPrice]:
if not isinstance(value, list):
raise TypeError("Cached delivery prices must be a list.")
return [cls._coerce_delivery_price(item) for item in value]
@staticmethod
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
return AddressSuggestion.model_validate(value, from_attributes=True)
@staticmethod
def _build_address_suggest_request(
request: SuggestAddressRequest,
) -> AddressSuggestRequest:
city_entry = cities_map.get(request.city)
if not isinstance(city_entry, dict):
raise InvalidAddressSuggestRequestError(
f"City is not configured for id {request.city}."
)
city_name = city_entry.get("city")
country_code = city_entry.get("country")
if not isinstance(city_name, str) or not isinstance(country_code, str):
raise InvalidAddressSuggestRequestError(
f"City mapping is invalid for id {request.city}."
)
return AddressSuggestRequest(
country_code=country_code,
city=city_name,
query=request.query,
limit=request.limit,
)
def _resolve_address_suggestion_provider(
self, country_code: str
) -> AddressSuggestionProvider:
provider_id = self._address_suggestion_country_to_provider.get(country_code)
if provider_id is None:
raise UnsupportedAddressSuggestionCountryError(
"Address suggestions are not configured for the requested country."
)
provider = self._address_suggestion_providers.get(provider_id)
if provider is None:
raise UnsupportedAddressSuggestionCountryError(
"Address suggestions are not configured for the requested country."
)
return provider