diff --git a/backend/api/routers/tickets.py b/backend/api/routers/tickets.py index 99f56b0..4418d40 100644 --- a/backend/api/routers/tickets.py +++ b/backend/api/routers/tickets.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload 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 +from schemas.ticket import TicketResponse, TicketScanRequest, TicketScanResponse router = APIRouter(prefix="/api/tickets", tags=["tickets"]) @@ -27,3 +27,59 @@ async def get_my_tickets( .order_by(Ticket.created_at.desc()) ) return list(result.scalars().all()) + + +@router.post("/scan", response_model=TicketScanResponse) +async def scan_ticket( + body: TicketScanRequest, + 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) + ) + 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(), + ) + + 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(), + ) + + 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(), + ) + + # PAID → SCANNED + ticket.status = TicketStatus.SCANNED + await db.commit() + + return TicketScanResponse( + success=True, + message="Проход разрешен", + ticket_id=ticket.id, + ) diff --git a/backend/core/pdf_generator.py b/backend/core/pdf_generator.py index d74f2ca..3409d34 100644 --- a/backend/core/pdf_generator.py +++ b/backend/core/pdf_generator.py @@ -3,7 +3,6 @@ Поддержка кириллицы: ищет системный TTF-шрифт; при неудаче — транслитерация. """ import io -import json import os import qrcode @@ -85,6 +84,7 @@ def generate_qr_ticket( row: int, number: int, price: int, + secret_token: str, ) -> bytes: """ Renders a landscape ticket (600×250 pt) and returns PDF bytes. @@ -165,11 +165,7 @@ def generate_qr_ticket( c.drawCentredString(495, 30, _safe("Сканировать при входе")) # ── QR code ── - qr_data = json.dumps( - {"id": ticket_id, "t": title, "s": sector, "r": row, "m": number}, - ensure_ascii=False, - separators=(",", ":"), - ) + qr_data = f"https://openticket.artifitial.ru/scanner?token={secret_token}" qr = qrcode.QRCode(box_size=5, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M) qr.add_data(qr_data) qr.make(fit=True) diff --git a/backend/database/models.py b/backend/database/models.py index 297623c..96629e8 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -1,4 +1,5 @@ import enum +import uuid from datetime import datetime, timezone from sqlalchemy import String, Integer, ForeignKey, DateTime, Enum, Boolean from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -55,6 +56,11 @@ class Ticket(Base): ) idempotency_key: Mapped[str] = mapped_column(String, unique=True, nullable=True) pdf_url: Mapped[str | None] = mapped_column(String, nullable=True) + # nullable=True — безопасно для существующих строк; новые билеты получают UUID автоматически + secret_token: Mapped[str | None] = mapped_column( + String, unique=True, index=True, nullable=True, + default=lambda: str(uuid.uuid4()), + ) 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/migrations/versions/b2e071ae215a_add_secret_token.py b/backend/migrations/versions/b2e071ae215a_add_secret_token.py new file mode 100644 index 0000000..7ab7624 --- /dev/null +++ b/backend/migrations/versions/b2e071ae215a_add_secret_token.py @@ -0,0 +1,34 @@ +"""add secret token + +Revision ID: b2e071ae215a +Revises: d096f9d0b612 +Create Date: 2026-03-10 11:51:02.385582 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b2e071ae215a' +down_revision: Union[str, Sequence[str], None] = 'd096f9d0b612' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tickets', sa.Column('secret_token', sa.String(), nullable=True)) + op.create_index(op.f('ix_tickets_secret_token'), 'tickets', ['secret_token'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tickets_secret_token'), table_name='tickets') + op.drop_column('tickets', 'secret_token') + # ### end Alembic commands ### diff --git a/backend/schemas/ticket.py b/backend/schemas/ticket.py index 5e63d1c..6522b7f 100644 --- a/backend/schemas/ticket.py +++ b/backend/schemas/ticket.py @@ -32,3 +32,13 @@ class TicketResponse(BaseModel): seat: SeatInfo model_config = ConfigDict(from_attributes=True) + + +class TicketScanRequest(BaseModel): + token: str + + +class TicketScanResponse(BaseModel): + success: bool + message: str + ticket_id: int | None diff --git a/backend/worker.py b/backend/worker.py index b8846a0..57c6bcd 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -71,6 +71,7 @@ async def _handle_ticket_paid( row=seat.row, number=seat.number, price=seat.price, + secret_token=str(ticket.secret_token), ) object_name = f"tickets/ticket_{ticket_id}.pdf" diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index 3baaf28..50a8623 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -122,6 +122,34 @@ export async function processPaymentWebhook(ticketId: number): Promise { }); } +// ─── Ticket scanner ─────────────────────────────────────────────────────────── + +export interface ScanResponse { + success: boolean; + message: string; + ticket_id?: number; +} + +/** + * POST /api/tickets/scan + * Validates a ticket token and marks it as SCANNED. + * Always resolves (never throws) — 400/404 error bodies are returned as ScanResponse. + */ +export async function scanTicketApi(token: string): Promise { + try { + const response = await apiClient.post("/tickets/scan", { token }); + return response.data; + } catch (err) { + const axiosErr = err as import("axios").AxiosError<{ detail: ScanResponse }>; + // Backend encodes ScanResponse inside HTTPException.detail + const detail = axiosErr.response?.data?.detail; + if (detail && typeof detail === "object" && "success" in detail) { + return detail as ScanResponse; + } + return { success: false, message: "Ошибка соединения с сервером" }; + } +} + // ─── Tournament public seats ────────────────────────────────────────────────── /** diff --git a/frontend-client/src/app/scanner/page.tsx b/frontend-client/src/app/scanner/page.tsx new file mode 100644 index 0000000..605de9c --- /dev/null +++ b/frontend-client/src/app/scanner/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { CheckCircle2, XCircle, Loader2, ScanLine, LogIn } from "lucide-react"; +import { scanTicketApi, type ScanResponse } from "@/api/client"; +import { useAuthStore } from "@/store/authStore"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ScanStatus = "idle" | "loading" | "success" | "error"; + +// ─── Result screen ──────────────────────────────────────────────────────────── + +function ResultScreen({ + scanStatus, + message, + ticketId, + onReset, +}: { + scanStatus: "success" | "error"; + message: string; + ticketId?: number; + onReset: () => void; +}) { + const isSuccess = scanStatus === "success"; + + return ( +
+ {isSuccess ? ( + + ) : ( + + )} + +

+ {isSuccess ? "ПРОХОД РАЗРЕШЕН" : "ПРОХОД ЗАПРЕЩЕН"} +

+ + {ticketId && ( +

+ Билет #{ticketId} +

+ )} + +

+ {message} +

+ + +
+ ); +} + +// ─── Loading screen ─────────────────────────────────────────────────────────── + +function LoadingScreen() { + return ( +
+ +

Проверка билета…

+

Запрос к серверу

+
+ ); +} + +// ─── Manual input form ──────────────────────────────────────────────────────── + +function ManualInput({ + onScan, + loading, +}: { + onScan: (token: string) => void; + loading: boolean; +}) { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + // Auto-focus for USB barcode scanners (act as keyboard) + useEffect(() => { + inputRef.current?.focus(); + }, []); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const token = value.trim(); + if (token) onScan(token); + } + + return ( +
+ setValue(e.target.value)} + placeholder="Введите или отсканируйте токен…" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + className="w-full bg-[#2C2C2E] border border-[#3A3A3C] focus:border-[#E32636] rounded-2xl px-5 py-4 text-[15px] text-white placeholder-[#4B5563] outline-none transition-colors text-center tracking-wide" + /> + +
+ ); +} + +// ─── Core scanner logic (needs Suspense because of useSearchParams) ─────────── + +function ScannerCore() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = useAuthStore((s) => s.token); + + const [scanStatus, setScanStatus] = useState("idle"); + const [message, setMessage] = useState(""); + const [ticketId, setTicketId] = useState(); + const scanCalledRef = useRef(false); + + // Auth guard + useEffect(() => { + if (!token) router.push("/login"); + }, [token, router]); + + // Auto-scan when ?token= is present in the URL + useEffect(() => { + const urlToken = searchParams.get("token"); + if (!urlToken || scanCalledRef.current) return; + scanCalledRef.current = true; + void runScan(urlToken); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + async function runScan(rawToken: string) { + setScanStatus("loading"); + setMessage(""); + setTicketId(undefined); + + const result: ScanResponse = await scanTicketApi(rawToken); + + setMessage(result.message); + setTicketId(result.ticket_id); + setScanStatus(result.success ? "success" : "error"); + } + + function handleReset() { + setScanStatus("idle"); + setMessage(""); + setTicketId(undefined); + scanCalledRef.current = false; + // Clear the URL token so a fresh scan can be started + router.replace("/scanner"); + } + + if (!token) return null; + + if (scanStatus === "loading") return ; + + if (scanStatus === "success" || scanStatus === "error") { + return ( + + ); + } + + // ── Idle: manual input UI ── + return ( +
+
+ + {/* Brand / header */} +
+ +
+

+ Сканер билетов +

+

+ Наведите QR-код или введите токен вручную +

+ + + + {/* Hint for USB scanners */} +

+ USB-сканер работает как клавиатура —{"\n"}поле захватит фокус автоматически +

+ + +
+
+ ); +} + +// ─── Page export (wraps in Suspense for useSearchParams) ───────────────────── + +export default function ScannerPage() { + return ( + + + + } + > + + + ); +}