발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.
★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
행위를 한다. 아래 결정이 전부 여기서 나왔다.
승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.
★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.
플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.
- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔
밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
분 단위 정밀도가 필요 없다
검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
422 lines
22 KiB
Python
422 lines
22 KiB
Python
"""빌드 스냅샷 조립 — 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_songs, place_units,
|
||
site_sections, sites, place_social_posts,
|
||
)
|
||
from common.enums import (
|
||
PUBLISHABLE_FACT_STATUSES,
|
||
DBWRType,
|
||
ErrorType,
|
||
FactStatus,
|
||
LocalContentStatus,
|
||
LocalSource,
|
||
MediaStatus,
|
||
PlaceCategory,
|
||
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-07 결정).
|
||
# 스냅샷은 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)
|
||
)
|
||
|
||
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()
|
||
],
|
||
}
|
||
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'])}"
|
||
)
|
||
return snapshot
|
||
|
||
|
||
async def _local_contents(place) -> dict:
|
||
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
|
||
|
||
두 캐시를 합친다 —
|
||
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
|
||
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외
|
||
|
||
★ 노출 가능 = PUBLISHED + 노출 기간 안.
|
||
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
|
||
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
|
||
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
|
||
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
|
||
display_start_at/display_end_at 은 운영자가 정한 노출 창이다. 기간이 지난 축제를
|
||
"이번 주말 행사"로 걸어두는 것도 틀린 정보라 여기서 함께 막는다.
|
||
|
||
★ 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)
|
||
)
|
||
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),
|
||
)
|
||
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)
|
||
)
|
||
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]:
|
||
"""행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다.
|
||
seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다."""
|
||
out = []
|
||
for row in rows:
|
||
content_type = int(row.content_type)
|
||
kind = getattr(row, "kind", None)
|
||
taken = seen.get(content_type, 0)
|
||
if 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:
|
||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||
place_facts.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: DB_SESSION_MNG.execute(s, query),
|
||
)
|
||
return list(rows) if err == ErrorType.SUCCESS else []
|