Перейти к содержанию

Bitcoin-платежи: выдача доступа и transactional outbox

Денежная политика может признать счёт достаточным, но право доступа ещё нужно создать в системе библиотеки. Если записать «оплачено» в одной базе, а затем отправить внешнему сервису HTTP-запрос, между действиями возможен сбой. Платёж останется признанным, а материал не откроется — либо повтор создаст выдачу дважды.

Для учебного проекта используем transactional outbox. Вместе с решением в локальной SQL-транзакции сохраняем задачу доставки. Отдельный обработчик позже выполняет её по устойчивому ключу. Код и SQL в этом уроке служат исходниками модели и сейчас не запускаются.

Разделяем решение и выполнение

Запись payment_qualified сообщает, что условия счёта выполнены. Запись access_granted сообщает, что система материалов действительно приняла выдачу. Между ними допустимо промежуточное состояние grant_pending. Интерфейс не должен скрывать его уверенным утверждением о доступности файла.

Ключ выдачи выбираем по заказу и виду операции, например grant:order_id. Если один заказ пережил несколько повторных наблюдений или реорганизаций, ключ первой выдачи не меняется. Иначе история технических повторов станет источником нескольких прав на один курс.

У пользователя могут существовать разные заказы того же курса. Коммерческая политика определяет, как они соотносятся, но доставка всё равно должна уметь отличать свою операцию. Не строим ключ только по email или названию курса: они могут измениться и не являются устойчивым идентификатором заказа.

Сохраняем outbox вместе с решением

Дополнительная таблица содержит минимальное сообщение, состояние доставки и число попыток. Не помещаем в неё ключи кошелька или полный пользовательский профиль. Задача относится к выдаче материала, а не к управлению средствами.

CREATE TABLE access_outbox (
    operation_key TEXT PRIMARY KEY,
    invoice_id TEXT NOT NULL,
    order_id TEXT NOT NULL,
    course_id INTEGER NOT NULL,
    user_id TEXT NOT NULL,
    decision_id TEXT NOT NULL,
    delivery_state TEXT NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    next_attempt_at INTEGER NOT NULL,
    FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

В транзакции квалификации вставляется решение и задача с уникальным operation_key. Если ключ уже присутствует с теми же неизменяемыми данными, повтор не создаёт новую доставку. Если данные расходятся, это конфликт модели, а не разрешение перезаписать другого получателя.

BEGIN
  save payment decision with its source snapshot
  insert grant operation if absent
  update invoice projection to grant_pending
COMMIT

После COMMIT отдельный обработчик получает задачу. Таким образом, падение между фиксацией решения и отправлением HTTP-запроса не теряет необходимость выдачи. Она уже сохранена и будет обнаружена при следующем цикле доставки.

Что означает идемпотентная выдача

Сервис материалов принимает операционный ключ и возвращает одно и то же право при повторе. Сам обработчик также проверяет его локальную запись, но только локальная проверка недостаточна: ответ внешнего сервиса может потеряться после фактического создания права.

Если внешняя система не поддерживает ключ повторения, нужно другое средство сверки по её устойчивому идентификатору. Нельзя обещать «ровно один раз» только потому, что отправитель пометил задачу завершённой в своей базе. Общая атомарность двух независимых систем здесь отсутствует.

В собственной учебной реализации хранилище прав может находиться в той же SQL-базе. Тогда выдача и решение допускают одну транзакцию без сети. Outbox становится полезным при отдельном сервисе или отправлении уведомления; не добавляем распределённую сложность, если всё уже находится в одной границе хранения.

Разбираем потерянный ответ

Обработчик отправил запрос выдачи, сервис создал доступ, но соединение оборвалось до ответа. Задача остаётся незавершённой и повторяется с прежним ключом. Сервис возвращает существующее право, а не создаёт новое. После сохранения подтверждённого результата задача получает delivered.

Если первый запрос вообще не дошёл, повтор с тем же ключом создаёт право впервые. Для пользователя итог одинаков: один доступ к курсу. Отличается только техническая история попыток, которая нужна для диагностики задержки.

Число попыток не заменяет смысл результата. HTTP timeout остаётся неизвестным исходом, как в седьмом уроке. После предела автоматических попыток задача переводится в служебный разбор, но квалифицированный платёж не становится неоплаченным только из-за недоступности системы материалов.

Согласуем выдачу с пересмотром цепочки

Предположим, реорганизация обнаружена до выполнения задачи. Сохранённая политика может приостановить выдачу, связав это действие с новым решением. Обработчик перед отправлением проверяет актуальную допустимость задачи, а не полагается только на старое сообщение в очереди.

Если реорганизация произошла после фактической выдачи, создаём отдельную компенсирующую операцию либо разбор. Её ключ отличается от ключа первой выдачи, и её смысл явно указан. Нельзя менять старое сообщение grant на revoke с тем же id, оставляя внешнему сервису неоднозначный повтор.

Между последней проверкой и внешним запросом всё равно существует окно гонки. Поэтому внешнее право и текущая денежная позиция согласуются через последующие события и сверку. Полностью устранить такую границу одним условием if в обработчике нельзя.

Что показывает карточка счёта

До квалификации интерфейс объясняет ожидание суммы или подтверждений. После решения показывает «Оплата учтена, готовим доступ». После подтверждённой выдачи появляется ссылка на курс. Эти состояния соответствуют реальным сохранённым этапам, а не анимации таймера.

Если доставка задержалась, пользователь может обновить карточку, но кнопка не создаёт новый заказ и не отправляет второй платёж. Служба поддержки видит decision id, операционный ключ и последнюю категорию отказа, не раскрывая credentials внутренних сервисов.

Уведомление не подтверждает выдачу

Письмо «Оплата получена» является отдельным сообщением пользователю, а не доказательством существования права доступа. Оно тоже может доставляться с задержкой или повтором. В интерфейсе ссылка на материал зависит от подтверждённого результата системы прав, а уведомление лишь объясняет уже сохранённый этап. Поэтому outbox для письма имеет свой ключ и не переводит задачу выдачи в завершённую.

При обращении в поддержку обе операции видны отдельно: деньги квалифицированы, право создано либо ожидает доставки, сообщение отправлено либо задержалось. Это предотвращает ложное заключение о готовности курса по одному успешному почтовому ответу.

Разбор результата

Квалификация платежа и выдача доступа теперь имеют согласованный, восстанавливаемый маршрут. Outbox хранится вместе с решением, а повтор доставки использует прежний ключ. Потерянный ответ не дублирует право, сбой внешнего сервиса не стирает денежное решение.

В следующем уроке разберём возврат как отдельную согласованную денежную операцию. Полное оглавление связывает его с дальнейшей сверкой и восстановлением.

Оглавление серии