phase 3 23 qr-scan-check

This commit is contained in:
2026-03-10 12:11:38 +00:00
parent 887a718a65
commit 3bf4a2189f
8 changed files with 377 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends 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
@@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload
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 from schemas.ticket import TicketResponse, TicketScanRequest, TicketScanResponse
router = APIRouter(prefix="/api/tickets", tags=["tickets"]) router = APIRouter(prefix="/api/tickets", tags=["tickets"])
@@ -27,3 +27,59 @@ async def get_my_tickets(
.order_by(Ticket.created_at.desc()) .order_by(Ticket.created_at.desc())
) )
return list(result.scalars().all()) 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,
)

View File

@@ -3,7 +3,6 @@
Поддержка кириллицы: ищет системный TTF-шрифт; при неудаче — транслитерация. Поддержка кириллицы: ищет системный TTF-шрифт; при неудаче — транслитерация.
""" """
import io import io
import json
import os import os
import qrcode import qrcode
@@ -85,6 +84,7 @@ def generate_qr_ticket(
row: int, row: int,
number: int, number: int,
price: int, price: int,
secret_token: str,
) -> bytes: ) -> bytes:
""" """
Renders a landscape ticket (600×250 pt) and returns PDF 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("Сканировать при входе")) c.drawCentredString(495, 30, _safe("Сканировать при входе"))
# ── QR code ── # ── QR code ──
qr_data = json.dumps( qr_data = f"https://openticket.artifitial.ru/scanner?token={secret_token}"
{"id": ticket_id, "t": title, "s": sector, "r": row, "m": number},
ensure_ascii=False,
separators=(",", ":"),
)
qr = qrcode.QRCode(box_size=5, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M) qr = qrcode.QRCode(box_size=5, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(qr_data) qr.add_data(qr_data)
qr.make(fit=True) qr.make(fit=True)

View File

@@ -1,4 +1,5 @@
import enum import enum
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import String, Integer, ForeignKey, DateTime, Enum, Boolean from sqlalchemy import String, Integer, ForeignKey, DateTime, Enum, Boolean
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 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) idempotency_key: Mapped[str] = mapped_column(String, unique=True, nullable=True)
pdf_url: Mapped[str | None] = mapped_column(String, 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)) 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),

View File

@@ -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 ###

View File

@@ -32,3 +32,13 @@ class TicketResponse(BaseModel):
seat: SeatInfo seat: SeatInfo
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TicketScanRequest(BaseModel):
token: str
class TicketScanResponse(BaseModel):
success: bool
message: str
ticket_id: int | None

View File

@@ -71,6 +71,7 @@ async def _handle_ticket_paid(
row=seat.row, row=seat.row,
number=seat.number, number=seat.number,
price=seat.price, price=seat.price,
secret_token=str(ticket.secret_token),
) )
object_name = f"tickets/ticket_{ticket_id}.pdf" object_name = f"tickets/ticket_{ticket_id}.pdf"

View File

@@ -122,6 +122,34 @@ export async function processPaymentWebhook(ticketId: number): Promise<void> {
}); });
} }
// ─── 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<ScanResponse> {
try {
const response = await apiClient.post<ScanResponse>("/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 ────────────────────────────────────────────────── // ─── Tournament public seats ──────────────────────────────────────────────────
/** /**

View File

@@ -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 (
<div
className={`flex flex-col items-center justify-center min-h-screen w-full px-6 transition-colors duration-300 ${
isSuccess ? "bg-green-500" : "bg-red-500"
}`}
>
{isSuccess ? (
<CheckCircle2 className="text-white mb-6" size={120} strokeWidth={1.5} />
) : (
<XCircle className="text-white mb-6" size={120} strokeWidth={1.5} />
)}
<p className="text-white text-[32px] font-black tracking-tight text-center uppercase leading-tight">
{isSuccess ? "ПРОХОД РАЗРЕШЕН" : "ПРОХОД ЗАПРЕЩЕН"}
</p>
{ticketId && (
<p className="text-white/70 text-[14px] font-medium mt-2">
Билет #{ticketId}
</p>
)}
<p className="text-white/90 text-[18px] font-semibold mt-4 text-center max-w-[300px] leading-snug">
{message}
</p>
<button
onClick={onReset}
className="mt-12 px-8 py-3.5 bg-white/20 hover:bg-white/30 active:scale-95 transition-all rounded-2xl text-white text-[15px] font-semibold"
>
Сканировать следующий
</button>
</div>
);
}
// ─── Loading screen ───────────────────────────────────────────────────────────
function LoadingScreen() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#121212]">
<Loader2 size={64} className="text-[#E32636] animate-spin mb-6" strokeWidth={1.5} />
<p className="text-white text-[20px] font-bold">Проверка билета</p>
<p className="text-[#8E8E93] text-[13px] mt-2">Запрос к серверу</p>
</div>
);
}
// ─── Manual input form ────────────────────────────────────────────────────────
function ManualInput({
onScan,
loading,
}: {
onScan: (token: string) => void;
loading: boolean;
}) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(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 (
<form onSubmit={handleSubmit} className="w-full max-w-[340px] flex flex-col gap-3">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={loading || !value.trim()}
className="w-full flex items-center justify-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[16px] font-bold py-4 rounded-2xl"
>
{loading ? (
<><Loader2 size={18} className="animate-spin" /> Проверка</>
) : (
<><ScanLine size={18} /> Проверить билет</>
)}
</button>
</form>
);
}
// ─── 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<ScanStatus>("idle");
const [message, setMessage] = useState("");
const [ticketId, setTicketId] = useState<number | undefined>();
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 <LoadingScreen />;
if (scanStatus === "success" || scanStatus === "error") {
return (
<ResultScreen
scanStatus={scanStatus}
message={message}
ticketId={ticketId}
onReset={handleReset}
/>
);
}
// ── Idle: manual input UI ──
return (
<div className="flex justify-center min-h-screen bg-[#121212]">
<div className="w-full max-w-[390px] flex flex-col items-center px-5 pt-16 pb-10">
{/* Brand / header */}
<div className="w-16 h-16 rounded-2xl bg-[#E32636] flex items-center justify-center mb-6">
<ScanLine size={32} className="text-white" strokeWidth={2} />
</div>
<h1 className="text-[26px] font-black text-white tracking-tight mb-1">
Сканер билетов
</h1>
<p className="text-[13px] text-[#8E8E93] mb-10 text-center">
Наведите QR-код или введите токен вручную
</p>
<ManualInput onScan={runScan} loading={false} />
{/* Hint for USB scanners */}
<p className="text-[11px] text-[#4B5563] mt-8 text-center leading-relaxed max-w-[260px]">
USB-сканер работает как клавиатура {"\n"}поле захватит фокус автоматически
</p>
<button
onClick={() => router.push("/")}
className="mt-auto pt-10 flex items-center gap-1.5 text-[12px] text-[#8E8E93] hover:text-white transition-colors"
>
<LogIn size={13} />
На главную
</button>
</div>
</div>
);
}
// ─── Page export (wraps in Suspense for useSearchParams) ─────────────────────
export default function ScannerPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-[#121212]">
<Loader2 size={40} className="text-[#E32636] animate-spin" />
</div>
}
>
<ScannerCore />
</Suspense>
);
}