o2o-site-AEO/solution/backend/router/v1/site/booking_request.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

89 lines
3.4 KiB
Python

"""발행본의 예약 요청 폼 → 사장님 메일.
★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
★ 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))