"""발행본의 예약 요청 폼 → 사장님 메일. ★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다. ★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고, 보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다. ★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은 **연락 요청**이고, 화면도 그렇게 말한다. """ 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))