o2o-site-AEO/solution/backend/router/v1/site/review.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

76 lines
2.8 KiB
Python

"""이용 후기 접수 — 발행본에서 손님이 남긴다.
★ 로그인 없는 공개 엔드포인트다. 예약 요청(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))