Модель данных и назначение
Создайте Go REST API симулятора платежей, подключённого только к локальному эмулятору; реальные платежи запрещены. PostgreSQL с миграциями хранит состояние. Переменная окружения API_TOKENS содержит JSON-отображение Bearer-токенов в owner_id; token передаётся в Authorization: Bearer, owner_id никогда не принимается в теле. Запрос без токена или с неизвестным токеном возвращает 401. Order содержит id, owner_id, amount_minor, currency=RUB, state, idempotency_key, provider_reference, created_at, updated_at. amount_minor — целое от 1 до 1,000,000,000,000 копеек. Payment хранит order_id, provider, provider_payment_id, state и timestamps. WebhookEvent хранит provider_event_id, signature_valid, received_at и результат обработки; уникальность задаётся парой (provider, provider_event_id).
API, проверки и эксплуатация
POST /api/orders требует Bearer-токен и принимает amountMinor и idempotencyKey, создаёт заказ с кодом 201. Ключ уникален в пределах owner_id, длиной 1–128 символов. Сумма от 1 до 1,000,000,000,000 копеек, валюта всегда RUB; повтор того же ключа с той же суммой возвращает существующий заказ с кодом 200, с другой суммой — 409 idempotency_conflict. POST /api/orders/{id}/pay разрешён владельцу, запускает эмулятор не более чем на 60 секунд и переводит created в processing; ответ 202, повтор не запускает вторую оплату. GET /api/orders/{id} требует Bearer-токен и возвращает состояние владельцу; существующий чужой заказ — 403, отсутствующий id — 404. POST /api/orders/{id}/cancel переводит только created в cancelled и отвечает 200; для других состояний — 409. Подтверждённое событие переводит processing в paid, отклонённое — в failed; paid, failed и cancelled терминальны. Повторные и пришедшие не по порядку события сохраняются как ignored и не создают второй эффект.
POST /api/webhooks/fake-provider принимает необработанное тело запроса с полями eventId, orderId, providerPaymentId, sequence, outcome, amountMinor, currency и providerReference, а также подпись HMAC-SHA256. Подпись проверяется сравнением за постоянное время по отдельному FAKE_PROVIDER_WEBHOOK_SECRET из окружения; тело ограничено 1 MiB, неизвестные поля и outcome кроме approved/declined дают 400. Bearer API token для webhook не используется. Неверная подпись — 401 без записи бизнес-события; валидное неизвестное/невалидное событие — 400; валидный дубль — 200 duplicate=true. При успешной проверке событие, маркер дедупликации, переход платежа и заказа и одна запись эффекта фиксируются одной транзакцией. Повторная доставка события с тем же идентификатором не создаёт повторный побочный эффект. Событие с порядковым номером, который не превышает последний принятый, сохраняется как ignored и не меняет текущее состояние. Подтверждённый платёж должен точно равняться order.amount_minor и иметь currency=RUB; частичные платежи запрещены. Ссылка провайдера, amountMinor и currency сверяются с заказом; несовпадение даёт 409 payment_mismatch без изменения заказа. Эмулятор задаёт approved/declined/тайм-аут и порядковые номера событий; порядковый номер должен строго расти, иначе событие сохраняется как ignored. Вызов ограничен 60 секундами и учитывает отмену контекста. Состояние processing и эффект хранятся в PostgreSQL транзакционно; после перезапуска незавершённая оплата безопасно сверяется с эмулятором без повторного эффекта. Используйте контекстные тайм-ауты SQL и индексы по ключу идемпотентности и событию. Неверный ввод даёт 400, отсутствующая запись — 404, конфликт состояния — 409, ошибка эмулятора — 502. Не выводите секреты, подписи, токены или платёжные данные в логи. README указывает, что провайдер тестовый.
Структура программы
В internal/httpapi проверяются токен и подпись; internal/payments задаёт суммы и переходы; internal/provider содержит эмулятор; internal/workers восстанавливает оплаты. main.go связывает компоненты; ctx передаётся исполнителю, os.Exit не используется.