Один слот, два покупателя: как избежать двойной брони в Telegram-боте

У бота остался один слот на онлайн-консультацию: четверг, 18:00. Два клиента нажимают «Оплатить» почти одновременно. Оба обработчика видят в базе свободный слот и разрешают оплату. Telegram не знает, что за двумя счетами стоит одна запись в календаре. Проверить наличие места и временно занять его должен сам бот.

Разберём разовую оплату цифровой услуги внутри бота через Telegram Stars. У слота может быть несколько попыток покупки, но только одна подтверждённая бронь. В этой схеме на каждую новую попытку после истечения резерва создаём новый заказ и новый счёт. Это нужно, чтобы запоздалая оплата старого счёта не подтвердила новый резерв, даже если его занял тот же человек.

Что должно успеть до оплаты

Передаём в счёт идентификатор заказа в поле payload. В pre_checkout_query Telegram возвращает его как invoice_payload. По нему бот находит запись, проверяет плательщика, сумму и валюту XTR, затем пытается временно занять связанный слот. Если слот уже занят, бот отвечает ok: false и объясняет причину: «Это время уже заняли. Выберите другой слот».

Бот должен успеть ответить через answerPreCheckoutQuery за 10 секунд с момента, когда Telegram отправил запрос. В этот бюджет входят доставка обновления, работа базы и вызов API. Поэтому синхронизацию с внешним календарём и уведомления выносим за пределы проверки. Если база не успела подтвердить резерв, не отправляем ok: true; короткий тайм-аут блокировки помогает оставить время на явный отказ.

Резервируем слот под блокировкой строки

У каждого слота одна строка. Внутри короткой транзакции в PostgreSQL сначала блокируем её через SELECT … FOR UPDATE, затем отдельной командой пытаемся поставить резерв. Вторая команда выполняется только если первая нашла слот. Условие разрешает занять свободный слот или заменить истёкший резерв, пока окончательной брони ещё нет.

SELECT id FROM slots WHERE id = $1 FOR UPDATE;
UPDATE slotsSET hold_order_id = $1, hold_until = statement_timestamp() + interval '5 minutes'WHERE id = $2 AND booked_order_id IS NULL AND (hold_order_id IS NULL OR hold_until <= statement_timestamp())RETURNING id, hold_until;

В SELECT передаём только ID слота. В UPDATE $1 — ID заказа, $2 — ID слота. В режиме READ COMMITTED второй покупатель дождётся завершения первой транзакции, увидит действующий резерв и не получит строки из RETURNING. В рабочем боте нужно также проверить, что время консультации ещё не наступило.

Почему две отдельные команды? statement_timestamp() фиксируется при получении команды сервером. Если взять это время до ожидания блокировки, решение о сроке резерва может опираться на устаревшие часы. Здесь время берётся после захвата строки, а одно и то же значение используется и для проверки срока, и для расчёта нового. Пять минут — условный срок резерва в приложении; это не срок доставки платежа, обещанный Telegram.

В той же транзакции сохраняем результат под уникальным pre_checkout_query.id. В полной схеме сначала блокируем заказ, затем слот; этого порядка придерживаемся и при подтверждении оплаты. Резерв может получить только заказ, по которому ещё не было одобрения. Повтор того же запроса читает сохранённое решение и не продлевает резерв. Положительный ответ можно повторить, только пока резерв действителен и ответ ещё имеет смысл в пределах срока Telegram. Другой запрос с тем же заказом не захватывает слот повторно. Если резерв истёк, покупателю нужен новый счёт, даже если слот снова свободен.

Сначала фиксируем резерв и решение в базе, затем отвечаем Telegram. При тайм-ауте ответа бот не знает, получил ли Telegram подтверждение. Не освобождаем резерв досрочно только из-за этого тайм-аута. Если платёж придёт после его истечения, обрабатываем его по отдельному правилу.

Окончательно бронируем по successful_payment

ok: true разрешает продолжить оплату, но ещё не означает, что она прошла. Когда приходит message.successful_payment, снова сверяем заказ, сумму, валюту и плательщика. При расхождении сохраняем факт оплаты для ручной сверки, бронь не выдаём. Запись платежа по уникальному telegram_payment_charge_id (в общей таблице — вместе с ID бота) и решение по брони сохраняем в одной транзакции. Повтор того же платежа не создаёт вторую бронь или вторую задачу на возврат.

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

Задачу на возврат сохраняем в той же транзакции, что и платёж, а refundStarPayment вызываем после коммита по ID плательщика и платежа. При тайм-ауте держим статус «возврат проверяется». Сверить результат можно по записи возврата в getStarTransactions: у неё тот же ID, что у исходной операции, поэтому одного совпадения ID мало — нужно проверить, что это именно исходящий возврат. Пользователю показываем «Возврат завершён» после подтверждения.

Событие

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

Два разных заказа одновременно проходят pre_checkout_query для одного слота

Только один получает действующий резерв; второй получает отказ

То же обновление доставлено дважды

Повтор не создаёт новый резерв и не увеличивает срок старого

successful_payment приходит дважды для одного платежа

Одна запись оплаты, одно решение по брони

У первого заказа истёк резерв, второй уже занял слот, затем пришла оплата первого

Бронь второго сохранена; оплата первого зафиксирована для возврата

Первый покупатель забронировал тот же слот новым заказом, затем прошла оплата старого

Старый платёж не подтверждает новый резерв

Вызов answerPreCheckoutQuery оборвался по тайм-ауту

Резерв не снимается досрочно; система ждёт платёж или истечение срока

Как воспроизвести гонку в двух сессиях

В отдельной тестовой базе создадим общую для двух сессий таблицу и одну строку. TEMP TABLE здесь не подойдёт: вторая сессия её не увидит.

CREATE TABLE slots ( id bigint PRIMARY KEY, hold_order_id bigint, hold_until timestamptz, booked_order_id bigint);INSERT INTO slots (id) VALUES (1);
  1. В сессии A выполните BEGIN ISOLATION LEVEL READ COMMITTED;, затем SELECT id FROM slots WHERE id = 1 FOR UPDATE; и UPDATE из примера с $1 = 101, $2 = 1. Запрос вернёт одну строку. Пока не делайте коммит.

  2. В сессии B откройте такую же транзакцию и запустите тот же SELECT … FOR UPDATE. Он будет ждать. Сделайте COMMIT; в A. Теперь запрос в B завершится, и B получит блокировку.

  3. В B выполните UPDATE с $1 = 102, $2 = 1. Пока резерв A действует, RETURNING вернёт ноль строк. Закройте транзакцию в B. Если подставляете значения вручную в psql, замените $1 и $2 числами перед выполнением.

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

Источники: Telegram: платежи за цифровые услуги, answerPreCheckoutQuery, SuccessfulPayment, refundStarPayment, StarTransaction, PostgreSQL: блокировки строк, PostgreSQL: функции времени.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев