"""이용 후기 접수 — 발행본에서 손님이 남긴다. ★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 — 허니팟 · 최소 체류시간 · 레이트리밋. ★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다. """ import uuid from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from common.logger import LOG from router.v1.site.booking_request import MIN_ELAPSED_MS, _allow from router.v1.validator.dependencies import RemoveNoneResponse from services.review_service import ReviewService router = APIRouter(prefix="/v1/site", tags=["Site"]) IP_LIMIT_PER_HOUR = 3 PLACE_LIMIT_PER_HOUR = 30 class ReqReview(BaseModel): place_id: uuid.UUID body: str = Field(min_length=1, max_length=1000) nickname: str | None = Field(default=None, max_length=40) consent: bool company: str | None = Field(default=None, max_length=100) elapsed_ms: int = 0 client_ip: str | None = None class ResReview(BaseModel): success: bool message: str class PublicReview(BaseModel): reviewId: str body: str nickname: str publishedAt: str class ResPublicReviews(BaseModel): items: list[PublicReview] = [] @router.post(path="/review", response_model=ResReview, summary="이용 후기 남기기") async def submit_review(body: ReqReview, request: Request, service: ReviewService = 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("[review] 봇 의심 요청을 버렸다") return RemoveNoneResponse(ResReview(success=True, message="후기를 남겨 주셔서 고맙습니다.")) if not body.consent: return RemoveNoneResponse(ResReview(success=False, message="공개에 동의해 주세요.")) if not _allow(f"review-ip:{client_ip}", IP_LIMIT_PER_HOUR) or \ not _allow(f"review-place:{body.place_id}", PLACE_LIMIT_PER_HOUR): return RemoveNoneResponse(ResReview( success=False, message="요청이 많습니다. 잠시 뒤 다시 남겨 주세요.", )) body.client_ip = client_ip return RemoveNoneResponse(await service.submit(body)) @router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다") async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()): """날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고, 화면은 붙은 뒤 이 주소로 최신을 받아 덮는다.""" return RemoveNoneResponse(await service.list_public(place_id))