"""발행본의 예약 요청 폼 → 사장님 메일.""" import time import uuid from collections import defaultdict, deque from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from common.logger import LOG from router.v1.validator.dependencies import RemoveNoneResponse from services.booking_request_service import BookingRequestService router = APIRouter(prefix="/v1/site", tags=["Site"]) # 한 아이피가 한 시간에 보낼 수 있는 통수. IP_LIMIT_PER_HOUR = 5 PLACE_LIMIT_PER_HOUR = 30 WINDOW_SEC = 3600 # 폼을 연 뒤 이만큼은 지나야 사람으로 친다. MIN_ELAPSED_MS = 1500 _hits: dict[str, deque] = defaultdict(deque) def _allow(key: str, limit: int) -> bool: now = time.monotonic() hits = _hits[key] while hits and now - hits[0] > WINDOW_SEC: hits.popleft() if len(hits) >= limit: return False hits.append(now) return True class ReqBookingRequest(BaseModel): place_id: uuid.UUID name: str = Field(min_length=1, max_length=40) phone: str = Field(min_length=6, max_length=30) email: str | None = Field(default=None, max_length=255) stay: str | None = Field(default=None, max_length=60) guests: str | None = Field(default=None, max_length=30) message: str | None = Field(default=None, max_length=1000) consent: bool # 봇 잡이. company: str | None = Field(default=None, max_length=100) elapsed_ms: int = 0 class ResBookingRequest(BaseModel): success: bool message: str @router.post( path="/booking-request", response_model=ResBookingRequest, summary="예약 요청 — 발행본 폼에서 사장님 메일로 전달", ) async def send_booking_request( body: ReqBookingRequest, request: Request, service: BookingRequestService = Depends(), ): client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown")) # 봇 두 겹. if body.company or body.elapsed_ms < MIN_ELAPSED_MS: LOG.w("[booking-request] 봇 의심 요청을 버렸다") return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다.")) if not body.consent: return RemoveNoneResponse(ResBookingRequest(success=False, message="연락처 수집에 동의해 주세요.")) if not _allow(f"ip:{client_ip}", IP_LIMIT_PER_HOUR) or not _allow(f"place:{body.place_id}", PLACE_LIMIT_PER_HOUR): return RemoveNoneResponse(ResBookingRequest( success=False, message="요청이 많습니다. 잠시 뒤 다시 시도하거나 전화로 문의해 주세요.", )) return RemoveNoneResponse(await service.send(body))