o2o-site-AEO/solution/backend/router/v1/site/booking_request.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

82 lines
2.7 KiB
Python

"""발행본의 예약 요청 폼 → 사장님 메일."""
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))