Модель данных и назначение
Разработайте 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 не используется.