From 1dcecb8d52e33a2fd5772ec60a00b506b990afdf Mon Sep 17 00:00:00 2001 From: openit Date: Thu, 12 Mar 2026 09:00:14 +0000 Subject: [PATCH] Update rabbitMQ&paimentsMOK --- backend/api/routers/tickets.py | 180 ++++++++++++++++++++++++++------- backend/core/rabbitmq.py | 57 ++++++----- backend/requirements.txt | 3 +- backend/schemas/ticket.py | 16 +++ 4 files changed, 191 insertions(+), 65 deletions(-) diff --git a/backend/api/routers/tickets.py b/backend/api/routers/tickets.py index 4418d40..2b3495a 100644 --- a/backend/api/routers/tickets.py +++ b/backend/api/routers/tickets.py @@ -1,16 +1,31 @@ +import os +import uuid +import httpx +from datetime import datetime, timedelta, timezone + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload - +from yookassa import Configuration, Payment +from core.rabbitmq import publish_ticket_task from api.deps import get_current_user from database.models import Seat, Ticket, TicketStatus, User from database.session import get_db -from schemas.ticket import TicketResponse, TicketScanRequest, TicketScanResponse +from schemas.ticket import ( + TicketResponse, + TicketScanRequest, + TicketScanResponse, + TicketBookRequest, + TicketBookResponse, + YookassaWebhook +) + +# Если у тебя есть готовая функция для Раббита, раскомментируй импорт (путь может отличаться) +# from core.rabbitmq import publish_ticket_task router = APIRouter(prefix="/api/tickets", tags=["tickets"]) - @router.get("/me", response_model=list[TicketResponse]) async def get_my_tickets( current_user: User = Depends(get_current_user), @@ -20,10 +35,7 @@ async def get_my_tickets( result = await db.execute( select(Ticket) .where(Ticket.user_id == current_user.id, Ticket.status == TicketStatus.PAID) - .options( - # Ticket → Seat → Tournament (один запрос на каждый уровень, без N+1) - selectinload(Ticket.seat).selectinload(Seat.tournament) - ) + .options(selectinload(Ticket.seat).selectinload(Seat.tournament)) .order_by(Ticket.created_at.desc()) ) return list(result.scalars().all()) @@ -35,51 +47,147 @@ async def scan_ticket( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> TicketScanResponse: - """ - Сканирует билет по secret_token (QR-код). - Переводит статус PAID → SCANNED. Идемпотентно обрабатывает повторное сканирование. - """ - result = await db.execute( - select(Ticket).where(Ticket.secret_token == body.token) - ) + """Сканирует билет по secret_token (QR-код).""" + result = await db.execute(select(Ticket).where(Ticket.secret_token == body.token)) ticket: Ticket | None = result.scalar_one_or_none() if ticket is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=TicketScanResponse( - success=False, - message="Билет не найден или подделка", - ticket_id=None, - ).model_dump(), + detail=TicketScanResponse(success=False, message="Билет не найден или подделка").model_dump(), ) - if ticket.status == TicketStatus.SCANNED: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=TicketScanResponse( - success=False, - message="Билет уже отсканирован!", - ticket_id=ticket.id, - ).model_dump(), + detail=TicketScanResponse(success=False, message="Билет уже отсканирован!", ticket_id=ticket.id).model_dump(), ) - if ticket.status != TicketStatus.PAID: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=TicketScanResponse( - success=False, - message=f"Проход запрещен: статус билета '{ticket.status.value}'", - ticket_id=ticket.id, - ).model_dump(), + detail=TicketScanResponse(success=False, message=f"Проход запрещен: статус билета '{ticket.status.value}'", ticket_id=ticket.id).model_dump(), ) - # PAID → SCANNED ticket.status = TicketStatus.SCANNED await db.commit() + return TicketScanResponse(success=True, message="Проход разрешен", ticket_id=ticket.id) - return TicketScanResponse( - success=True, - message="Проход разрешен", - ticket_id=ticket.id, + +@router.post("/book", response_model=TicketBookResponse) +async def book_ticket( + body: TicketBookRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Блокирует место на 15 минут и генерирует ссылку на оплату.""" + seat_query = await db.execute( + select(Seat).options(selectinload(Seat.tournament)).where(Seat.id == body.seat_id) ) + seat = seat_query.scalar_one_or_none() + if not seat: + raise HTTPException(status_code=404, detail="Место не найдено") + + ticket_query = await db.execute(select(Ticket).where(Ticket.seat_id == seat.id)) + existing_ticket = ticket_query.scalar_one_or_none() + now = datetime.now(timezone.utc) + + # Проверка блокировок + if existing_ticket: + if existing_ticket.status == TicketStatus.PAID: + raise HTTPException(status_code=400, detail="Место уже выкуплено") + if existing_ticket.status == TicketStatus.LOCKED and existing_ticket.expires_at and existing_ticket.expires_at > now: + if existing_ticket.user_id != current_user.id: + raise HTTPException(status_code=400, detail="Место временно забронировано другим пользователем") + + description = f"Билет: {seat.tournament.title}, Сектор {seat.sector}, Ряд {seat.row}, Место {seat.number}"[:128] + shop_id = os.getenv("YOOKASSA_SHOP_ID", "dummy") + + if shop_id == "test_shop": + # Стучимся в наш мок-сервер + mock_url = os.getenv("MOCK_API_URL", "http://192.168.149.101:8083/v3/payments") + async with httpx.AsyncClient() as client: + response = await client.post( + mock_url, + json={ + "amount": {"value": f"{seat.price}.00", "currency": "RUB"}, + "confirmation": {"type": "redirect", "return_url": "https://openticket.artifitial.ru/tickets"}, + "capture": True, + "description": description + }, + headers={"Idempotence-Key": str(uuid.uuid4())} + ) + response.raise_for_status() + payment_data = response.json() + payment_id = payment_data["id"] + payment_url = payment_data["confirmation"]["confirmation_url"] + + else: + # Реальная ЮKassa + Configuration.account_id = shop_id + Configuration.secret_key = os.getenv("YOOKASSA_SECRET_KEY") + payment = Payment.create({ + "amount": {"value": f"{seat.price}.00", "currency": "RUB"}, + "confirmation": {"type": "redirect", "return_url": "https://openticket.artifitial.ru/tickets"}, + "capture": True, + "description": description + }, str(uuid.uuid4())) + payment_id = payment.id + payment_url = payment.confirmation.confirmation_url + + # Фиксируем бронь + if not existing_ticket: + ticket = Ticket( + seat_id=seat.id, + user_id=current_user.id, + status=TicketStatus.LOCKED, + payment_id=payment_id, + payment_url=payment_url, + expires_at=now + timedelta(minutes=15) + ) + db.add(ticket) + else: + existing_ticket.user_id = current_user.id + existing_ticket.status = TicketStatus.LOCKED + existing_ticket.payment_id = payment_id + existing_ticket.payment_url = payment_url + existing_ticket.expires_at = now + timedelta(minutes=15) + ticket = existing_ticket + + await db.commit() + + return TicketBookResponse(ticket_id=ticket.id, payment_url=payment_url) + + +@router.post("/webhook/yookassa") +async def yookassa_webhook( + payload: YookassaWebhook, + db: AsyncSession = Depends(get_db) +): + """Принимает уведомления от платежного шлюза.""" + query = await db.execute(select(Ticket).where(Ticket.payment_id == payload.object.id)) + ticket = query.scalar_one_or_none() + + if not ticket: + return {"status": "ignored", "reason": "ticket not found"} + + if payload.event == "payment.succeeded" and payload.object.status == "succeeded": + if ticket.status != TicketStatus.PAID: + ticket.status = TicketStatus.PAID + await db.commit() + + # ---> ЗДЕСЬ ПУШИМ ЗАДАЧУ В RABBITMQ <--- + # Если у тебя есть функция публикации: + # ... + ticket.status = TicketStatus.PAID + await db.commit() + + # Отправляем в очередь + await publish_ticket_task(ticket.id) + # ... + print(f"Оплата получена. Задача на PDF для билета {ticket.id} отправлена в очередь.") + + elif payload.event == "payment.canceled": + if ticket.status == TicketStatus.LOCKED: + await db.delete(ticket) + await db.commit() + + return {"status": "ok"} \ No newline at end of file diff --git a/backend/core/rabbitmq.py b/backend/core/rabbitmq.py index cea4c24..ffd2386 100644 --- a/backend/core/rabbitmq.py +++ b/backend/core/rabbitmq.py @@ -1,34 +1,35 @@ -import json import os - +import json import aio_pika +import logging -RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://user:password@rabbitmq/") -QUEUE_NAME: str = "ticket_events" +logger = logging.getLogger(__name__) +QUEUE_NAME = "pdf_generation_queue" -async def publish_ticket_paid_event(ticket_id: int, user_id: int) -> None: - """Публикует событие ticket_paid в очередь RabbitMQ. +async def publish_ticket_task(ticket_id: int): + """Отправляет ID билета в RabbitMQ.""" + # Читаем строго из ENV прямо перед вызовом + rabbitmq_url = os.getenv("RABBITMQ_URL") + if not rabbitmq_url: + logger.error("КРИТИЧЕСКАЯ ОШИБКА: RABBITMQ_URL не задан в переменных окружения!") + return - Формат совпадает с тем, что ожидает worker.py: - {"action": "ticket_paid", "ticket_id": , "user_id": } - """ - connection = await aio_pika.connect_robust(RABBITMQ_URL) - async with connection: - channel = await connection.channel() - - # durable=True — очередь переживёт перезапуск брокера - queue = await channel.declare_queue(QUEUE_NAME, durable=True) - - body = json.dumps( - {"action": "ticket_paid", "ticket_id": ticket_id, "user_id": user_id} - ).encode() - - await channel.default_exchange.publish( - aio_pika.Message( - body=body, - delivery_mode=aio_pika.DeliveryMode.PERSISTENT, - content_type="application/json", - ), - routing_key=queue.name, - ) + try: + connection = await aio_pika.connect_robust(rabbitmq_url) + async with connection: + channel = await connection.channel() + queue = await channel.declare_queue(QUEUE_NAME, durable=True) + + message = aio_pika.Message( + body=json.dumps({"ticket_id": ticket_id}).encode(), + delivery_mode=aio_pika.DeliveryMode.PERSISTENT + ) + + await channel.default_exchange.publish( + message, + routing_key=queue.name, + ) + logger.info(f"Задача на генерацию PDF для билета {ticket_id} улетела в очередь.") + except Exception as e: + logger.error(f"Сбой RabbitMQ при отправке билета {ticket_id}: {e}") \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index e5c99d7..72daff8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,4 +13,5 @@ reportlab aioboto3 pydantic[email]>=2.5.0 qrcode[pil]==7.4.2 -yookassa==3.3.0 \ No newline at end of file +yookassa==3.3.0 +httpx \ No newline at end of file diff --git a/backend/schemas/ticket.py b/backend/schemas/ticket.py index 6522b7f..7c2f43e 100644 --- a/backend/schemas/ticket.py +++ b/backend/schemas/ticket.py @@ -4,6 +4,22 @@ from pydantic import BaseModel, ConfigDict from database.models import TicketStatus +class YookassaWebhookObject(BaseModel): + id: str + status: str + paid: bool + +class YookassaWebhook(BaseModel): + event: str + type: str + object: YookassaWebhookObject + +class TicketBookRequest(BaseModel): + seat_id: int + +class TicketBookResponse(BaseModel): + ticket_id: int + payment_url: str class TournamentInfo(BaseModel): id: int