Files
g2s-aggregator/spec/overview.md
T
2026-05-13 16:35:22 +03:00

21 KiB
Raw Blame History

spec/overview.md

Контекст, специфичный для проекта агрегатора служб доставки. Глобальные правила определены в AGENTS.md.


Проект

Название: Агрегатор служб доставки (бэкенд) Язык: Python 3.14 Назначение: Агрегировать расчёты стоимости доставки от нескольких служб и отдавать единый ответ фронтенду через унифицированный API.


Продуктовые требования

  • Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
  • Поддерживать необязательный параметр parcel_type в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
  • Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для from_location.address и to_location.address перед инициализацией оплаты доставки
  • Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
  • Принимать сумму оплаты в поле price в копейках
  • После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
  • Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
  • При получении валидного уведомления TBank со статусом CONFIRMED находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
  • Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
  • Выбирать сервис подсказок адреса по country_code через маппинг стран в конфиге
  • Для RU, BY и KZ, сопоставленных с provider id dadata, использовать dadata.ru
  • Для AM, AZ, KG, MD, TJ, TM и UZ, сопоставленных с provider id yandex_geosuggest, использовать Yandex Geosuggest
  • Для европейских стран, сопоставленных с provider id tomtom, использовать TomTom Search API Fuzzy Search
  • Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов
  • Опрашивать всех зарегистрированных провайдеров параллельно
  • Возвращать унифицированный список тарифов, отсортированных по цене
  • Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
  • Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг
  • Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения
  • Для расчёта стоимости использовать локальный справочник cities_map с provider-specific данными города; для CDEK брать cdek.code без обращения к API подсказок городов

Провайдеры

Фаза 1

  • CDEKhttps://apidoc.cdek.ru/
    • Аутентификация: OAuth2 (client credentials)
    • Операции: расчёт тарифа
    • Cache TTL: 15 минут
  • TBankhttps://securepay.tinkoff.ru/v2/Init
    • Аутентификация: TerminalKey и token на основе password
    • Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений

Фаза 2+

  • Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс DeliveryProvider без изменений в логике агрегации

Компоненты

Controller (app/controllers/v1/delivery.py)

  • POST /api/v1/delivery/price — принимает DeliveryCalculationRequest, возвращает list[DeliveryPrice]
  • POST /api/v1/delivery/suggest-address — принимает AddressSuggestRequest, возвращает list[AddressSuggestion]
  • POST /api/v1/delivery/order — принимает InitPaymentRequest, возвращает InitPaymentResponse
  • POST /api/v1/delivery/tbank/notifications — принимает TBankPaymentNotification, возвращает plain text OK при успешной обработке
  • Парсинг и валидация входных данных через Pydantic
  • Маппинг исключений сервиса в HTTP-ответы
  • Каждый endpoint вызывает ровно один метод Service: AggregatorService.get_all_prices(), AggregatorService.suggest_addresses(), AggregatorService.init_payment() или AggregatorService.handle_tbank_payment_notification()

Service (app/services/aggregator.py)

  • AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]
  • Распределяет запросы по всем зарегистрированным провайдерам через asyncio.gather(..., return_exceptions=True)
  • Принимает от каждого провайдера список тарифов и объединяет их в единый список
  • Фильтрует упавшие результаты
  • Сортирует тарифы по цене
  • AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]
  • AggregatorService.suggest_addresses() выбирает address suggestion provider по country_code через injected config mapping и оркестрирует ровно один adapter call
  • AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse
  • AggregatorService.init_payment() оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с payment_url через injected order repository; ошибка сохранения логируется, но не блокирует возврат payment_url
  • AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str
  • AggregatorService.handle_tbank_payment_notification() оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе CONFIRMED
  • Повторное валидное уведомление CONFIRMED для заявки с уже сохранённым cdek_order_uuid не должно повторно создавать заказ в CDEK
  • Валидные уведомления TBank со статусами, отличными от CONFIRMED, подтверждаются ответом OK без обращения к CDEK
  • Service не содержит бизнес-логики и provider HTTP-деталей

Business Logic (app/domain/)

  • Правила сортировки и фильтрации тарифов
  • Правила фильтрации тарифов по parcel_type
  • Логика сравнения цен
  • Правила применения конфигурируемого мультипликатора к ценам провайдеров
  • Округление цены после применения мультипликатора до целого значения по детерминированному правилу
  • Правило выбора действия для TBank payment notification по статусу платежа
  • Нормализация входных данных (например, идентификаторов городов и правил округления веса)
  • Чистые функции, без IO, без зависимостей от фреймворков

Repository (app/repositories/cache/)

  • PriceCache — хранилище ключ/значение на базе Redis
  • Операции: get(key), set(key, value, ttl), invalidate(key)
  • Без бизнес-решений; формирование ключа — ответственность Adapter

Repository (app/repositories/order/)

  • OrderRepository — сохранение данных заявки в PostgreSQL
  • Операции: create_order(session, order_data) — сохраняет запись заявки с данными InitPaymentRequest и payment_url
  • Операции: get_order_by_order_uuid(session, order_uuid) — получает сохранённую заявку для payment notification flow
  • Операции: mark_payment_status(session, order_uuid, status, payment_id) — сохраняет последний статус платежа TBank
  • Операции: mark_cdek_order_registered(session, order_uuid, cdek_order_uuid) — сохраняет UUID заказа CDEK после успешной регистрации
  • SQLAlchemy ORM model таблицы orders в models.py
  • Без бизнес-решений; только CRUD-примитивы

Adapter (app/adapters/delivery_providers)

  • base.py — абстрактный интерфейс DeliveryProvider:
    class DeliveryProvider(ABC):
        name: str
        async def get_prices(self, request: DeliveryCalculationRequest) -> list[DeliveryPrice]: ...
    
  • cdek/client.py — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
  • cdek/auth.py — управление OAuth2-токеном
  • cdek/mapper.py — ответ CDEK → list[DeliveryPrice]
  • cdek/order_mapper.py — request/response mapping для CDEK order contract; публичный init-payment flow не вызывает регистрацию заказа в CDEK
  • Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
  • Для расчёта тарифа CDEK adapter принимает city identifiers из DeliveryCalculationRequest, находит запись в cities_map, берёт cdek.code и передаёт его в CDEK API

Для CDEK order contract mapper принимает InitPaymentRequest, сериализует sender.phone и recipient.phone в provider payload phones с одним элементом, не отправляет services при отсутствии значения, конвертирует packages[*].weight из килограммов в граммы и возвращает entity.uuid строкой.

Adapter (app/adapters/tbank)

  • base.py — исключения TBank payment adapter
  • client.pyTBankAdapter
  • TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str инициализирует платёж TBank и возвращает payment URL
  • TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая Token и вложенные объекты
  • TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling
  • TBank adapter владеет собственной секцией конфигурации tbank_payment с полями init_url, auth.terminal_key, auth.password, timeout_seconds, retry_attempts, retry_backoff_seconds

Adapter (app/adapters/postgres)

  • Управление подключением к PostgreSQL: создание AsyncEngine и async_sessionmaker из конфигурации
  • Инкапсулирует инфраструктурные детали SQLAlchemy async engine
  • Владеет собственной секцией конфигурации postgres с обязательным полем dsn
  • Без SQL-запросов, без бизнес-логики

Adapter (app/adapters/address_suggestions)

  • base.py — абстрактный интерфейс AddressSuggestionProvider:
    class AddressSuggestionProvider(ABC):
        name: str
        async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: ...
    
  • dadata/client.py — HTTP-клиент dadata.ru для address suggestions
  • yandex_geosuggest/client.py — HTTP-клиент Yandex Geosuggest GET https://suggest-maps.yandex.ru/v1/suggest
  • tomtom/client.py — HTTP-клиент TomTom Search API Fuzzy Search GET https://api.tomtom.com/search/2/search/{query}.json
  • provider-specific модули address suggestion adapters инкапсулируют внешние API-контракты, auth, serialization и error handling
  • В конфиге adapter layer хранится маппинг country_code -> provider_id для выбора address suggestion provider
  • Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service
  • Для Yandex Geosuggest adapter возвращает postal_code=None, так как контракт Geosuggest не используется как источник почтового индекса
  • Для TomTom Search adapter использует request.city и request.query для формирования search query, передаёт countrySet=request.country_code, typeahead=true, idxSet=PAD,Addr,Str,EPP, а postal_code берёт из address.postalCode, если оно присутствует

Reference Data

  • cities_map содержит city identifier и provider-specific данные города для price flow.
  • Справочник может быть частично заполнен; отсутствие provider-specific данных для города должно обрабатываться детерминированно.
  • Структура справочника должна допускать появление дополнительных провайдеров без изменения публичного API расчёта.

Модели данных

Входная: DeliveryCalculationRequest

entity: Enum(individual, legal)
from_city: int
to_city: int
weight_kg: float
length_cm: float
width_cm: float
height_cm: float
parcel_type: Literal[doc, parcel] | None

Выходная: DeliveryPrice

provider: str
service_name: str
price: Decimal
currency: str
delivery_days_min: int
delivery_days_max: int

Входная: AddressSuggestRequest

country_code: str
city: str
query: str
limit: int | None

Выходная: AddressSuggestion

address: str
street: str | None
house: str | None
flat: str | None
postal_code: str | None

Входная: InitPaymentRequest

order_uuid: str
price: int
type: Literal[2]
tariff_code: Literal[535]
comment: str | None
sender:
  name: str
  email: str
  phone: {number: str}
recipient:
  name: str
  email: str
  phone: {number: str}
from_location:
  address: str
  city: str
  country_code: str
to_location:
  address: str
  city: str
  country_code: str
services: list[{code: str, parameter: str}] | None
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]

price задаётся в копейках, является обязательным целым числом и должен быть больше 0. from_location.address и to_location.address должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup. sender.phone и recipient.phone представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается. packages[*].weight в InitPaymentRequest задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload.

Выходная: InitPaymentResponse

payment_url: str

Входная: TBankPaymentNotification

TerminalKey: str
OrderId: str
Success: bool
Status: str
PaymentId: int
ErrorCode: str
Amount: int
Token: str

PaymentId приходит из TBank как JSON-число (long integer) и валидируется как положительное целое. Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме Token, добавляет Password из tbank_payment.auth.password, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase true/false) и сравнивает SHA-256 hash с Token. Вложенные объекты, включая Data и Receipt, не участвуют в проверке token.

Выходная: TBank notification response

OK

Успешно обработанное HTTP-уведомление TBank возвращает HTTP 200 и plain text body OK.


Технологический стек

Задача Инструмент
Web-фреймворк FastAPI
HTTP-клиент httpx (async)
Валидация Pydantic v2
Кеш Redis
База данных PostgreSQL (asyncpg + SQLAlchemy 2.0 async)
Миграции Alembic (async)
Конфигурация pydantic-settings
Сервер Uvicorn

Структура проекта

app/
├── controllers/
│   ├── http_client.py
│   └── v1/
│       └── delivery.py        # Controller
├── services/
│   └── aggregator.py          # Service
├── domain/
│   └── price.py                  # Business Logic
├── adapters/
│   ├── address_suggestions/
│   │   ├── base.py               # Интерфейс Adapter
│   │   ├── dadata/
│   │       └── client.py
│   │   ├── yandex_geosuggest/
│   │       └── client.py
│   │   └── tomtom/
│   │       └── client.py
│   └── delivery_providers/
│       ├── base.py                    # Интерфейс Adapter
│       └── cdek/
│           ├── client.py
│           ├── auth.py
│           ├── mapper.py
│           └── order_mapper.py
│   └── tbank/
│       ├── base.py
│       └── client.py
│   └── postgres/
│       └── engine.py                  # AsyncEngine & session factory
├── repositories/
│   ├── cache/
│   │   └── redis_cache.py             # Repository (Redis)
│   └── order/
│       ├── models.py                  # SQLAlchemy ORM model
│       └── repository.py             # Repository (PostgreSQL)
├── schemas/
│   ├── address.py
│   ├── request.py
│   ├── response.py
│   └── payment.py
├── config.py
alembic/                               # Alembic migrations
alembic.ini

Инфраструктура

# docker-compose сервисы
app        # FastAPI-приложение
redis      # Кеш тарифов
postgres   # База данных заявок