o2o-site-AEO/solution/backend/services/review_service.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

174 lines
6.4 KiB
Python

"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**."""
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))