Добавлено сохранение заказов в postgres

This commit is contained in:
Раис Юсупалиев
2026-04-18 00:33:45 +03:00
parent 2b201a08be
commit bddac60965
38 changed files with 971 additions and 17 deletions
View File
+16
View File
@@ -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)
+15
View File
@@ -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
+76 -4
View File
@@ -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)}"
+6
View File
@@ -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]
+6
View File
@@ -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,
+5
View File
@@ -0,0 +1,5 @@
"""Order repository exports."""
from app.repositories.order.repository import OrderData, OrderRepository
__all__ = ("OrderData", "OrderRepository")
+63
View File
@@ -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,
)
+53
View File
@@ -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
+66
View File
@@ -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,
*,