o2o-site-AEO/solution/backend/services/snapshot.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

258 lines
13 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 facts, faqs, local_contents, media, units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
MediaStatus,
PlaceCategory,
)
from common.logger import LOG
from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라
# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다.
# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다.
_LOCAL_MAX_PER_TYPE = 20
# 지역 원문(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(facts).where(
facts.place_id == pid,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHABLE),
)
)
unit_rows = await _select(
select(units).where(units.place_id == pid, units.deleted == False) # noqa: E712
.order_by(units.sort_order.asc())
)
faq_rows = await _select(
select(faqs).where(
faqs.place_id == pid,
faqs.deleted == False, # noqa: E712
faqs.status.in_(_PUBLISHABLE),
).order_by(faqs.sort_order.asc())
)
# ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다.
media_rows = await _select(
select(media).where(
media.place_id == pid,
media.deleted == False, # noqa: E712
media.status == MediaStatus.APPROVED.value,
).order_by(media.sort_order.asc())
)
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,
}
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'])}"
)
return snapshot
async def _local_contents(place) -> dict:
"""사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]}
★ 노출 가능 = PUBLISHED + 노출 기간 안.
local_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 ""
if not region_code:
return {"region_code": None, "contents": []}
now = datetime.now(timezone.utc)
query = (
select(local_contents)
.where(
local_contents.region_code == region_code,
local_contents.deleted == False, # noqa: E712
local_contents.status == LocalContentStatus.PUBLISHED.value,
or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now),
or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now),
)
.order_by(local_contents.content_type.asc(), local_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
local_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}")
return {"region_code": region_code, "contents": []}
seen: dict[int, int] = {}
contents = []
for row in rows or []:
content_type = int(row.content_type)
# 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다.
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 {}
contents.append({
"content_type": content_type,
"source": 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),
})
return {"region_code": region_code, "contents": contents}
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(
facts.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query),
)
return list(rows) if err == ErrorType.SUCCESS else []