여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
174 lines
6.4 KiB
Python
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))
|