"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**. ★ 정적 사이트인데도 즉시 보이는 방법은 날씨(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))