Update rabbitMQ&paimentsMOK
All checks were successful
Deploy / deploy (push) Successful in 48s

This commit is contained in:
2026-03-12 09:00:14 +00:00
parent 1788a12cda
commit 1dcecb8d52
4 changed files with 191 additions and 65 deletions

View File

@@ -1,16 +1,31 @@
import os
import uuid
import httpx
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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 api.deps import get_current_user
from database.models import Seat, Ticket, TicketStatus, User from database.models import Seat, Ticket, TicketStatus, User
from database.session import get_db 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 = APIRouter(prefix="/api/tickets", tags=["tickets"])
@router.get("/me", response_model=list[TicketResponse]) @router.get("/me", response_model=list[TicketResponse])
async def get_my_tickets( async def get_my_tickets(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -20,10 +35,7 @@ async def get_my_tickets(
result = await db.execute( result = await db.execute(
select(Ticket) select(Ticket)
.where(Ticket.user_id == current_user.id, Ticket.status == TicketStatus.PAID) .where(Ticket.user_id == current_user.id, Ticket.status == TicketStatus.PAID)
.options( .options(selectinload(Ticket.seat).selectinload(Seat.tournament))
# Ticket → Seat → Tournament (один запрос на каждый уровень, без N+1)
selectinload(Ticket.seat).selectinload(Seat.tournament)
)
.order_by(Ticket.created_at.desc()) .order_by(Ticket.created_at.desc())
) )
return list(result.scalars().all()) return list(result.scalars().all())
@@ -35,51 +47,147 @@ async def scan_ticket(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> TicketScanResponse: ) -> TicketScanResponse:
""" """Сканирует билет по secret_token (QR-код)."""
Сканирует билет по secret_token (QR-код). result = await db.execute(select(Ticket).where(Ticket.secret_token == body.token))
Переводит статус PAID → SCANNED. Идемпотентно обрабатывает повторное сканирование.
"""
result = await db.execute(
select(Ticket).where(Ticket.secret_token == body.token)
)
ticket: Ticket | None = result.scalar_one_or_none() ticket: Ticket | None = result.scalar_one_or_none()
if ticket is None: if ticket is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=TicketScanResponse( detail=TicketScanResponse(success=False, message="Билет не найден или подделка").model_dump(),
success=False,
message="Билет не найден или подделка",
ticket_id=None,
).model_dump(),
) )
if ticket.status == TicketStatus.SCANNED: if ticket.status == TicketStatus.SCANNED:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=TicketScanResponse( detail=TicketScanResponse(success=False, message="Билет уже отсканирован!", ticket_id=ticket.id).model_dump(),
success=False,
message="Билет уже отсканирован!",
ticket_id=ticket.id,
).model_dump(),
) )
if ticket.status != TicketStatus.PAID: if ticket.status != TicketStatus.PAID:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=TicketScanResponse( detail=TicketScanResponse(success=False, message=f"Проход запрещен: статус билета '{ticket.status.value}'", ticket_id=ticket.id).model_dump(),
success=False,
message=f"Проход запрещен: статус билета '{ticket.status.value}'",
ticket_id=ticket.id,
).model_dump(),
) )
# PAID → SCANNED
ticket.status = TicketStatus.SCANNED ticket.status = TicketStatus.SCANNED
await db.commit() await db.commit()
return TicketScanResponse(success=True, message="Проход разрешен", ticket_id=ticket.id)
return TicketScanResponse(
success=True, @router.post("/book", response_model=TicketBookResponse)
message="Проход разрешен", async def book_ticket(
ticket_id=ticket.id, 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"}

View File

@@ -1,34 +1,35 @@
import json
import os import os
import json
import aio_pika import aio_pika
import logging
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://user:password@rabbitmq/") logger = logging.getLogger(__name__)
QUEUE_NAME: str = "ticket_events"
QUEUE_NAME = "pdf_generation_queue"
async def publish_ticket_paid_event(ticket_id: int, user_id: int) -> None: async def publish_ticket_task(ticket_id: int):
"""Публикует событие ticket_paid в очередь RabbitMQ. """Отправляет ID билета в RabbitMQ."""
# Читаем строго из ENV прямо перед вызовом
rabbitmq_url = os.getenv("RABBITMQ_URL")
if not rabbitmq_url:
logger.error("КРИТИЧЕСКАЯ ОШИБКА: RABBITMQ_URL не задан в переменных окружения!")
return
Формат совпадает с тем, что ожидает worker.py: try:
{"action": "ticket_paid", "ticket_id": <int>, "user_id": <int>} connection = await aio_pika.connect_robust(rabbitmq_url)
""" async with connection:
connection = await aio_pika.connect_robust(RABBITMQ_URL) channel = await connection.channel()
async with connection: queue = await channel.declare_queue(QUEUE_NAME, durable=True)
channel = await connection.channel()
message = aio_pika.Message(
# durable=True — очередь переживёт перезапуск брокера body=json.dumps({"ticket_id": ticket_id}).encode(),
queue = await channel.declare_queue(QUEUE_NAME, durable=True) delivery_mode=aio_pika.DeliveryMode.PERSISTENT
)
body = json.dumps(
{"action": "ticket_paid", "ticket_id": ticket_id, "user_id": user_id} await channel.default_exchange.publish(
).encode() message,
routing_key=queue.name,
await channel.default_exchange.publish( )
aio_pika.Message( logger.info(f"Задача на генерацию PDF для билета {ticket_id} улетела в очередь.")
body=body, except Exception as e:
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, logger.error(f"Сбой RabbitMQ при отправке билета {ticket_id}: {e}")
content_type="application/json",
),
routing_key=queue.name,
)

View File

@@ -13,4 +13,5 @@ reportlab
aioboto3 aioboto3
pydantic[email]>=2.5.0 pydantic[email]>=2.5.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
yookassa==3.3.0 yookassa==3.3.0
httpx

View File

@@ -4,6 +4,22 @@ from pydantic import BaseModel, ConfigDict
from database.models import TicketStatus 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): class TournamentInfo(BaseModel):
id: int id: int