Разработка платформы для продажи билетов (Ticketing)

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка платформы для продажи билетов (Ticketing)
Сложная
от 2 недель до 3 месяцев
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Разработка платформы для продажи билетов (Ticketing)

Тикетинговая платформа — это система с жёсткими требованиями к конкурентоспособности данных. Несколько пользователей одновременно покупают последний билет в ряду А. Race condition в этом случае означает проданные «дважды» места — и возврат денег, и испорченная репутация. Правильная архитектура начинается с выбора механизма блокировок.

Модель мест и резервирования

CREATE TABLE events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organizer_id    UUID NOT NULL REFERENCES users(id),
    title           VARCHAR(300) NOT NULL,
    slug            VARCHAR(300) UNIQUE NOT NULL,
    venue_id        UUID REFERENCES venues(id),
    starts_at       TIMESTAMPTZ NOT NULL,
    ends_at         TIMESTAMPTZ,
    status          VARCHAR(20) NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft','on_sale','sold_out','cancelled','completed')),
    timezone        VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ticket_types (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id        UUID NOT NULL REFERENCES events(id),
    name            VARCHAR(200) NOT NULL,   -- 'VIP', 'Стандарт', 'Студенческий'
    price           NUMERIC(12,2) NOT NULL,
    currency        CHAR(3) NOT NULL DEFAULT 'RUB',
    total_qty       INTEGER NOT NULL,
    reserved_qty    INTEGER NOT NULL DEFAULT 0,
    sold_qty        INTEGER NOT NULL DEFAULT 0,
    sale_starts_at  TIMESTAMPTZ,
    sale_ends_at    TIMESTAMPTZ,
    max_per_order   INTEGER NOT NULL DEFAULT 10,
    CONSTRAINT qty_valid CHECK (sold_qty + reserved_qty <= total_qty)
);

CREATE TABLE seats (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticket_type_id  UUID NOT NULL REFERENCES ticket_types(id),
    section         VARCHAR(50),
    row             VARCHAR(10),
    number          VARCHAR(10),
    status          VARCHAR(20) NOT NULL DEFAULT 'available'
                    CHECK (status IN ('available','reserved','sold','blocked')),
    reserved_until  TIMESTAMPTZ,    -- когда истекает резервация
    order_id        UUID,
    UNIQUE (ticket_type_id, section, row, number)
);

CREATE INDEX idx_seats_available
    ON seats(ticket_type_id, status)
    WHERE status = 'available';

Оптимистичная блокировка мест

Наивный SELECT → UPDATE даст race condition. Правильный подход — SELECT FOR UPDATE SKIP LOCKED:

from django.db import transaction
from django.utils import timezone
from datetime import timedelta


def reserve_seats(ticket_type_id: str, qty: int, session_id: str) -> list:
    """
    Резервируем N мест на 15 минут для сессии покупки.
    Возвращает список seat_id или raise если недостаточно.
    """
    with transaction.atomic():
        # Захватываем строки без ожидания — другие транзакции пропускают занятые
        seats = (
            Seat.objects
            .select_for_update(skip_locked=True)
            .filter(
                ticket_type_id=ticket_type_id,
                status='available'
            )
            .order_by('section', 'row', 'number')[:qty]
        )

        if len(seats) < qty:
            raise InsufficientSeatsError(
                f'Доступно {len(seats)} мест, запрошено {qty}'
            )

        seat_ids = [seat.id for seat in seats]
        reserved_until = timezone.now() + timedelta(minutes=15)

        Seat.objects.filter(id__in=seat_ids).update(
            status='reserved',
            reserved_until=reserved_until,
            order_id=None,  # будет заполнено после оплаты
        )

        TicketType.objects.filter(id=ticket_type_id).update(
            reserved_qty=F('reserved_qty') + qty
        )

        # Сохраняем в Redis для быстрого доступа
        redis_client.setex(
            f'reservation:{session_id}:{ticket_type_id}',
            900,  # 15 минут в секундах
            json.dumps(seat_ids)
        )

        return seat_ids

Освобождение просроченных резерваций

@shared_task
def release_expired_reservations():
    """Celery beat: каждые 2 минуты"""
    now = timezone.now()
    expired_seats = Seat.objects.filter(
        status='reserved',
        reserved_until__lt=now,
        order_id__isnull=True
    )

    seat_ids = list(expired_seats.values_list('id', flat=True))
    if not seat_ids:
        return

    # Группируем по ticket_type для обновления счётчиков
    type_counts = (
        Seat.objects
        .filter(id__in=seat_ids)
        .values('ticket_type_id')
        .annotate(cnt=Count('id'))
    )

    with transaction.atomic():
        Seat.objects.filter(id__in=seat_ids).update(
            status='available',
            reserved_until=None,
        )
        for row in type_counts:
            TicketType.objects.filter(id=row['ticket_type_id']).update(
                reserved_qty=F('reserved_qty') - row['cnt']
            )

    # Уведомляем через WebSocket — билеты снова доступны
    for row in type_counts:
        channel_layer.group_send(
            f'event_{row["ticket_type_id"]}',
            {'type': 'seats_released', 'count': row['cnt']}
        )

QR-код билета и верификация на входе

import qrcode
import hmac
import hashlib
from io import BytesIO


def generate_ticket_token(ticket_id: str, secret: str) -> str:
    """HMAC-подписанный токен для QR-кода"""
    msg = f'ticket:{ticket_id}'.encode()
    signature = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return f'{ticket_id}:{signature[:16]}'


def verify_ticket_token(token: str, secret: str) -> str | None:
    """Возвращает ticket_id если токен валиден, иначе None"""
    parts = token.split(':')
    if len(parts) != 2:
        return None

    ticket_id, provided_sig = parts
    expected_token = generate_ticket_token(ticket_id, secret)
    expected_sig = expected_token.split(':')[1]

    if hmac.compare_digest(provided_sig, expected_sig):
        return ticket_id
    return None


def generate_qr_image(ticket) -> bytes:
    token = generate_ticket_token(str(ticket.id), settings.TICKET_SECRET)
    qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_H)
    qr.add_data(f'https://tickets.site.com/verify/{token}')
    qr.make(fit=True)
    img = qr.make_image(fill_color='black', back_color='white')
    buf = BytesIO()
    img.save(buf, format='PNG')
    return buf.getvalue()

API для сканеров на входе:

# POST /api/v1/tickets/verify
def verify_ticket_view(request):
    token = request.data.get('token')
    ticket_id = verify_ticket_token(token, settings.TICKET_SECRET)

    if not ticket_id:
        return Response({'valid': False, 'reason': 'invalid_token'}, status=400)

    ticket = Ticket.objects.select_related('event', 'seat').get(id=ticket_id)

    if ticket.scanned_at:
        return Response({
            'valid': False,
            'reason': 'already_used',
            'scanned_at': ticket.scanned_at.isoformat(),
        }, status=400)

    if ticket.event.starts_at.date() != date.today():
        return Response({'valid': False, 'reason': 'wrong_date'}, status=400)

    ticket.scanned_at = timezone.now()
    ticket.save()

    return Response({
        'valid': True,
        'holder': ticket.holder_name,
        'seat': f'{ticket.seat.section} ряд {ticket.seat.row} место {ticket.seat.number}',
        'ticket_type': ticket.ticket_type.name,
    })

Динамическое ценообразование

Цена растёт по мере заполнения зала — стандартная практика для концертов:

def get_current_price(ticket_type) -> Decimal:
    """Динамическая цена на основе % проданных билетов"""
    sold_ratio = ticket_type.sold_qty / ticket_type.total_qty

    # Пороги заполнения
    tiers = [
        (0.0,  0.0),   # 0–50%: базовая цена
        (0.5,  0.10),  # 50–70%: +10%
        (0.7,  0.25),  # 70–85%: +25%
        (0.85, 0.50),  # 85–95%: +50%
        (0.95, 1.00),  # 95–100%: +100%
    ]

    multiplier = Decimal('1.0')
    for threshold, increase in reversed(tiers):
        if sold_ratio >= threshold:
            multiplier = Decimal(str(1 + increase))
            break

    return (ticket_type.base_price * multiplier).quantize(Decimal('1'))

Схема платёжного флоу

  1. Пользователь выбирает места → reserve_seats() → 15-минутная бронь
  2. Переход к оплате → создаётся Order со статусом pending
  3. Оплата через ЮKassa или Stripe → webhook подтверждает
  4. Order.status = 'paid' → места переходят в sold → генерируются PDF-билеты
  5. Email с билетами → PDF-аттачмент + QR-код

Сроки

Базовая тикетинговая платформа без схемы зала (только типы билетов, количество): 4–6 недель. Полная система с интерактивным планом зала (SVG), динамическим ценообразованием, сканером и личным кабинетом организатора: 3–4 месяца.