21 KiB
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 iddadata, использоватьdadata.ru - Для
AM,AZ,KG,MD,TJ,TMиUZ, сопоставленных с provider idyandex_geosuggest, использовать Yandex Geosuggest - Для европейских стран, сопоставленных с provider id
tomtom, использовать TomTom Search API Fuzzy Search - Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов
- Опрашивать всех зарегистрированных провайдеров параллельно
- Возвращать унифицированный список тарифов, отсортированных по цене
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
- Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг
- Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения
- Для расчёта стоимости использовать локальный справочник
cities_mapс provider-specific данными города; для CDEK братьcdek.codeбез обращения к API подсказок городов
Провайдеры
Фаза 1
- CDEK — https://apidoc.cdek.ru/
- Аутентификация: OAuth2 (client credentials)
- Операции: расчёт тарифа
- Cache TTL: 15 минут
- TBank — https://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, возвращаетInitPaymentResponsePOST /api/v1/delivery/tbank/notifications— принимаетTBankPaymentNotification, возвращает plain textOKпри успешной обработке- Парсинг и валидация входных данных через 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 callAggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponseAggregatorService.init_payment()оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе сpayment_urlчерез injected order repository; ошибка сохранения логируется, но не блокирует возвратpayment_urlAggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> strAggregatorService.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-paymentflow не вызывает регистрацию заказа в 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 adapterclient.py—TBankAdapterTBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> strинициализирует платёж TBank и возвращает payment URLTBankAdapter.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 suggestionsyandex_geosuggest/client.py— HTTP-клиент Yandex GeosuggestGET https://suggest-maps.yandex.ru/v1/suggesttomtom/client.py— HTTP-клиент TomTom Search API Fuzzy SearchGET 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 # База данных заявок