여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다."""
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
|
||
from sqlalchemy import or_, select
|
||
|
||
from common.category_schema import get_schema
|
||
from common.database.db_session_manager import DB_SESSION_MNG
|
||
from common.database.model.models import (
|
||
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_posts, place_reviews, place_songs, place_units,
|
||
site_sections, sites, place_social_posts,
|
||
)
|
||
from common.enums import (
|
||
PUBLISHABLE_FACT_STATUSES,
|
||
DBWRType,
|
||
ErrorType,
|
||
FactStatus,
|
||
LocalContentStatus,
|
||
LocalContentType,
|
||
LocalSource,
|
||
MediaStatus,
|
||
PlaceCategory,
|
||
PostStatus,
|
||
ReviewStatus,
|
||
SongStatus,
|
||
)
|
||
from common.logger import LOG
|
||
from services.external.naver import region_key
|
||
|
||
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
|
||
|
||
# 지역 정보를 종류별로 몇 건까지 박제할지.
|
||
_LOCAL_MAX_PER_TYPE = 20
|
||
|
||
# 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004 `uq_local_contents_kind`).
|
||
|
||
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
|
||
_LOCAL_BODY_DROP = ("raw",)
|
||
|
||
|
||
async def build_snapshot(place) -> dict:
|
||
"""사업장 1건의 빌드 스냅샷을 만든다."""
|
||
pid = place.place_id if isinstance(place.place_id, uuid.UUID) else uuid.UUID(str(place.place_id))
|
||
category = PlaceCategory(place.category)
|
||
schema = get_schema(category)
|
||
|
||
fact_rows = await _select(
|
||
select(place_facts).where(
|
||
place_facts.place_id == pid,
|
||
place_facts.deleted == False, # noqa: E712
|
||
place_facts.status.in_(_PUBLISHABLE),
|
||
)
|
||
)
|
||
unit_rows = await _select(
|
||
select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712
|
||
.order_by(place_units.sort_order.asc())
|
||
)
|
||
faq_rows = await _select(
|
||
select(place_faqs).where(
|
||
place_faqs.place_id == pid,
|
||
place_faqs.deleted == False, # noqa: E712
|
||
place_faqs.status.in_(_PUBLISHABLE),
|
||
).order_by(place_faqs.sort_order.asc())
|
||
)
|
||
# 승인된 사진만.
|
||
media_rows = await _select(
|
||
select(place_photos).where(
|
||
place_photos.place_id == pid,
|
||
place_photos.deleted == False, # noqa: E712
|
||
place_photos.status == MediaStatus.APPROVED.value,
|
||
).order_by(place_photos.sort_order.asc())
|
||
)
|
||
|
||
# 완성(READY)된 최신 곡 하나.
|
||
song_rows = await _select(
|
||
select(place_songs).where(
|
||
place_songs.place_id == pid,
|
||
place_songs.deleted == False, # noqa: E712
|
||
place_songs.status == SongStatus.READY.value,
|
||
).order_by(place_songs.created_at.desc()).limit(1)
|
||
)
|
||
|
||
# 미니 블로그 — 사장님이 승인한 글과 이미 게재된 글.
|
||
post_rows = await _select(
|
||
select(place_posts).where(
|
||
place_posts.place_id == pid,
|
||
place_posts.deleted == False, # noqa: E712
|
||
place_posts.status.in_((PostStatus.APPROVED.value, PostStatus.PUBLISHED.value)),
|
||
).order_by(place_posts.created_at.desc()).limit(200)
|
||
)
|
||
|
||
# 이용 후기 — 검수를 통과한 것만.
|
||
review_rows = await _select(
|
||
select(place_reviews).where(
|
||
place_reviews.place_id == pid,
|
||
place_reviews.deleted == False, # noqa: E712
|
||
place_reviews.status == ReviewStatus.PUBLISHED.value,
|
||
).order_by(place_reviews.published_at.desc()).limit(200)
|
||
)
|
||
|
||
social_rows = await _select(select(place_social_posts).where(
|
||
place_social_posts.place_id == pid, place_social_posts.deleted == False, # noqa: E712
|
||
place_social_posts.status == 'POSTED', place_social_posts.posted_at.is_not(None),
|
||
).order_by(place_social_posts.posted_at.desc()).limit(3))
|
||
local_rows = await _local_contents(place)
|
||
|
||
snapshot = {
|
||
"place": {
|
||
"name": place.name,
|
||
"category": category.value,
|
||
"category_name": schema.label,
|
||
"road_address": place.road_address,
|
||
"address": place.address,
|
||
"phone": place.phone,
|
||
"latitude": str(place.latitude) if place.latitude is not None else None,
|
||
"longitude": str(place.longitude) if place.longitude is not None else None,
|
||
},
|
||
"facts": [
|
||
{
|
||
"key": r.key,
|
||
"label": (schema.get(r.key).label if schema.get(r.key) else r.key),
|
||
"value": r.value,
|
||
"unit": r.unit,
|
||
"scope": (schema.get(r.key).scope if schema.get(r.key) else "place"),
|
||
"unit_id": str(r.unit_id) if r.unit_id else None,
|
||
# 게이트가 다시 볼 수 있게 상태를 함께 싣는다(스냅샷은 감사 기록이기도 하다).
|
||
"status": r.status,
|
||
# 출처와 확인 시각도 박제한다.
|
||
"source_type": r.source_type,
|
||
"source_url": r.source_url,
|
||
"collected_at": _iso(r.collected_at),
|
||
"verified_at": _iso(r.verified_at),
|
||
}
|
||
for r in fact_rows
|
||
],
|
||
# sort_order 를 함께 싣는다 — 객실·메뉴 순서는 사장님이 정한 것이고, 발행본도 그 순서를 따른다.
|
||
"units": [{"unit_id": str(r.unit_id), "name": r.name, "sort_order": r.sort_order} for r in unit_rows],
|
||
"faqs": [
|
||
{
|
||
"faq_id": str(r.faq_id),
|
||
"question": r.question,
|
||
"answer": r.answer,
|
||
# 렌더러가 노출 필터를 한 번 더 걸 수 있게 상태·출처를 싣는다(fact 와 같은 규칙).
|
||
"status": r.status,
|
||
"generated_by": r.generated_by,
|
||
"sort_order": r.sort_order,
|
||
}
|
||
for r in faq_rows
|
||
],
|
||
# alt 가 없는 사진은 넣지 않는다 — 빌더가 렌더하지 않고, 접근성·AI 검색 신호도 잃는다.
|
||
"media": [
|
||
{
|
||
"media_id": str(r.media_id),
|
||
"url": r.url,
|
||
"origin_url": r.origin_url,
|
||
"source_type": r.source_type,
|
||
"label": r.label,
|
||
"alt_text": r.alt_text,
|
||
"width": r.width,
|
||
"height": r.height,
|
||
"sort_order": r.sort_order,
|
||
"unit_id": str(r.unit_id) if r.unit_id else None,
|
||
}
|
||
for r in media_rows
|
||
if (r.alt_text or "").strip()
|
||
],
|
||
# 지역 정보.
|
||
"local": local_rows,
|
||
# 승인 토큰·계정 자격증명·근거 원문은 공개 스냅샷으로 보내지 않는다.
|
||
"social_posts": [{"post_id": str(r.post_id), "provider": r.provider, "body": r.body,
|
||
"permalink": r.permalink, "posted_at": r.posted_at.isoformat()}
|
||
for r in social_rows],
|
||
# 이 숙소의 노래.
|
||
"songs": [
|
||
{
|
||
"song_id": str(r.song_id),
|
||
"title": r.title,
|
||
"lyrics": r.lyrics,
|
||
"style": r.style,
|
||
"file_name": r.file_name,
|
||
"duration_sec": float(r.duration_sec) if r.duration_sec is not None else None,
|
||
}
|
||
for r in song_rows
|
||
if (r.file_name or "").strip()
|
||
],
|
||
# 본문과 날짜만 싣는다.
|
||
"posts": [
|
||
{
|
||
"post_id": str(r.post_id),
|
||
"body": r.body,
|
||
"topic_kind": int(r.topic_kind),
|
||
"published_at": (r.published_at or r.approved_at or r.created_at).isoformat(),
|
||
}
|
||
for r in post_rows
|
||
],
|
||
# 손님이 적은 표시 이름만 싣는다.
|
||
"reviews": [
|
||
{
|
||
"review_id": str(r.review_id),
|
||
"body": r.body,
|
||
"nickname": r.nickname or "",
|
||
"published_at": (r.published_at or r.created_at).isoformat(),
|
||
}
|
||
for r in review_rows
|
||
],
|
||
}
|
||
LOG.i(
|
||
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
|
||
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
|
||
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])} · 글 {len(snapshot['posts'])} · 후기 {len(snapshot['reviews'])}"
|
||
)
|
||
return snapshot
|
||
|
||
|
||
async def _local_contents(place) -> dict:
|
||
"""사업장의 노출 가능한 지역·주변 정보."""
|
||
# getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다.
|
||
region_code = str(getattr(place, "region_code", None) or "").strip()
|
||
if not region_code:
|
||
# 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다.
|
||
region_code = region_key(
|
||
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
|
||
) or ""
|
||
now = datetime.now(timezone.utc)
|
||
contents: list[dict] = []
|
||
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
|
||
|
||
# ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
|
||
if region_code:
|
||
query = (
|
||
select(area_contents)
|
||
.where(
|
||
area_contents.region_code == region_code,
|
||
area_contents.deleted == False, # noqa: E712
|
||
# **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌).
|
||
area_contents.external_id.is_(None),
|
||
area_contents.status == LocalContentStatus.PUBLISHED.value,
|
||
or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now),
|
||
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
|
||
)
|
||
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
|
||
)
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False)
|
||
)
|
||
if err != ErrorType.SUCCESS:
|
||
# 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
|
||
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
|
||
rows = []
|
||
contents += _local_rows(rows or [], seen)
|
||
|
||
# ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ──
|
||
place_id = getattr(place, "place_id", None)
|
||
if place_id is not None:
|
||
personal = await _site_places(place_id)
|
||
if personal:
|
||
ids = [uuid.UUID(k) for k in personal if _is_uuid(k)]
|
||
shared_q = select(area_contents).where(
|
||
area_contents.local_content_id.in_(ids),
|
||
area_contents.deleted == False, # noqa: E712
|
||
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
|
||
)
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
area_contents.DBType(), DBWRType.DB_READ.value,
|
||
lambda s: DB_SESSION_MNG.execute(s, shared_q, raise_error=False),
|
||
)
|
||
if err != ErrorType.SUCCESS:
|
||
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
|
||
rows = []
|
||
|
||
merged = []
|
||
for row in rows or []:
|
||
mine = personal.get(str(row.local_content_id)) or {}
|
||
if mine.get("hidden"):
|
||
continue
|
||
body = dict(row.body if isinstance(row.body, dict) else {})
|
||
# 거리만 얹는다.
|
||
if mine.get("distanceMeters") is not None:
|
||
body["distanceMeters"] = mine["distanceMeters"]
|
||
merged.append((row, body, mine.get("distanceMeters")))
|
||
|
||
merged.sort(key=lambda t: (not bool(t[1].get("imageUrl")), t[2] if t[2] is not None else 1 << 30))
|
||
contents += _local_rows(
|
||
[_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value
|
||
)
|
||
|
||
# ── 여행 일정(place_itineraries) — 업장마다 기간당 한 행 ──────────
|
||
itineraries: list[dict] = []
|
||
if place_id is not None:
|
||
from services.itinerary_llm_service import get_itineraries
|
||
|
||
itineraries = await get_itineraries(place_id)
|
||
|
||
return {
|
||
"region_code": region_code or None,
|
||
"contents": contents,
|
||
"itineraries": itineraries,
|
||
}
|
||
|
||
|
||
class _Row:
|
||
"""area_contents 행 + 사이트 값이 얹힌 body."""
|
||
|
||
__slots__ = ("content_type", "source", "title", "body", "collected_at", "kind",
|
||
"latitude", "longitude")
|
||
|
||
def __init__(self, row, body):
|
||
self.content_type, self.source = row.content_type, row.source
|
||
self.title, self.body, self.collected_at, self.kind = row.title, body, row.collected_at, row.kind
|
||
self.latitude, self.longitude = row.latitude, row.longitude
|
||
|
||
|
||
def _is_uuid(value: str) -> bool:
|
||
try:
|
||
uuid.UUID(value)
|
||
except (ValueError, AttributeError, TypeError):
|
||
return False
|
||
return True
|
||
|
||
|
||
async def _site_places(place_id) -> dict:
|
||
"""이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden})."""
|
||
q = (
|
||
select(site_sections.data)
|
||
.join(sites, sites.site_id == site_sections.site_id)
|
||
.where(
|
||
sites.place_id == place_id,
|
||
sites.deleted == False, # noqa: E712
|
||
site_sections.section_id == "local",
|
||
site_sections.deleted == False, # noqa: E712
|
||
)
|
||
.limit(1)
|
||
)
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q, raise_error=False)
|
||
)
|
||
if err != ErrorType.SUCCESS or not rows:
|
||
return {}
|
||
data = rows[0]
|
||
return (data or {}).get("places") or {} if isinstance(data, dict) else {}
|
||
|
||
|
||
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
|
||
"""행 → 스냅샷 항목."""
|
||
out = []
|
||
for row in rows:
|
||
content_type = int(row.content_type)
|
||
kind = getattr(row, "kind", None)
|
||
taken = seen.get(content_type, 0)
|
||
# 맛집 수집은 전체 보존인데 여기서 20개로 자르면 발행본만 일부가 사라진다.
|
||
if content_type != LocalContentType.RESTAURANT.value and taken >= _LOCAL_MAX_PER_TYPE:
|
||
continue
|
||
seen[content_type] = taken + 1
|
||
body = row.body if isinstance(row.body, dict) else {}
|
||
entry = {
|
||
"content_type": content_type,
|
||
"source": source if source is not None else row.source,
|
||
"title": row.title,
|
||
"body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP},
|
||
"collected_at": _iso(row.collected_at),
|
||
}
|
||
if kind:
|
||
entry["kind"] = kind
|
||
# 좌표는 **컬럼**에서 온다.
|
||
for key, value in (("latitude", getattr(row, "latitude", None)),
|
||
("longitude", getattr(row, "longitude", None))):
|
||
if value is not None:
|
||
entry[key] = str(value)
|
||
out.append(entry)
|
||
return out
|
||
|
||
|
||
def _iso(value) -> str | None:
|
||
"""datetime → ISO8601 문자열."""
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
return value.isoformat()
|
||
|
||
|
||
async def _select(query) -> list:
|
||
"""조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게."""
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
place_facts.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False),
|
||
)
|
||
return list(rows) if err == ErrorType.SUCCESS else []
|