Добавлено сохранение заказов в postgres
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""PostgreSQL async engine and session factory management."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import PostgresConfig
|
||||
|
||||
|
||||
def create_postgres_engine(config: PostgresConfig) -> AsyncEngine:
|
||||
return create_async_engine(config.dsn, pool_pre_ping=True)
|
||||
|
||||
|
||||
def create_postgres_session_factory(
|
||||
engine: AsyncEngine,
|
||||
) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
@@ -7,3 +7,18 @@ class TBankPaymentAdapterError(RuntimeError):
|
||||
|
||||
class TBankPaymentRequestError(TBankPaymentAdapterError):
|
||||
"""Raised when TBank rejects payment request data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
error_code: str | None = None,
|
||||
provider_message: str | None = None,
|
||||
details: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
self.provider_message = provider_message
|
||||
self.details = details
|
||||
|
||||
@@ -88,9 +88,10 @@ class TBankAdapter:
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
raise TBankPaymentRequestError(
|
||||
"TBank payment init request was rejected with status "
|
||||
f"{response.status_code}."
|
||||
raise _build_tbank_request_error(
|
||||
"TBank payment init request was rejected.",
|
||||
status_code=response.status_code,
|
||||
payload=_response_json_or_none(response),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -134,7 +135,10 @@ class TBankAdapter:
|
||||
)
|
||||
|
||||
if payload.get("Success") is False:
|
||||
raise TBankPaymentRequestError("TBank payment init request was rejected.")
|
||||
raise _build_tbank_request_error(
|
||||
"TBank payment init request was rejected.",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
payment_url = payload.get("PaymentURL")
|
||||
if not isinstance(payment_url, str) or not payment_url.strip():
|
||||
@@ -155,3 +159,71 @@ def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
|
||||
str(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_tbank_request_error(
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
payload: object | None = None,
|
||||
) -> TBankPaymentRequestError:
|
||||
error_code = _payload_text_value(payload, "ErrorCode")
|
||||
provider_message = _payload_text_value(payload, "Message")
|
||||
details = _payload_text_value(payload, "Details")
|
||||
return TBankPaymentRequestError(
|
||||
_format_tbank_request_error_message(
|
||||
message,
|
||||
status_code=status_code,
|
||||
error_code=error_code,
|
||||
provider_message=provider_message,
|
||||
details=details,
|
||||
),
|
||||
status_code=status_code,
|
||||
error_code=error_code,
|
||||
provider_message=provider_message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _payload_text_value(payload: object | None, key: str) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _format_tbank_request_error_message(
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None,
|
||||
error_code: str | None,
|
||||
provider_message: str | None,
|
||||
details: str | None,
|
||||
) -> str:
|
||||
fields: list[str] = []
|
||||
if status_code is not None:
|
||||
fields.append(f"status_code={status_code}")
|
||||
if error_code is not None:
|
||||
fields.append(f"error_code={error_code}")
|
||||
if provider_message is not None:
|
||||
fields.append(f"message={provider_message}")
|
||||
if details is not None:
|
||||
fields.append(f"details={details}")
|
||||
|
||||
if not fields:
|
||||
return message
|
||||
return f"{message} {' '.join(fields)}"
|
||||
|
||||
@@ -61,6 +61,10 @@ class TBankPaymentConfig(BaseModel):
|
||||
retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
|
||||
|
||||
class PostgresConfig(BaseModel):
|
||||
dsn: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class DadataAddressSuggestionsConfig(BaseModel):
|
||||
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
api_key: str = ""
|
||||
@@ -121,6 +125,7 @@ class Settings(BaseSettings):
|
||||
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
||||
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
||||
tbank_payment: TBankPaymentConfig
|
||||
postgres: PostgresConfig
|
||||
address_suggestions: AddressSuggestionsConfig = Field(
|
||||
default_factory=AddressSuggestionsConfig
|
||||
)
|
||||
@@ -153,6 +158,7 @@ class _RequiredYamlSections(BaseModel):
|
||||
repository: dict[str, Any]
|
||||
adapter: dict[str, Any]
|
||||
tbank_payment: dict[str, Any]
|
||||
postgres: dict[str, Any]
|
||||
address_suggestions: dict[str, Any]
|
||||
observability: dict[str, Any]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.adapters.postgres.engine import create_postgres_engine, create_postgres_session_factory
|
||||
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
|
||||
from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider
|
||||
from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||
@@ -12,6 +13,7 @@ from app.adapters.tbank import TBankAdapter
|
||||
from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.repositories.order import OrderRepository
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||
@@ -58,10 +60,14 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
)
|
||||
providers = (cdek_provider,)
|
||||
cache = PriceCache.from_repository_config(settings.repository)
|
||||
postgres_engine = create_postgres_engine(settings.postgres)
|
||||
postgres_session_factory = create_postgres_session_factory(postgres_engine)
|
||||
order_repository = OrderRepository(session_factory=postgres_session_factory)
|
||||
service = AggregatorService(
|
||||
providers=providers,
|
||||
cache=cache,
|
||||
payment_adapter=payment_adapter,
|
||||
order_repository=order_repository,
|
||||
address_suggestion_providers=(
|
||||
dadata_provider,
|
||||
yandex_geosuggest_provider,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Order repository exports."""
|
||||
|
||||
from app.repositories.order.repository import OrderData, OrderRepository
|
||||
|
||||
__all__ = ("OrderData", "OrderRepository")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""SQLAlchemy models for order persistence."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
|
||||
def _json_payload_type() -> JSON:
|
||||
return JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
|
||||
)
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid4,
|
||||
)
|
||||
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
delivery_type: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sender: Mapped[dict[str, Any]] = mapped_column(_json_payload_type(), nullable=False)
|
||||
recipient: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
from_location: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
to_location: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
packages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
services: Mapped[list[dict[str, Any]] | None] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=True,
|
||||
)
|
||||
comment: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PostgreSQL order repository."""
|
||||
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.repositories.order.models import Order
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderData:
|
||||
order_uuid: str
|
||||
payment_url: str
|
||||
price: int
|
||||
delivery_type: int
|
||||
tariff_code: int
|
||||
sender: dict[str, Any]
|
||||
recipient: dict[str, Any]
|
||||
from_location: dict[str, Any]
|
||||
to_location: dict[str, Any]
|
||||
packages: list[dict[str, Any]]
|
||||
services: list[dict[str, Any]] | None
|
||||
comment: str | None
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
def session(self) -> AbstractAsyncContextManager[AsyncSession]:
|
||||
return self._session_factory.begin()
|
||||
|
||||
async def create_order(self, session: AsyncSession, order_data: OrderData) -> Order:
|
||||
order = Order(
|
||||
order_uuid=order_data.order_uuid,
|
||||
payment_url=order_data.payment_url,
|
||||
price=order_data.price,
|
||||
delivery_type=order_data.delivery_type,
|
||||
tariff_code=order_data.tariff_code,
|
||||
sender=order_data.sender,
|
||||
recipient=order_data.recipient,
|
||||
from_location=order_data.from_location,
|
||||
to_location=order_data.to_location,
|
||||
packages=order_data.packages,
|
||||
services=order_data.services,
|
||||
comment=order_data.comment,
|
||||
)
|
||||
session.add(order)
|
||||
await session.flush()
|
||||
await session.refresh(order)
|
||||
return order
|
||||
@@ -4,9 +4,12 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
|
||||
from app.adapters.address_suggestions.base import (
|
||||
AddressSuggestionClientError,
|
||||
AddressSuggestionProvider,
|
||||
@@ -24,10 +27,13 @@ from app.domain.price import (
|
||||
filter_and_sort_prices,
|
||||
normalize_delivery_request,
|
||||
)
|
||||
from app.repositories.order import OrderData
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AggregatorServiceError(RuntimeError):
|
||||
"""Base exception for AggregatorService failures."""
|
||||
@@ -67,6 +73,12 @@ class PaymentAdapterProtocol(Protocol):
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
|
||||
|
||||
|
||||
class OrderRepositoryProtocol(Protocol):
|
||||
def session(self) -> AbstractAsyncContextManager[object]: ...
|
||||
|
||||
async def create_order(self, session: object, order_data: OrderData) -> object: ...
|
||||
|
||||
|
||||
class FilterAndSortPricesFn(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
@@ -83,6 +95,7 @@ class AggregatorService:
|
||||
providers: Sequence[DeliveryProvider],
|
||||
cache: PriceCacheProtocol | None = None,
|
||||
payment_adapter: PaymentAdapterProtocol | None = None,
|
||||
order_repository: OrderRepositoryProtocol | None = None,
|
||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
||||
*,
|
||||
@@ -93,6 +106,7 @@ class AggregatorService:
|
||||
self._providers = tuple(providers)
|
||||
self._cache = cache
|
||||
self._payment_adapter = payment_adapter
|
||||
self._order_repository = order_repository
|
||||
self._weight_round_scale = weight_round_scale
|
||||
self._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
@@ -180,6 +194,14 @@ class AggregatorService:
|
||||
amount_kopecks=request.price,
|
||||
)
|
||||
except TBankPaymentRequestError as exc:
|
||||
logger.exception(
|
||||
"payment_init_rejected",
|
||||
order_uuid=request.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
|
||||
@@ -192,8 +214,52 @@ class AggregatorService:
|
||||
"Payment initialization is temporarily unavailable."
|
||||
) from exc
|
||||
|
||||
await self._persist_order(request=request, payment_url=payment_url)
|
||||
return InitPaymentResponse(payment_url=payment_url)
|
||||
|
||||
async def _persist_order(
|
||||
self,
|
||||
*,
|
||||
request: InitPaymentRequest,
|
||||
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, payment_url=payment_url),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"order_persistence_failed",
|
||||
order_uuid=request.order_uuid,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_order_data(
|
||||
*,
|
||||
request: InitPaymentRequest,
|
||||
payment_url: str,
|
||||
) -> OrderData:
|
||||
payload = request.model_dump(mode="json")
|
||||
return OrderData(
|
||||
order_uuid=request.order_uuid,
|
||||
payment_url=payment_url,
|
||||
price=request.price,
|
||||
delivery_type=request.type,
|
||||
tariff_code=request.tariff_code,
|
||||
sender=payload["sender"],
|
||||
recipient=payload["recipient"],
|
||||
from_location=payload["from_location"],
|
||||
to_location=payload["to_location"],
|
||||
packages=payload["packages"],
|
||||
services=payload["services"],
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
async def _get_provider_prices(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user