o2o-site-AEO/solution/backend/services/snapshot.py
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. 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>
2026-09-14 15:46:44 +09:00

422 lines
22 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_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 []