o2o-site-AEO/solution/backend/services/snapshot.py
Mina Choi b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00

473 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다.
★ 정적 빌드의 경계다. DB 는 **빌드 시점에만** 읽고, 방문자는 DB 와 만나지 않는다.
여기서 만든 스냅샷이 site_versions.snapshot 에 박제되고, 그 뒤로는 그것만 렌더된다.
★ 필터링이 여기 한 곳에만 있다:
fact — VERIFIED / CORRECTED 만
사진 — APPROVED 만 (Vision 신뢰도 미달은 PENDING_REVIEW 로 남아 여기서 빠진다)
FAQ — VERIFIED / CORRECTED 만
지역 — PUBLISHED 만 + 노출 기간 안에 있는 것만 (운영자가 검수해 발행한 것만 나간다)
게이트(publish_gate)가 뒤에서 한 번 더 보지만, 애초에 미검증 값이 스냅샷에 들어오면 안 된다.
★ 지역 정보가 왜 여기서 읽히나(services/site_payload 가 아니라).
site_payload 는 "DB 를 다시 읽지 않는다 — 입력은 박제된 스냅샷뿐"이 원칙이다. 거기서 지역 캐시를
읽으면 발행 시점과 렌더 시점 사이에 지역 정보가 바뀌었을 때 '스냅샷과 다른 페이지'가 나온다.
그래서 지역 정보도 다른 재료와 똑같이 여기서 걸러 스냅샷에 박제하고, site_payload 는 모양만 바꾼다.
"""
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)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 관광지·축제·코스 노출 상한. 맛집은 수집된 전체를 발행한다(2026-09-14).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
# ★ 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004
# `uq_local_contents_kind`). 그래서 다섯 종류가 위 상한 안에서 나란히 선다.
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
# ★ TourAPI 원본을 통째로 담은 필드라 정규화된 값과 100% 중복이고, 축제 1건의 크기를 두 배로 만든다.
# site_payload 는 정규화된 키만 읽는다.
_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())
)
# ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다.
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,
# ★ 출처와 확인 시각도 박제한다. 발행 payload(FactEntry)가 이 값을 그대로 싣고,
# 화면은 "언제 무엇으로 확인된 값인지"를 보여준다 — 출처 없는 사실은 우리 규칙 위반이다.
"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 검색 신호도 잃는다.
# ★ source_type/origin_url 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라
# (docs/DECISIONS.md 1-2) 결론이 나면 출처로 걸러내야 한다. 여기서 버리면 재수집밖에 답이 없다.
"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()
],
# ★ 지역 정보. 캐시 키가 place_id 가 아니라 region_code 라 사업장의 지역 코드로 찾는다
# (같은 지역 사이트 50개여도 외부 조회는 1회 — 그게 이 테이블이 region_code 로 묶인 이유다).
# region_code 가 비어 있으면 조회할 키가 없으므로 빈 목록이다. 그 경우 지어내지 않는다 —
# 지역 코드는 수집 파이프라인이 채우는 값이고, 없으면 아직 지역을 특정하지 못한 사업장이다.
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
"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],
# ★ 이 숙소의 노래. 파일은 DB 가 아니라 `solution/site/songs/<file_name>` 에 있고,
# 프리렌더가 그걸 사이트 디렉토리로 복사한다(services/song_service 머리주석).
# ★ origin_url(Suno 주소)은 싣지 않는다 — 만료되는 주소라 발행본에 나가면 안 된다.
"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
],
# ★ 손님이 적은 표시 이름만 싣는다. IP 해시·상태는 운영 값이라 발행본에 나가면 안 된다.
"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:
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
두 캐시를 합친다 —
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김만 제외
(축제는 종료 여부와 무관하게 노출, 2026-09-17 결정)
★ 노출 가능 = PUBLISHED + 노출 기간 안.
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
display_start_at/display_end_at 은 운영자가 수기로 정한 노출 창이다(운영자가 걸어 둔 항목에만
쓰인다 — TourAPI 로 긁은 축제는 종료 여부와 무관하게 둘 다 NULL, 2026-09-17 결정:
services/external/tour_api.py, local_content_service.py 참고).
★ expires_at 은 보지 않는다. 모델 주석대로 그건 '갱신 대상'이라는 표시지 '못 쓰는 값'이 아니다
(외부 API 가 죽어도 직전 값을 유지하는 게 이 캐시의 규약이다). 게다가 날씨는 렌더러가
하이드레이션 뒤 최신값으로 덮어쓴다(solution/site/src/lib/use-live-weather.ts).
"""
# ★ getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다.
region_code = str(getattr(place, "region_code", None) or "").strip()
if not region_code:
# ★ 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다.
# places.region_code 를 채우는 곳은 신원 확정(place_service.verify) 한 곳뿐이라,
# 그 코드가 생기기 전에 만들어진 사업장은 영영 NULL 로 남는다(실측: 28곳 중 25곳).
# 그 사업장은 날씨·축제·주변 관광지가 통째로 비고, 발행본에서 날씨 섹션이 아예
# 사라진다 — 에디터에는 보이는데(폴백값을 그리므로) 사이트에는 없는 그 자리다.
# 여기서 유도하면 신원을 다시 확정하지 않아도 다음 발행부터 지역 정보가 붙는다.
# ★ 지어내지 않는 규칙은 그대로다. region_key 는 주소에서 뽑을 뿐이고,
# 주소가 없거나 형식이 다르면 None 이다(그때는 비는 게 맞다).
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 없이 지역에 한 벌).
# 관광지·맛집·축제는 같은 표에 있지만 업장마다 거리가 달라, 아래 사이트 쪽에서
# 개인화 값과 함께 읽는다. 여기서 같이 긁으면 거리 없는 항목이 먼저 들어와
# 종류별 상한을 채워 버린다(실측 2026-09-09: 주변 12건이 전부 거리 없이 나갔다).
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) ──
# ★ 2026-09-09 에 자리를 갈랐다. 공용 실체는 지역이 나눠 쓰고(거리를 담을 수 없다),
# 거리·숨김은 사이트마다 다르다. 그래서 관계 테이블이 아니라 **사이트 섹션**에서 읽는다.
# 정렬은 여기가 한다 — 사진 있는 것 먼저, 그다음 가까운 순(2026-09-07 결정).
# 저장 쪽에 정렬을 구워 두면 기준이 바뀔 때 전 사이트를 다시 써야 한다.
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) — 업장마다 기간당 한 행 ──────────
# ★ 여기서 **읽기만** 한다. 생성은 잡·빌드가 한다(services/itinerary_llm_service).
# 스냅샷 조립 안에서 LLM 을 부르면 발행 한 번이 1분 늘고, 캔버스 조회도 같은 길을 탄다.
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. `_local_rows` 가 두 캐시를 같은 모양으로 읽게 한다."""
__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]:
"""행 → 스냅샷 항목. 맛집 외 종류별 상한은 들어온 순서(정렬)대로 자른다.
seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다."""
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
# ★ 좌표는 **컬럼**에서 온다(2026-09-09). 예전에는 body.mapx/mapy 였는데, 같은 값이
# 컬럼에도 있어 한쪽만 갱신될 자리였다. 일정 조립(services/itinerary)이 이걸 읽는다.
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 문자열.
★ 스냅샷은 JSONB 컬럼에 그대로 들어간다 — datetime 을 그대로 넣으면 직렬화에서 터진다.
DB 의 timestamptz 는 naive UTC 로 올라오므로(GTime 규약) UTC 를 명시해 둔다."""
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:
"""조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게.
★ raise_error=False 가 핵심이다 (2026-09-14). 이 함수는 원래 실패를 [] 로 삼키도록
썼는데, DB 계층이 기본값 raise_error=True 로 **예외를 던져** 그 처리가 실행될 기회조차
없었다. 실측: place_songs 테이블이 없던 동안 미리보기가 통째로 HTTP 500 이었다 —
노래 한 칸이 빠진 화면 대신 아무것도 못 보는 화면이 나갔다.
"""
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 []