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 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"}