Интеграция платежей в Telegram-боте: проблема идемпотентности и её решение

Автор: Javohir Abdullayev · · Telegram-боты

Интеграция платежей в Telegram-боте: проблема идемпотентности и её решение

Введение: неожиданная проблема в реальном проекте

Продажа товаров и услуг через Telegram-ботов сегодня стала очень популярной на рынке Узбекистана. Пользователь оформляет заказ внутри бота, оплачивает через Click или Payme и автоматически получает доступ к услуге. Однако в недавно запущенном боте по продаже электронных билетов возникла серьезная проблема: со счетов некоторых пользователей деньги списывались один раз, но бот генерировал два одинаковых билета, а в отдельных случаях средства списывались с баланса пользователя несколько раз.

В этой статье мы подробно разберем первопричину этой проблемы и на реальном примере кода рассмотрим, как сделать платежи на 100% безопасными и надежными в архитектуре бэкенда с помощью принципа идемпотентности (idempotency).

В чем была причина проблемы? (Root Cause Analysis)

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

  1. Повторные попытки провайдера (Retries): если ваш сервер не успевает вернуть успешный ответ (HTTP 200) на запрос платежной системы (например, Click или Payme) в течение 2–3 секунд или происходит сетевой сбой, сервер платежной системы отправляет точно такой же запрос транзакции повторно.
  2. Действия пользователя: при медленном интернет-соединении пользователь нажимает кнопку оплаты или подтверждения несколько раз подряд, что приводит к параллельным запросам.

Если ваш обработчик вебхуков принимает один и тот же запрос дважды и обрабатывает их одновременно параллельно, возникает состояние гонки (race condition). В результате пользователю предоставляются две услуги либо дублируется транзакция, что нарушает финансовую отчетность.

Решение: идемпотентность и распределенная блокировка (Distributed Lock)

Идемпотентность — это принцип, согласно которому независимо от того, сколько раз отправляется запрос в систему с одинаковыми параметрами, результат изменяется только один раз и всегда сохраняется одно и то же конечное состояние. Для обеспечения этого в платежных системах мы внедрили два уровня защиты:

  • Проверка статуса транзакции: для каждого запроса на оплату существует уникальный transaction_id. Если в базе эта транзакция уже была успешно выполнена, повторный запрос немедленно прерывается.
  • Блокировка на базе Redis (Distributed Mutex): чтобы два параллельных запроса не прошли проверку одновременно, с помощью Redis устанавливается блокировка на несколько секунд.

Пример реализации на практике: Python и Redis

Ниже представлена логика установки блокировки с помощью Redis в обработчике вебхуков на FastAPI или aiohttp:

import redis
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
r = redis.Redis(host='localhost', port=6379, db=0)

@app.post('/webhook/payme/')
async def payme_webhook(request: Request):
    data = await request.json()
    transaction_id = data.get('params', {}).get('id')
    order_id = data.get('params', {}).get('account', {}).get('order_id')

    if not transaction_id:
        raise HTTPException(status_code=400, detail='Отсутствует ID транзакции')

    # Устанавливаем блокировку на 10 секунд через Redis (Distributed Lock)
    lock_key = f'lock:payment:{transaction_id}'
    is_acquired = r.set(lock_key, 'locked', nx=True, ex=10)

    if not is_acquired:
        # Если эта транзакция уже обрабатывается другим потоком
        return {'error': {'code': -31008, 'message': 'Транзакция уже обрабатывается'}}

    try:
        # 1. Проверяем заказ в базе данных
        # 2. Если заказ уже оплачен, повторная оплата не производится
        # 3. Обновляем статус заказа и отправляем уведомление в Telegram
        return {'result': {'state': 2, 'transaction': transaction_id}}
    finally:
        # Освобождаем блокировку после завершения
        r.delete(lock_key)

Безопасность транзакций на уровне базы данных

Одного Redis недостаточно. Система должна стабильно работать, даже если кэш-сервер Redis временно недоступен. Для этого в реляционных базах данных, таких как PostgreSQL, необходимо использовать команду SELECT ... FOR UPDATE. Она блокирует строку заказа для других запросов до завершения текущей транзакции.

В Django ORM это реализуется следующим образом:

from django.db import transaction

@transaction.atomic
def process_payment(order_id, transaction_id):
    # select_for_update() удерживает другие запросы в очереди
    order = Order.objects.select_for_update().get(id=order_id)
    
    if order.status == Order.Status.PAID:
        return False, 'Заказ уже оплачен'
        
    order.status = Order.Status.PAID
    order.transaction_id = transaction_id
    order.save()
    
    return True, 'Успешно завершено'

Заключение и реальные результаты

Какие результаты мы получили после внедрения данной архитектуры в проект?

  • Случаи повторных платежей и дублирования услуг полностью свелись к нулю.
  • Сервер стал абсолютно устойчив к сетевым сбоям и агрессивным повторным запросам от платежных провайдеров.
  • Жалобы пользователей в службу поддержки по поводу ошибок оплаты прекратились.

Если вы работаете с финансами в Telegram-боте, всегда следуйте правилам идемпотентности. При интеграции платежных систем на первом месте должно стоять не 'быстрое решение', а прежде всего 'безопасная и надежная архитектура'.

Теги: #Telegram Bot #To'lov Tizimlari #Python #Redis #Backend