← Все проекты уровня 10
Уровень 10 · Связь с другими сервисамиВариант A

Отслеживание доставок

Единая история доставки из двух провайдеров с опросом, проверенными webhook-событиями и защитой от устаревшего статуса.

Техническое задание

Модель данных и назначение

Разработайте Go REST API трекинга отправлений, интегрирующий два внешних сервиса через интерфейсы; детерминированные тесты используют управляемые эмуляторы обоих провайдеров. Shipment хранит id, owner_id, provider, external_id, registration_state (pending, registered, failed), normalized_state, provider_state, last_event_at, last_polled_at, stale, created_at, updated_at. Отказ регистрации меняет только registration_state на failed и сохраняет безопасный registration_error_code; normalized_state до регистрации равен null, а после неё содержит ровно одно из шести состояний created, in_transit, out_for_delivery, delivered, exception, cancelled. Переменная окружения API_TOKENS содержит JSON-отображение Bearer-токенов в owner_id; клиент передаёт token в Authorization: Bearer. owner_id никогда не принимается в теле. Запрос без токена или с неизвестным токеном возвращает 401. Для каждого из двух зарегистрированных провайдеров нормализуются ровно состояния created, in_transit, out_for_delivery, delivered, exception и cancelled; неизвестный provider даёт 400. Каждое внешнее событие записывается в неизменяемую историю с provider_event_id, occurred_at и уникальностью (provider, provider_event_id). Переходы отражаются только если событие новее последнего принятого; при одинаковом occurred_at раньше обрабатывается событие с лексикографически меньшим provider_event_id. delivered и cancelled терминальны: любые последующие события, включая исправления провайдера, сохраняются как ignored и не меняют normalized_state.

API, проверки и эксплуатация

POST /api/shipments требует Bearer-токен и принимает только provider и trackingNumber, валидирует их, сохраняет задание регистрации и отвечает 202 с id созданной доставки и URL состояния; GET /api/shipments возвращает только доставки владельца с limit 1–100 и cursor; отказ регистрации провайдера устанавливает registration_state=failed, не меняя отправленный HTTP-ответ. Повтор владельцем того же provider/trackingNumber идемпотентен и возвращает существующую доставку. GET /api/shipments/{id} требует Bearer-токен и возвращает оба поля registration_state и normalized_state; registration_error_code присутствует только при registration_state=failed. GET /api/shipments/{id}/events также требует Bearer-токен; владелец получает данные, существующий чужой ресурс — 403, неизвестный id — 404. Те же правила действуют на список и создание. Список имеет limit 1–100 и cursor, сортировку updated_at DESC,id. POST /api/providers/{provider}/webhooks принимает необработанные байты и проверяет HMAC-SHA256 по отдельному PROVIDER_WEBHOOK_SECRET из окружения с сравнением за постоянное время; тело не более 1 MiB. Неверная подпись — 401; провайдер определяется доверенным маршрутом, а не payload. Валидное повторное событие получает 200 с duplicate=true без повторного эффекта. Невалидная схема — 400.

Опрос выполняется раз в 60 секунд одним фоновым исполнителем; каждый запрос к адаптеру имеет тайм-аут 10 секунд и отменяется вместе с контекстом. Временная ошибка повторяется после пауз 10 и 30 секунд, не более трёх попыток. После 15 минут без принятого события поле stale становится true. Секреты и tracking number не пишутся в журнал. Обработка события, защита от дублей, история и состояние доставки обновляются в одной транзакции. Событие с более ранним occurred_at сохраняется как историческое с причиной ignored, но не меняет текущее состояние. Данные опроса и задания сохраняются в PostgreSQL и обрабатываются после перезапуска; параллельные обновления одной доставки сериализуются. Все клиентские ошибки имеют безопасный JSON-формат. Контексты всех SQL-вызовов имеют тайм-аут, HTTP-сервер ограничивает соединения/тела и выполняет graceful shutdown. Индексы обслуживают owner/cursor и dedup, запрос событий не делает N+1.

Структура программы

Код разделён на небольшие internal-пакеты: internal/httpapi обрабатывает API и подписи webhook, internal/shipment задаёт нормализацию и переходы, internal/providers содержит два адаптера, internal/polling выполняет опрос и повторы. main.go связывает компоненты; ctx передаётся исполнителю и адаптерам, os.Exit не используется.

Критерии готовности

Ожидаемый результат

Поставка

  • Запускаемый Go REST API, PostgreSQL-миграции и README описывают два адаптера, статусы доставки, опрос, подпись webhook и владение.
  • Настроенные Bearer-токены API связаны с ownerID; тестовые эмуляторы провайдеров управляют HTTP-тестами без сети и реальных секретов.

Приёмка

  • Повтор provider_event_id даёт один эффект; событие с меньшим временем сохраняется как ignored и не откатывает состояние.
  • Событие после delivered или cancelled остаётся в истории, но не меняет терминальный state.
  • Неверная HMAC-подпись возвращает 401 без записи; корректная подпись принимает webhook с кодом 200.
  • Медленный тестовый эмулятор провайдера прерывается через заданный тайм-аут; после срока свежести API возвращает stale=true.
  • GET доставки показывает registration_state и normalized_state; отказ регистрации устанавливает registration_state=failed, normalized_state остаётся null.
  • Bearer-токен другого owner получает 403, отсутствующий shipment — 404; polling возобновляется после рестарта.
  • README показывает зависимости; переходы состояния тестируются без HTTP/БД, адаптеры проверяются эмуляторами, webhook и хранилище — отдельно.