인앱 미니 블로그(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에서 보정).
183 lines
7.2 KiB
Python
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))
|