diff --git a/backend/core/minio.py b/backend/core/minio.py new file mode 100644 index 0000000..9198006 --- /dev/null +++ b/backend/core/minio.py @@ -0,0 +1,50 @@ +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import aioboto3 +from botocore.exceptions import ClientError + +MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "http://minio:9000") +MINIO_ACCESS_KEY: str = os.getenv("MINIO_ACCESS_KEY", "minioadmin") +MINIO_SECRET_KEY: str = os.getenv("MINIO_SECRET_KEY", "minioadminpassword") +MINIO_BUCKET: str = os.getenv("MINIO_BUCKET", "tickets-media") + +_session = aioboto3.Session() + + +@asynccontextmanager +async def _s3_client() -> AsyncIterator: + async with _session.client( + "s3", + endpoint_url=MINIO_ENDPOINT, + aws_access_key_id=MINIO_ACCESS_KEY, + aws_secret_access_key=MINIO_SECRET_KEY, + region_name="us-east-1", # MinIO не требует региона, но boto3 обязывает передать + ) as client: + yield client + + +async def ensure_bucket_exists() -> None: + """Создаёт бакет при старте воркера, если он ещё не существует.""" + async with _s3_client() as client: + try: + await client.head_bucket(Bucket=MINIO_BUCKET) + except ClientError as exc: + error_code = exc.response["Error"]["Code"] + if error_code in ("404", "NoSuchBucket"): + await client.create_bucket(Bucket=MINIO_BUCKET) + else: + raise + + +async def upload_pdf(object_name: str, pdf_bytes: bytes) -> str: + """Загружает PDF в MinIO и возвращает публичный URL объекта.""" + async with _s3_client() as client: + await client.put_object( + Bucket=MINIO_BUCKET, + Key=object_name, + Body=pdf_bytes, + ContentType="application/pdf", + ) + return f"{MINIO_ENDPOINT}/{MINIO_BUCKET}/{object_name}" diff --git a/backend/database/models.py b/backend/database/models.py index f43a206..312bbda 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -52,6 +52,7 @@ class Ticket(Base): index=True ) idempotency_key: Mapped[str] = mapped_column(String, unique=True, nullable=True) + pdf_url: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/backend/requirements.txt b/backend/requirements.txt index 869ceed..aedb0cd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,4 +7,7 @@ psycopg2-binary redis passlib[bcrypt] PyJWT +aio-pika +reportlab +aioboto3 pydantic[email]>=2.5.0 diff --git a/backend/worker.py b/backend/worker.py new file mode 100644 index 0000000..c3b32b7 --- /dev/null +++ b/backend/worker.py @@ -0,0 +1,135 @@ +""" +Standalone worker: слушает очередь RabbitMQ 'ticket_events', +генерирует PDF-билет через reportlab, загружает в MinIO, +сохраняет pdf_url в PostgreSQL. +""" +import asyncio +import io +import json +import logging +import os +from typing import Any + +import aio_pika +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from core.minio import ensure_bucket_exists, upload_pdf +from database.models import Ticket + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +log = logging.getLogger("worker") + +RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://user:password@rabbitmq/") +DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://admin:your_strong_password@postgres:5432/ticket_db", +) +QUEUE_NAME: str = "ticket_events" + + +def _build_pdf(ticket_id: int, seat_id: int, user_id: int) -> bytes: + """Генерирует PDF-билет в памяти и возвращает байты.""" + buffer = io.BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + + pdf.setFont("Helvetica-Bold", 24) + pdf.drawCentredString(width / 2, height - 80, "TICKET") + + pdf.setFont("Helvetica", 14) + pdf.drawCentredString(width / 2, height - 130, f"Ticket ID: {ticket_id}") + pdf.drawCentredString(width / 2, height - 160, f"Seat ID: {seat_id}") + pdf.drawCentredString(width / 2, height - 190, f"User ID: {user_id}") + + pdf.setFont("Helvetica-Oblique", 10) + pdf.drawCentredString(width / 2, 40, "Thank you for your purchase!") + + pdf.save() + return buffer.getvalue() + + +async def _handle_ticket_paid( + payload: dict[str, Any], + db_session: AsyncSession, +) -> None: + ticket_id: int | None = payload.get("ticket_id") + if ticket_id is None: + log.error("Event 'ticket_paid' missing 'ticket_id': %s", payload) + return + + result = await db_session.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket: Ticket | None = result.scalar_one_or_none() + + if ticket is None: + log.error("Ticket %s not found in DB", ticket_id) + return + + if ticket.pdf_url: + log.info("Ticket %s already has a PDF, skipping (idempotency guard)", ticket_id) + return + + log.info("Generating PDF for ticket %s …", ticket_id) + pdf_bytes = _build_pdf( + ticket_id=ticket.id, + seat_id=ticket.seat_id, + user_id=ticket.user_id or 0, + ) + + object_name = f"tickets/ticket_{ticket_id}.pdf" + pdf_url = await upload_pdf(object_name, pdf_bytes) + log.info("PDF uploaded: %s", pdf_url) + + ticket.pdf_url = pdf_url + await db_session.commit() + log.info("Ticket %s updated with pdf_url", ticket_id) + + +async def _process_message( + message: aio_pika.abc.AbstractIncomingMessage, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with message.process(requeue=True): + try: + payload: dict[str, Any] = json.loads(message.body) + action: str = payload.get("action", "") + + if action == "ticket_paid": + async with session_factory() as session: + await _handle_ticket_paid(payload, session) + else: + log.debug("Unknown action '%s', skipping", action) + + except json.JSONDecodeError: + log.exception("Failed to decode message body: %r", message.body) + # Некорректный JSON — не возвращаем в очередь (requeue=False через reject) + await message.reject(requeue=False) + + +async def main() -> None: + engine = create_async_engine(DATABASE_URL, echo=False) + session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + await ensure_bucket_exists() + log.info("MinIO bucket ready") + + connection = await aio_pika.connect_robust(RABBITMQ_URL) + async with connection: + channel = await connection.channel() + await channel.set_qos(prefetch_count=10) + + queue = await channel.declare_queue(QUEUE_NAME, durable=True) + log.info("Worker started. Listening on queue '%s' …", QUEUE_NAME) + + async with queue.iterator() as queue_iter: + async for message in queue_iter: + await _process_message(message, session_factory) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/design/01-login.png b/docs/design/01-login.png new file mode 100644 index 0000000..c4c11f8 Binary files /dev/null and b/docs/design/01-login.png differ diff --git a/docs/design/02-events.png b/docs/design/02-events.png new file mode 100644 index 0000000..0f4f26e Binary files /dev/null and b/docs/design/02-events.png differ diff --git a/docs/design/03-event-details.png b/docs/design/03-event-details.png new file mode 100644 index 0000000..90bd441 Binary files /dev/null and b/docs/design/03-event-details.png differ diff --git a/docs/design/04-seat-selection.png b/docs/design/04-seat-selection.png new file mode 100644 index 0000000..083e962 Binary files /dev/null and b/docs/design/04-seat-selection.png differ diff --git a/docs/design/05-checkout.png b/docs/design/05-checkout.png new file mode 100644 index 0000000..e7a3c5d Binary files /dev/null and b/docs/design/05-checkout.png differ diff --git a/docs/design/06-my-tickets.png b/docs/design/06-my-tickets.png new file mode 100644 index 0000000..cc8a9a4 Binary files /dev/null and b/docs/design/06-my-tickets.png differ diff --git a/docs/design/07-ticket-qr.png b/docs/design/07-ticket-qr.png new file mode 100644 index 0000000..7440b12 Binary files /dev/null and b/docs/design/07-ticket-qr.png differ diff --git a/docs/design/08-profile-loyalty.png b/docs/design/08-profile-loyalty.png new file mode 100644 index 0000000..44190d1 Binary files /dev/null and b/docs/design/08-profile-loyalty.png differ diff --git a/docs/design/09-order-history.png b/docs/design/09-order-history.png new file mode 100644 index 0000000..aaa9bd2 Binary files /dev/null and b/docs/design/09-order-history.png differ diff --git a/docs/design/10-notifications.png b/docs/design/10-notifications.png new file mode 100644 index 0000000..2864505 Binary files /dev/null and b/docs/design/10-notifications.png differ diff --git a/docs/design/UI-spec.md b/docs/design/UI-spec.md new file mode 100644 index 0000000..d169875 --- /dev/null +++ b/docs/design/UI-spec.md @@ -0,0 +1,110 @@ +# Спецификация интерфейсов системы бронирования билетов (Fight & Sports) + +**Общий стиль:** Modern SaaS, Dark Theme. +**Цветовая палитра:** Фон #121212, Акцентный красный #E32636 (кнопки, активные состояния), Золотой/Желтый (лояльность). +**Шрифты:** Без засечек (San Francisco / Inter / Roboto), четкая иерархия заголовков. + +--- + +## I. Клиентское мобильное приложение (Client App) + +### 01-login.png — Вход в систему +* **Назначение:** Авторизация и регистрация. +* **Логика:** Переключатель (Segmented Control) между E-mail и Телефоном. Поля с валидацией. Социальный вход (Apple, Google, VK) в нижней части экрана. +* **Кнопки:** "Войти" (Primary), "Создать аккаунт" (Secondary/Ghost). + +### 02-events.png — Главный экран (Каталог) +* **Назначение:** Поиск и выбор спортивного события. +* **Логика:** Горизонтальные табы-фильтры («Все», «Ближайшие», «Популярные»). Список карточек с вертикальным скроллом. +* **Контент карточки:** Миниатюра афиши, название (напр. "Турнир по ММА"), дата/время, площадка, цена "от 2500 ₽". + +### 03-event-details.png — Страница события +* **Назначение:** Подробная информация об ивенте перед покупкой. +* **Логика:** Большая афиша (Hero Image) сверху. Блоки: «Ключевая информация» (возраст 18+, длительность), описание, правила возврата, что включено в билет. +* **Действие:** Закрепленная внизу кнопка «Выбрать места». + +### 04-seat-selection.png — Выбор мест (Схема зала) +* **Назначение:** Интерактивное бронирование мест. +* **Логика:** Карта арены с секторами. Цветовая индикация: VIP (фиолетовый), Стандарт (синий). Интерактивные точки (кресла). +* **Панель итогов:** Выбранное место (Ряд/Место), количество и общая стоимость с кнопкой перехода к оплате. + +### 05-checkout.png — Оформление заказа +* **Назначение:** Финализация покупки и выбор метода оплаты. +* **Логика:** Проверка данных заказа. Ввод промокода, тумблер использования бонусных баллов. Выбор Apple Pay или Карты. +* **Безопасность:** Текст «Все платежи защищены». + +### 06-my-tickets.png — Список билетов +* **Назначение:** Хранение купленных билетов пользователя. +* **Логика:** Табы «Активные» и «История». Карточки с перфорацией (визуальный стиль билета), краткие данные места. +* **Действие:** Кнопка «Открыть билет» для перехода к QR. + +### 07-ticket-qr.png — Электронный билет +* **Назначение:** Контроль доступа на входе. +* **Логика:** Крупный QR-код. Информация о секторе, ряде и месте продублирована крупным шрифтом для удобства стюардов. +* **Действие:** Кнопки «Добавить в Wallet» и «Скачать». + +### 08-profile-loyalty.png — Профиль и лояльность +* **Назначение:** Личный кабинет и геймификация. +* **Логика:** Карточка уровня лояльности («Серебро»). Баланс бонусов в рублях. Прогресс-бар до следующего уровня (Золото). +* **Навигация:** Меню настроек, заказов и реферальной программы. + +### 09-order-history.png — История заказов +* **Назначение:** Просмотр прошлых транзакций. +* **Логика:** Список заказов с датами. Цветовые статусы: Зеленый («Оплачен»), Синий («Завершен»), Красный («Отменен»). +* **Детали:** Сумма заказа и ссылка на подробности. + +### 10-notifications.png — Настройки уведомлений +* **Назначение:** Управление каналами коммуникации. +* **Логика:** Список переключателей (Switch) для каждого канала: SMS, E-mail, Push, Telegram, WhatsApp. +* **Группировка:** Отдельно системные уведомления и маркетинговые предложения. + +--- + +## II. Админ-панель (Admin Interface) + +### admin-01-dashboard.png — Дашборд аналитики +* **Назначение:** Оперативный мониторинг продаж. +* **Виджеты:** KPI (Выручка, Билеты, Конверсия). Линейный график продаж. Круговая диаграмма источников трафика. +* **Действия:** Быстрые кнопки «Создать событие» и «Рассылка». + +### admin-02-events.png — Управление событиями +* **Назначение:** Список и модерация ивентов. +* **Логика:** Табличное отображение мероприятий. Для каждого: статус (Черновик/Опубликовано), количество проданных мест в %, текущая выручка. +* **Действия:** Редактировать, Статистика. + +### admin-03-create-event.png — Форма создания +* **Назначение:** Добавление нового матча/боя в базу. +* **Поля:** Название, дата (Datepicker), время, площадка (Dropdown), описание (Textarea), загрузка афиши. +* **Логика:** Валидация обязательных полей перед публикацией. + +### admin-04-seat-map.png — Настройка схемы зала +* **Назначение:** Привязка категорий к местам. +* **Логика:** Область предпросмотра схемы (SVG). Панель конфигурации: выбор категории (VIP/Стандарт) и выделение соответствующих зон на карте. + +### admin-05-pricing.png — Управление ценами +* **Назначение:** Настройка стоимости и динамического ценообразования. +* **Логика:** Список категорий с полями ввода цены. Блок автоматизации: % наценки при высоком спросе или скидки при низком. + +### admin-06-segments.png — Сегментация аудитории +* **Назначение:** Группировка пользователей для маркетинга. +* **Фильтры:** По городу, LTV (сумме покупок), дате регистрации. +* **Результат:** Сохраненные сегменты с количеством доступных контактов. + +### admin-07-campaign-create.png — Создание рассылки +* **Назначение:** Запуск маркетинговых кампаний. +* **Логика:** Выбор сегмента из списка. Редактор сообщения с превью (как это увидит пользователь). Выбор каналов доставки. +* **Действие:** Кнопки «Отправить сейчас» или «Запланировать». + +### admin-08-campaign-stats.png — Аналитика рассылок +* **Назначение:** Анализ эффективности маркетинга. +* **Метрики:** Open Rate (открытия), Click Rate (клики), Conversion (покупки). Графики вовлеченности во времени. + +### admin-09-users.png — Список пользователей +* **Назначение:** CRM система. +* **Логика:** Список клиентов с быстрыми метриками: уровень лояльности, количество билетов, общая сумма трат (LTV). +* **Поиск:** По имени, телефону или ID. + +### admin-10-user-profile.png — Карточка клиента (Админ) +* **Назначение:** Детальная информация и ручное управление. +* **Логика:** Просмотр всех заказов пользователя, редактирование бонусного баланса, история активности. +* **Действия:** Кнопки связи и блокировки. \ No newline at end of file diff --git a/docs/design/admin-01-dashboard.png b/docs/design/admin-01-dashboard.png new file mode 100644 index 0000000..c7eb5ec Binary files /dev/null and b/docs/design/admin-01-dashboard.png differ diff --git a/docs/design/admin-02-events.png b/docs/design/admin-02-events.png new file mode 100644 index 0000000..2355b47 Binary files /dev/null and b/docs/design/admin-02-events.png differ diff --git a/docs/design/admin-03-create-event.png b/docs/design/admin-03-create-event.png new file mode 100644 index 0000000..ccff5f2 Binary files /dev/null and b/docs/design/admin-03-create-event.png differ diff --git a/docs/design/admin-04-seat-map.png b/docs/design/admin-04-seat-map.png new file mode 100644 index 0000000..0820ee2 Binary files /dev/null and b/docs/design/admin-04-seat-map.png differ diff --git a/docs/design/admin-05-pricing.png b/docs/design/admin-05-pricing.png new file mode 100644 index 0000000..4226de7 Binary files /dev/null and b/docs/design/admin-05-pricing.png differ diff --git a/docs/design/admin-06-segments.png b/docs/design/admin-06-segments.png new file mode 100644 index 0000000..abad30e Binary files /dev/null and b/docs/design/admin-06-segments.png differ diff --git a/docs/design/admin-07-campaign-create.png b/docs/design/admin-07-campaign-create.png new file mode 100644 index 0000000..6b9c1c7 Binary files /dev/null and b/docs/design/admin-07-campaign-create.png differ diff --git a/docs/design/admin-08-campaign-stats.png b/docs/design/admin-08-campaign-stats.png new file mode 100644 index 0000000..c176779 Binary files /dev/null and b/docs/design/admin-08-campaign-stats.png differ diff --git a/docs/design/admin-09-users.png b/docs/design/admin-09-users.png new file mode 100644 index 0000000..4727688 Binary files /dev/null and b/docs/design/admin-09-users.png differ diff --git a/docs/design/admin-10-user-profile.png b/docs/design/admin-10-user-profile.png new file mode 100644 index 0000000..ca7fd0c Binary files /dev/null and b/docs/design/admin-10-user-profile.png differ diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 6449d0a..a3ca077 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -23,6 +23,19 @@ services: networks: - ticket-network + worker: + build: ../backend + container_name: worker + command: python worker.py + env_file: + - .env + depends_on: + - rabbitmq + - minio + - postgres + networks: + - ticket-network + traefik: image: traefik:v3.6 container_name: traefik