o2o-site-AEO/solution/backend/services/review_service.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

183 lines
7.2 KiB
Python

"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**.
★ 정적 사이트인데도 즉시 보이는 방법은 날씨(useLiveWeather)가 이미 쓰는 것이다 —
구운 HTML 에는 굽는 시점까지의 후기가 들어가고, 브라우저가 붙은 뒤 최신 목록을 한 번 더
받아 덮는다. 크롤러는 구운 것을 읽고 손님은 최신을 본다.
★ 그래서 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 대신 기계가
거른다 — 길이·연락처. 문제 글은 어드민에서 **내린다**(사후 대응).
★ 사진은 받지 않는다. 받는 순간 남의 파일을 우리가 호스팅한다.
★ JSON-LD 로 내보내지 않는다 — 자체 수집 후기는 구글 리치결과 대상이 아니다.
"""
import hashlib
import os
import re
from sqlalchemy import select, update
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_reviews, places, sites
from common.enums import DBWRType, ErrorType, ReviewStatus, SiteStatus
from common.logger import LOG
from common.utils.gtime import GTime
MIN_LEN = 10
MAX_LEN = 500
NOT_AVAILABLE = "지금은 후기를 받을 수 없습니다."
RECEIVED = "후기를 남겨 주셔서 고맙습니다."
TOO_SHORT = f"{MIN_LEN}자 이상 적어 주세요."
# 연락처가 본문에 섞여 들어오는 것을 막는다 — 받지 않기로 한 값이 글로 들어오면 결국 보관하게 된다.
_CONTACT = (
re.compile(r"\d{2,3}-\d{3,4}-\d{4}"),
re.compile(r"01[016789][-\s]?\d{3,4}[-\s]?\d{4}"),
re.compile(r"[^@\s]+@[^@\s]+\.[A-Za-z]{2,}"),
)
def _ip_hash(ip: str) -> str:
salt = os.environ.get("JWT_ACCESS_SECRET", "")
return hashlib.sha256(f"{salt}{ip}".encode("utf-8")).hexdigest()
def check_body(text: str) -> tuple[bool, str]:
body = (text or "").strip()
if len(body) < MIN_LEN:
return False, TOO_SHORT
if len(body) > MAX_LEN:
return False, f"{MAX_LEN}자까지 적을 수 있습니다."
for pattern in _CONTACT:
if pattern.search(body):
return False, "전화번호·이메일은 적지 말아 주세요. 공개되는 글입니다."
return True, ""
class ReviewService:
async def submit(self, body):
from router.v1.site.review import ResReview
ok, reason = check_body(body.body)
if not ok:
return ResReview(success=False, message=reason)
if not await self._is_published(body.place_id):
return ResReview(success=False, message=NOT_AVAILABLE)
nickname = (body.nickname or "").strip()[:40] or None
text_body = body.body.strip()
ip_hash = _ip_hash(body.client_ip or "")
async def run(session):
session.add(place_reviews(
place_id=body.place_id, body=text_body, nickname=nickname,
status=ReviewStatus.PUBLISHED.value, published_at=GTime.UTC(),
submitted_ip_hash=ip_hash,
))
return ErrorType.SUCCESS
await DB_SESSION_MNG.execute_lambda_run([place_reviews.DBType()], [run])
LOG.i(f"[review] 접수 place={body.place_id}")
return ResReview(success=True, message=RECEIVED)
async def _is_published(self, place_id) -> bool:
def query(session):
return session.execute(
select(sites.site_id).join(places, places.place_id == sites.place_id).where(
sites.place_id == place_id,
sites.deleted == False, # noqa: E712
places.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
return bool(result and result.first())
async def list_public(self, place_id, limit: int = 200):
"""화면이 붙은 뒤 받아 가는 최신 목록. 내려간 글은 여기서 빠진다."""
from router.v1.site.review import PublicReview, ResPublicReviews
def query(session):
return session.execute(
select(place_reviews)
.where(
place_reviews.place_id == place_id,
place_reviews.status == ReviewStatus.PUBLISHED.value,
place_reviews.deleted == False, # noqa: E712
)
.order_by(place_reviews.published_at.desc())
.limit(limit)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
rows = list(result.scalars()) if result is not None else []
res = ResPublicReviews()
res.items = [
PublicReview(
reviewId=str(row.review_id),
body=row.body,
nickname=row.nickname or "",
publishedAt=row.published_at.isoformat() if row.published_at else "",
)
for row in rows
]
return res
async def list_for_review(self, status: int, limit: int):
from router.v1.site.review_admin import ResReviews, ReviewItem
def query(session):
return session.execute(
select(place_reviews, places.name)
.join(places, places.place_id == place_reviews.place_id)
.where(place_reviews.status == status, place_reviews.deleted == False) # noqa: E712
.order_by(place_reviews.created_at)
.limit(limit)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
rows = list(result.all()) if result is not None else []
res = ResReviews(total=len(rows))
res.items = [
ReviewItem(
review_id=str(review.review_id),
place_id=str(review.place_id),
place_name=name or "",
body=review.body,
nickname=review.nickname or "",
status=int(review.status),
created_at=review.created_at.isoformat() if review.created_at else "",
)
for review, name in rows
]
return res
async def decide(self, review_ids, *, publish: bool):
from router.v1.site.review_admin import ResDecide
ids = list(review_ids)
if not ids:
return ResDecide(changed=0)
target = ReviewStatus.PUBLISHED.value if publish else ReviewStatus.REJECTED.value
async def run(session):
# 올리기도 내리기도 여기서 한다 — 사후 대응이라 상태를 가리지 않는다.
await session.execute(
update(place_reviews)
.where(
place_reviews.review_id.in_(ids),
place_reviews.deleted == False, # noqa: E712
)
.values(status=target, published_at=GTime.UTC() if publish else None,
updated_at=GTime.UTC())
)
return ErrorType.SUCCESS
await DB_SESSION_MNG.execute_lambda_run([place_reviews.DBType()], [run])
return ResDecide(changed=len(ids))