Биллинг подписок: идемпотентное создание заказа
Анна нажала «Оформить Team», сервер создал заказ, но соединение оборвалось до ответа. Браузер не знает, завершилась ли команда, и повторяет запрос. Если каждый POST создаёт новую строку, у компании появятся два намерения покупки. Для финансового пути повтор должен быть определён до обращения к провайдеру.
В «Пространстве» клиент создаёт Idempotency-Key для одного намерения и сохраняет его до получения результата. Сервер ограничивает ключ компанией, связывает его с нормализованным содержимым и возвращает тот же заказ при повторе. Полный код находится в BillingService.cs архива; повторные запросы при подготовке не выполнялись.
Один ключ обозначает одно намерение
Ключ не должен создаваться заново на каждый сетевой retry. Тогда сервер увидит разные команды и законно создаст разные заказы. Новый ключ нужен после осознанного выбора другой покупки, а не после отсутствующего ответа. В интерфейсе его удобно хранить вместе с состоянием формы оформления.
Целый пример HTTP-команды выглядит так. Цена отсутствует, потому что сервер получает её из каталога предыдущего урока. Компания определяется маршрутом и проверенным членством.
POST /orgs/11111111-1111-1111-1111-111111111111/billing/orders
X-Dev-User: anna
Idempotency-Key: checkout-team-2026-01
Content-Type: application/json
{"planCode":"team","planVersion":1}
Ключ ограничен по длине и допустимому формату, чтобы не превращаться в бесконечный текст журнала. Он не является паролем и не даёт доступ к чужому результату. При повторе Борисом в «Севере» tenant-фильтр всё равно отклонит запрос. При том же ключе в «Юге» создаётся независимая команда, потому что первичный ключ включает компанию.
Хеш относится к значению, а не пробелам JSON
Два JSON-документа могут описывать одинаковую покупку, но различаться порядком свойств и пробелами. Поэтому сервер не хеширует произвольные байты исходного тела как единственное доказательство намерения. После десериализации он составляет стабильное представление полей DTO и вычисляет SHA-256.
Ниже приведён законченный фрагмент получения отпечатка из BillingService. В нём перечислены все поля, которые меняют предметное намерение этой команды. При добавлении нового значимого поля нужно обновить и эту форму.
var canonical = JsonSerializer.Serialize(new {
planCode = request.PlanCode,
planVersion = request.PlanVersion
});
var hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
Если найден тот же ключ и тот же hash, сервер возвращает существующий orderId. Если hash другой, ответ 409 с кодом idempotency_conflict. Молча менять содержимое команды нельзя: потерявшийся ответ мог относиться к первоначальной покупке, и клиент не узнает, какую из двух версий сервер принял.
Нормализация не должна стирать важный смысл. Можно привести код тарифа к договорённому регистру, если каталог тоже использует этот контракт. Нельзя игнорировать planVersion только потому, что code остался team. Версии могут иметь разные цены; такой повтор уже является другой покупкой.
Уникальность защищает параллельные повторы
Таблица команд имеет ключ (organization_id,key) и ссылку на заказ той же компании. В одной транзакции сервер проверяет либо создаёт команду, создаёт заказ и платежное намерение. Полный пример использует блокировку организации для небольшого потока оформления; уникальный ключ остаётся обязательной последней защитой.
Представьте два одинаковых запроса, пришедших одновременно. Простая схема «сначала SELECT, если нет — INSERT» позволяет обоим увидеть отсутствие. Ограничение уникальности заставит одну запись стать исходной, а вторую прочитать её результат по тому же контракту. PostgreSQL INSERT описывает ON CONFLICT; в нашем проекте он используется осознанно, без скрытого перезаписывания hash.
Важно не подавлять любое исключение как «это повтор». Ошибка подключения, неверная схема и конфликт другого ограничения не доказывают существование завершённой команды. Обработчик должен различать ожидаемую уникальность ключа и настоящий сбой. Иначе клиент получит случайный orderId либо успешный ответ без сохранённой покупки.
Запись команды сохраняется достаточно долго, чтобы повтор после перезапуска вернул результат. В учебном примере она остаётся вместе с заказом. Если очищать ключи через час, повтор старой команды через два часа снова создаст покупку. Срок хранения поэтому является частью внешнего контракта, а не только оптимизацией таблицы.
Локальная транзакция заканчивается до провайдера
Создание заказа и платежного намерения фиксируется до сетевого вызова. Это позволяет после сбоя увидеть, какая операция должна быть отправлена. Если держать транзакцию открытой до ответа провайдера, долгий запрос удержит блокировки, а rollback всё равно не отменит уже созданный внешний платеж.
После commit сервис передаёт адаптеру отдельный provider key, связанный с paymentId. Он не обязан совпадать с клиентским ключом оформления. Клиентская идемпотентность относится к заказу внутри компании, провайдерская — к одной попытке внешнего создания. Их явное разделение понадобится, если один заказ когда-нибудь получит новую попытку после отменённой.
При потере ответа между commit и созданием платежа повтор команды находит тот же paymentId и продолжает отправку. При потере ответа после провайдера используется тот же provider key. Таким образом, разные промежутки неопределённости закрываются сохранённым локальным состоянием, а не надеждой на одно безошибочное выполнение функции.
Для ручного наблюдения повторите HTTP-команду с прежним ключом трижды. Ожидается один orderId и одна сумма. Затем поменяйте planVersion с тем же ключом: ожидается 409, а исходный заказ не меняется. Наконец создайте новый ключ для другого осознанного намерения: это уже отдельная команда, и её наличие не является нарушением идемпотентности.
Два разных ключа тоже имеют предметную границу
Идемпотентность не запрещает владельцу отправить два новых ключа. Поэтому первоначальная покупка дополнительно проверяет, нет ли у компании уже действующего оплаченного периода или открытого первоначального заказа. В этом случае ответ объясняет существующее намерение вместо создания конкурирующей подписки. Это правило отличается от сравнения hash: одинаковый key распознаёт повтор транспорта, а поиск открытого initial-заказа предотвращает другую покупку, которая противоречит договору одной подписки. При canceled-попытке заказ закрывается и новый осознанный ключ снова может начать оформление. Если покупка потеряла ответ, интерфейс сначала продолжает прежний ключ, а не обходит предметный отказ новым значением.
Теперь потеря ответа не заставляет сервер угадывать, новый это заказ или повтор. Следующий урок добавит адаптер создания платежа, сохранив границу между локальным commit и внешним подтверждением.