Update project 2nd iteration
50
backend/core/minio.py
Normal file
@@ -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}"
|
||||||
@@ -52,6 +52,7 @@ class Ticket(Base):
|
|||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
idempotency_key: Mapped[str] = mapped_column(String, unique=True, nullable=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))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ psycopg2-binary
|
|||||||
redis
|
redis
|
||||||
passlib[bcrypt]
|
passlib[bcrypt]
|
||||||
PyJWT
|
PyJWT
|
||||||
|
aio-pika
|
||||||
|
reportlab
|
||||||
|
aioboto3
|
||||||
pydantic[email]>=2.5.0
|
pydantic[email]>=2.5.0
|
||||||
|
|||||||
135
backend/worker.py
Normal file
@@ -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())
|
||||||
BIN
docs/design/01-login.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/design/02-events.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/design/03-event-details.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
docs/design/04-seat-selection.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
docs/design/05-checkout.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/design/06-my-tickets.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/design/07-ticket-qr.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/design/08-profile-loyalty.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/design/09-order-history.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/design/10-notifications.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
110
docs/design/UI-spec.md
Normal file
@@ -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 — Карточка клиента (Админ)
|
||||||
|
* **Назначение:** Детальная информация и ручное управление.
|
||||||
|
* **Логика:** Просмотр всех заказов пользователя, редактирование бонусного баланса, история активности.
|
||||||
|
* **Действия:** Кнопки связи и блокировки.
|
||||||
BIN
docs/design/admin-01-dashboard.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-02-events.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/design/admin-03-create-event.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/design/admin-04-seat-map.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-05-pricing.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-06-segments.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-07-campaign-create.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-08-campaign-stats.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/design/admin-09-users.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
docs/design/admin-10-user-profile.png
Normal file
|
After Width: | Height: | Size: 423 KiB |
@@ -23,6 +23,19 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- ticket-network
|
- ticket-network
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: ../backend
|
||||||
|
container_name: worker
|
||||||
|
command: python worker.py
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- rabbitmq
|
||||||
|
- minio
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- ticket-network
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.6
|
image: traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
|
|||||||