최상단을 프로젝트 단위로 평평하게 둔다 — 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
774 lines
40 KiB
Python
774 lines
40 KiB
Python
"""발행 payload — 정적 렌더러(solution/site)가 먹는 유일한 입력 JSON.
|
|
|
|
★ 왜 필요한가
|
|
빌드 잡은 지금까지 HTML 을 굽고 그 **길이만** 재고 버렸다(발행 기록은 남는데 페이지가 없었다).
|
|
실제로 방문자에게 보여줄 페이지는 solution/site 의 SSG 가 굽는다. 그 렌더러의 유일한 입력이
|
|
이 payload 이므로, 빌드 잡이 이 JSON 만 파일로 떨어뜨리면 발행이 실제 페이지로 이어진다.
|
|
|
|
★ 스키마는 solution/shared/src/types/site-payload.ts 의 `SitePayload` 다.
|
|
필드명이 camelCase 인 이유는 그쪽이 원본이기 때문이다 — 여기서 스네이크로 바꾸면 렌더러가 못 읽는다.
|
|
스키마가 바뀌면 schemaVersion 을 올린다(렌더러는 모르는 버전을 조용히 반쪽 렌더하지 않고 실패한다).
|
|
|
|
★ DB 를 여기서 다시 읽지 않는다.
|
|
입력은 이미 박제된 스냅샷(site_versions.snapshot)과 그 빌드가 만든 행들뿐이다.
|
|
스냅샷이 정적 빌드의 경계다 — 여기서 DB 를 한 번 더 읽으면 '스냅샷과 다른 페이지'가 나올 수 있다.
|
|
(channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
|
|
"""
|
|
import json
|
|
import os
|
|
import unicodedata
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from common.category_schema import get_schema
|
|
from common.enums import (
|
|
FactStatus,
|
|
LinkChannel,
|
|
LocalContentType,
|
|
PlaceCategory,
|
|
SiteStatus,
|
|
SourceType,
|
|
)
|
|
from common.logger import LOG
|
|
|
|
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
|
|
SCHEMA_VERSION = 1
|
|
|
|
# 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다.
|
|
PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR"
|
|
DEFAULT_PAYLOAD_DIR = "/app/out/payloads"
|
|
|
|
# 커스텀 도메인이 없을 때 쓰는 기본 호스트. sites.domain 이 채워지면 그 값이 이긴다.
|
|
#
|
|
# ★ env 로 뺀 이유: 이 값이 canonical·og:url·사이트맵·IndexNow 통보에 **전부** 들어간다.
|
|
# 상수로 박아 두면 스테이징에 올릴 때마다 코드를 고쳐야 하고, 안 고치면 발행은 성공하는데
|
|
# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다).
|
|
# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**.
|
|
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
|
|
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "w4ai.o2o.kr"
|
|
|
|
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
|
|
_CHANNEL_TITLE = {
|
|
LinkChannel.YANOLJA.value: "야놀자",
|
|
LinkChannel.GOODCHOICE.value: "여기어때",
|
|
LinkChannel.NAVER_PLACE.value: "네이버 플레이스",
|
|
LinkChannel.INSTAGRAM.value: "인스타그램",
|
|
LinkChannel.OFFICIAL_SITE.value: "공식 홈페이지",
|
|
LinkChannel.BLOG.value: "블로그",
|
|
LinkChannel.ETC.value: "기타 채널",
|
|
}
|
|
|
|
# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다.
|
|
# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다:
|
|
# templateId ← sites.template_id (POST /v1/place/{id}/site/template)
|
|
# 색·서체·섹션 on/off·순서·배리에이션 ← sites.theme (POST /v1/place/{id}/site/theme)
|
|
# 저장된 값이 있으면 **그게 이긴다**. 이 표는 저장값이 없을 때만 쓰인다 —
|
|
# 고르지 않은 값을 고른 것처럼 굽지 않는다.
|
|
# ★ 이 표의 섹션 목록은 에디터의 업종별 기본 목록
|
|
# (admin `src/data/industryData.ts` 의 `sections`)과 **id·순서·이름·잠금이 1:1로 같아야 한다.**
|
|
# 여기가 에디터보다 적으면, 사장님이 에디터에서 본 섹션이 발행본에서 통째로 사라진다 —
|
|
# 저장값이 없는 사업장은 이 표가 곧 발행본이기 때문이다(실측: 날씨·실시간 예약·대관 문의).
|
|
# 에디터에 섹션을 늘릴 때는 이 표도 같이 늘린다. 어긋나면 tests/test_site_theme.py 가 잡는다.
|
|
# ★ 이 표가 여전히 필요한 이유: 위저드를 끝까지 돌지 않은 사업장, 그리고 sections 의
|
|
# locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다.
|
|
_DEFAULT_THEME = {
|
|
PlaceCategory.LODGING.value: {
|
|
"templateId": "stay-o2o-editorial",
|
|
"fontStyle": "Modern Editorial",
|
|
"colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff",
|
|
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
|
|
"sections": [
|
|
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
|
|
("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "실시간 예약", False),
|
|
("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
|
("local", "지역 정보", False), ("faq", "자주 묻는 질문", False),
|
|
],
|
|
},
|
|
PlaceCategory.CAFE.value: {
|
|
"templateId": "cafe-modern-espresso",
|
|
"fontStyle": "Sleek Roast",
|
|
"colors": {"primary": "#1c1917", "secondary": "#78716c", "bg": "#ffffff",
|
|
"card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"},
|
|
"sections": [
|
|
("hero", "히어로", True), ("intro", "소개", False), ("menu", "시그니처 메뉴", False),
|
|
("info", "기본 정보", True), ("space", "공간 · 좌석 안내", False), ("photos", "사진 갤러리", False),
|
|
("inquiry", "대관 및 단체 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
|
("local", "주변 나들이", False), ("faq", "자주 묻는 질문", False),
|
|
],
|
|
},
|
|
PlaceCategory.RESTAURANT.value: {
|
|
"templateId": "rest-neat-table",
|
|
"fontStyle": "Sophisticated Table",
|
|
"colors": {"primary": "#1c1917", "secondary": "#57534e", "bg": "#ffffff",
|
|
"card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"},
|
|
"sections": [
|
|
("hero", "히어로", True), ("intro", "소개", False), ("menu", "코스 및 메뉴", False),
|
|
("info", "기본 정보", True), ("booking", "예약 · 포장 안내", False), ("photos", "사진 갤러리", False),
|
|
("inquiry", "단체 행사 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
|
("local", "주변 안내", False), ("faq", "자주 묻는 질문", False),
|
|
],
|
|
},
|
|
PlaceCategory.TOUR_ACTIVITY.value: {
|
|
"templateId": "tour-visual-tour",
|
|
"fontStyle": "Visual Journey",
|
|
"colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff",
|
|
"card": "#f8fafc", "text": "#020617", "accent": "#0284c7"},
|
|
"sections": [
|
|
("hero", "히어로", True), ("intro", "소개", False), ("programs", "체험 프로그램", False),
|
|
("info", "기본 정보", True), ("exhibition", "관람 및 갤러리 안내", False), ("photos", "사진 갤러리", False),
|
|
("inquiry", "단체 및 출강 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
|
("local", "주변 관광 코스", False), ("faq", "자주 묻는 질문", False),
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
# ── 값 변환 헬퍼 ──────────────────────────────────────────────────────────
|
|
def _get(row, key, default=None):
|
|
"""ORM 행이든 dict 든 같은 방식으로 읽는다(테스트가 가짜 행을 넣기 쉬우라고)."""
|
|
if isinstance(row, dict):
|
|
return row.get(key, default)
|
|
return getattr(row, key, default)
|
|
|
|
|
|
def _text(value) -> str:
|
|
return str(value).strip() if value is not None else ""
|
|
|
|
|
|
def _iso(value) -> str | None:
|
|
"""ISO8601 문자열. ★ 화면·JSON-LD 의 dateModified 로 나가는 값이라 시간대를 명시한다.
|
|
|
|
DB 의 timestamptz 는 여기서 naive UTC 로 올라오므로(GTime 규약) UTC 를 붙인다.
|
|
naive 로 내보내면 렌더러·검색엔진이 로컬시각으로 오해한다."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, datetime):
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
"""solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다).
|
|
|
|
★ 한글을 음차하면 같은 가게가 두 주소를 갖는다. 한글은 그대로 두고 퍼센트 인코딩에 맡긴다.
|
|
NFC 로 정규화하는 이유: macOS 가 준 NFD 문자열이 그대로 디렉토리명이 되면
|
|
같은 이름이 서로 다른 경로로 갈린다."""
|
|
normalized = unicodedata.normalize("NFC", str(text or "")).strip().lower()
|
|
out = []
|
|
for ch in normalized:
|
|
if ch.isalnum() or ch == "-":
|
|
out.append(ch)
|
|
elif ch.isspace() or ch == "_":
|
|
out.append("-")
|
|
# 나머지(쉼표·괄호·따옴표…)는 버린다.
|
|
slug = "".join(out)
|
|
while "--" in slug:
|
|
slug = slug.replace("--", "-")
|
|
return slug.strip("-")
|
|
|
|
|
|
# ── 주소 구성요소 ──────────────────────────────────────────────
|
|
# 시·도 이름 → ISO 3166-2:KR 코드. `geo.region` 메타에 쓰인다(네이버·다음이 읽는 자리).
|
|
# 약칭과 정식명을 둘 다 적는다 — 외부 장소 API 가 둘을 섞어 준다("경기" / "경기도").
|
|
_SIDO_ISO = {
|
|
"서울": "KR-11", "서울특별시": "KR-11",
|
|
"부산": "KR-26", "부산광역시": "KR-26",
|
|
"대구": "KR-27", "대구광역시": "KR-27",
|
|
"인천": "KR-28", "인천광역시": "KR-28",
|
|
"광주": "KR-29", "광주광역시": "KR-29",
|
|
"대전": "KR-30", "대전광역시": "KR-30",
|
|
"울산": "KR-31", "울산광역시": "KR-31",
|
|
"세종": "KR-50", "세종시": "KR-50", "세종특별자치시": "KR-50",
|
|
"경기": "KR-41", "경기도": "KR-41",
|
|
"강원": "KR-42", "강원도": "KR-42", "강원특별자치도": "KR-42",
|
|
"충북": "KR-43", "충청북도": "KR-43",
|
|
"충남": "KR-44", "충청남도": "KR-44",
|
|
"전북": "KR-45", "전라북도": "KR-45", "전북특별자치도": "KR-45",
|
|
"전남": "KR-46", "전라남도": "KR-46",
|
|
"경북": "KR-47", "경상북도": "KR-47",
|
|
"경남": "KR-48", "경상남도": "KR-48",
|
|
"제주": "KR-49", "제주도": "KR-49", "제주특별자치도": "KR-49",
|
|
}
|
|
|
|
|
|
def _parse_address_parts(*addresses: str | None) -> dict:
|
|
"""주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다.
|
|
|
|
★ **원문에 있는 조각을 그대로만 쓴다.** "경기" 를 "경기도" 로 펴지 않는다.
|
|
addressRegion·addressLocality 는 JSON-LD 로 나가고, JSON-LD 는 화면 텍스트와
|
|
대조된다(solution/site/src/seo/verify.ts). 화면에는 원문 주소가 그대로 찍히므로
|
|
정식명으로 펴는 순간 '화면에 없는 값'이 되어 발행 게이트가 막는다.
|
|
정규화가 필요한 곳은 ISO 코드 하나뿐이고, 그건 화면 대조 대상이 아니다.
|
|
|
|
★ 첫 토큰이 아는 시·도가 아니면 **아무것도 돌려주지 않는다** — 추측해서 채우지 않는다.
|
|
|
|
도로명 주소를 먼저 보고, 못 읽으면 지번 주소로 넘어간다.
|
|
"""
|
|
for raw in addresses:
|
|
tokens = str(raw or "").split()
|
|
if len(tokens) < 2 or tokens[0] not in _SIDO_ISO:
|
|
continue
|
|
|
|
region = tokens[0]
|
|
rest = tokens[1:]
|
|
|
|
# 시·군 → (있으면) 그 아래 구. 광역시·특별시는 구가 바로 온다.
|
|
locality: list[str] = []
|
|
i = 0
|
|
while i < len(rest):
|
|
token = rest[i]
|
|
if not locality and token.endswith(("시", "군")):
|
|
locality.append(token)
|
|
elif token.endswith("구") and (not locality or locality[-1].endswith("시")):
|
|
locality.append(token)
|
|
else:
|
|
break
|
|
i += 1
|
|
|
|
# 읍·면 — "애월 스테이" 처럼 실제 검색어가 되는 자리라 따로 들고 있는다.
|
|
sub = rest[i] if i < len(rest) and rest[i].endswith(("읍", "면")) else None
|
|
|
|
return {
|
|
"addressRegion": region,
|
|
"addressLocality": " ".join(locality) or None,
|
|
"addressSubLocality": sub,
|
|
"addressRegionCode": _SIDO_ISO[region],
|
|
}
|
|
|
|
return {}
|
|
|
|
|
|
def _fact_entry(spec, row: dict) -> dict:
|
|
"""스냅샷 fact 1건 → FactEntry.
|
|
|
|
label/type/unit/critical/required 는 업종 스키마가 유일한 소스다 —
|
|
스냅샷에 라벨이 박제돼 있어도 스키마가 있으면 스키마를 따른다(라벨 오탈자 수정이 재빌드로 반영된다)."""
|
|
key = row.get("key")
|
|
return {
|
|
"key": key,
|
|
"label": (spec.label if spec else row.get("label") or key),
|
|
"value": row.get("value"),
|
|
"unit": row.get("unit") or (spec.unit if spec else None),
|
|
"type": (spec.type if spec else "text"),
|
|
"scope": row.get("scope") or (spec.scope if spec else "place"),
|
|
# ★ status 를 그대로 싣는다. 렌더러가 selectPublishable() 로 한 번 더 거른다(2중 방어).
|
|
"status": row.get("status") or FactStatus.VERIFIED.value,
|
|
# facts.source_type 은 NOT NULL 이라 여기 기본값은 옛 스냅샷용 안전망이다.
|
|
"sourceType": row.get("source_type") or SourceType.OWNER.value,
|
|
"sourceUrl": row.get("source_url"),
|
|
"critical": bool(spec.critical) if spec else False,
|
|
"required": bool(spec.required) if spec else False,
|
|
"unitId": row.get("unit_id"),
|
|
"collectedAt": row.get("collected_at"),
|
|
"verifiedAt": row.get("verified_at"),
|
|
}
|
|
|
|
|
|
def _theme(site, theme_spec: dict) -> dict:
|
|
"""SiteTheme — 저장된 디자인이 이기고, 없는 것만 업종 기본으로 떨어진다.
|
|
|
|
★ 저장값 우선이 이 함수의 존재 이유다. 예전에는 이 자리가 업종 기본 표를 그대로 굽고
|
|
enabled 를 True 로 박아 넣었다 — 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나
|
|
업종 기본 모양이었다. 저장할 자리(sites.theme)가 생겼으니 여기서 읽는다.
|
|
|
|
★ 서버는 값을 해석하지 않는다. 섹션 id 도 배리에이션 키도 색 토큰도 프론트가 소유하므로
|
|
모르는 값이 와도 그대로 싣는다 — 렌더러가 모르는 키를 만나면 자기 기본으로 떨어진다.
|
|
|
|
★ 딱 하나 서버가 우기는 것이 locked 다. 아래 _sections 주석 참조."""
|
|
saved = _get(site, "theme")
|
|
if not isinstance(saved, dict):
|
|
saved = {}
|
|
|
|
# 색: 저장값이 이기되 **업종 기본 위에 덮는다**.
|
|
# ★ 렌더러 타입(SiteTheme.colors)은 6개 키를 모두 요구한다. 저장값이 일부만 담고 있을 때
|
|
# 그것만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다 — 빠진 자리는 업종 기본이 메운다.
|
|
colors = dict(theme_spec["colors"])
|
|
for key, value in (saved.get("colors") or {}).items():
|
|
if isinstance(value, str) and value.strip():
|
|
colors[key] = value.strip()
|
|
|
|
font_style = _text(saved.get("fontStyle")) or theme_spec["fontStyle"]
|
|
|
|
# ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
|
|
# 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
|
|
return {
|
|
# 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
|
|
# 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
|
|
"templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
|
|
"colors": colors,
|
|
"fontStyle": font_style,
|
|
"sections": _sections(saved.get("sections"), theme_spec["sections"]),
|
|
}
|
|
|
|
|
|
def _sections(saved_sections, default_spec) -> list:
|
|
"""섹션 목록 — 저장된 **배열 순서**가 곧 발행본의 섹션 순서다.
|
|
|
|
★ locked 는 서버가 우긴다. 잠긴 섹션(히어로·기본 정보·오시는 길)은 SEO·필수 마크업 때문에
|
|
잠긴 것이라, 저장값이 껐다고 해도 켜서 내보낸다. 그리고 잠금 판정은 **업종 기본 표**가 하고
|
|
저장값의 locked 는 잠그는 방향으로만 더한다 — 저장값의 locked:false 를 그대로 믿으면
|
|
클라이언트가 locked 를 내려 보내는 것만으로 필수 섹션을 끌 수 있어 잠금 자체가 무의미해진다.
|
|
|
|
★ 저장값에 없는 기본 섹션은 **켜서** 목록 끝에 덧붙인다.
|
|
|
|
한때 잠기지 않은 섹션은 꺼서 붙였다 — "사장님이 목록에서 뺐다 = 안 쓰겠다는 뜻"이라고 봤다.
|
|
그 전제가 틀렸다. 에디터에는 섹션을 **빼는 기능이 없다**(toggleSection·reorderSection 뿐,
|
|
admin/src/stores/builder.ts). 그러니 저장값에 없다는 건 "뺐다"가 아니라
|
|
**저장할 당시 그 섹션이 아직 없었다**는 뜻이다 — 우리가 나중에 추가한 섹션이다.
|
|
|
|
꺼서 붙이면 새 섹션은 기존 사업장에 영원히 나오지 않는다. 에디터에는 보이는데
|
|
발행본에는 없는 상태가 되고(실측: 날씨 섹션), 사장님은 켠 적도 끈 적도 없는 것이
|
|
안 나온다고 본다. 저장값이 아예 없을 때 전부 켜서 내보내는 것과 같은 규칙으로 맞춘다.
|
|
|
|
★ 통째로 버리지는 않는다 — 렌더러가 섹션 이름을 알아야 에디터에서 껐을 때 같은 이름으로
|
|
붙고, 무엇이 꺼져 있는지도 payload 만 보고 알 수 있다.
|
|
|
|
★ 저장값에만 있고 업종 기본에 없는 섹션(프론트가 새로 추가한 것)은 그대로 싣는다.
|
|
섹션 목록은 프론트가 소유한다 — 서버가 모른다고 버리면 새 섹션이 발행되지 않는다."""
|
|
defaults = {sid: (label, locked) for sid, label, locked in default_spec}
|
|
|
|
# 저장값이 없으면(아직 아무것도 고르지 않았다) 업종 기본을 전부 켜서 내보낸다.
|
|
if not isinstance(saved_sections, list) or not saved_sections:
|
|
return [
|
|
{"id": sid, "name": label, "enabled": True, "locked": locked}
|
|
for sid, label, locked in default_spec
|
|
]
|
|
|
|
out = []
|
|
used = set()
|
|
for item in saved_sections:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
sid = _text(item.get("id"))
|
|
if not sid or sid in used:
|
|
continue
|
|
used.add(sid)
|
|
default_label, default_locked = defaults.get(sid, ("", False))
|
|
# 서버가 아는 잠금(업종 기본)이 항상 이긴다. 저장값은 잠그는 방향으로만 보탠다.
|
|
locked = bool(default_locked) or bool(item.get("locked"))
|
|
entry = {
|
|
"id": sid,
|
|
# 사장님이 붙인 제목이 있으면 그게 발행본의 소제목이다. 없으면 업종 기본 이름, 그것도 없으면 id.
|
|
"name": _text(item.get("name")) or default_label or sid,
|
|
# ★ 잠긴 섹션은 꺼진 채로 나갈 수 없다.
|
|
"enabled": bool(item.get("enabled", True)) or locked,
|
|
"locked": locked,
|
|
}
|
|
# ★ 고른 배리에이션이 있을 때만 키를 붙인다. 서버는 이 값을 해석하지 않는다 —
|
|
# 비어 있으면 렌더러가 그 섹션의 기본 레이아웃으로 떨어진다(null 을 실으면 타입이 안 맞는다).
|
|
variant_id = _text(item.get("variantId"))
|
|
if variant_id:
|
|
entry["variantId"] = variant_id
|
|
out.append(entry)
|
|
|
|
for sid, label, locked in default_spec:
|
|
if sid in used:
|
|
continue
|
|
# ★ 켜서 붙인다. 저장값에 없는 건 사장님이 뺀 게 아니라 저장 당시 없던 섹션이다(위 주석).
|
|
out.append({"id": sid, "name": label, "enabled": True, "locked": bool(locked)})
|
|
return out
|
|
|
|
|
|
# WMO weather code → 한 줄 날씨. ★ solution/site/src/lib/use-live-weather.ts 의 condition() 과
|
|
# **같은 구간**이어야 한다. 렌더러는 프리렌더된 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데,
|
|
# 두 곳이 다른 표를 쓰면 같은 날씨인데 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다).
|
|
def _weather_condition(code) -> str:
|
|
try:
|
|
value = int(code)
|
|
except (TypeError, ValueError):
|
|
return "흐림"
|
|
if value == 0:
|
|
return "맑음"
|
|
if 1 <= value <= 3:
|
|
return "구름많음"
|
|
if 50 <= value <= 69:
|
|
return "비"
|
|
if 70 <= value <= 79:
|
|
return "눈"
|
|
return "흐림"
|
|
|
|
|
|
def _weather(row: dict):
|
|
"""WeatherSnapshot. 값이 없으면 None — 없는 날씨를 지어내지 않는다."""
|
|
body = row.get("body") or {}
|
|
temperature = _as_float(body.get("temperature"))
|
|
observed_at = _text(body.get("observed_at"))
|
|
if temperature is None or not observed_at:
|
|
return None
|
|
return {
|
|
"temperature": temperature,
|
|
"condition": _weather_condition(body.get("weather_code")),
|
|
# note 는 LLM 이 쓰는 안내 문구다. 지금 그걸 만드는 경로가 없으므로 넣지 않는다(지어내지 않는다).
|
|
"observedAt": observed_at,
|
|
}
|
|
|
|
|
|
def _yyyymmdd(value) -> str:
|
|
digits = "".join(ch for ch in _text(value) if ch.isdigit())
|
|
return digits if len(digits) == 8 else ""
|
|
|
|
|
|
def _festival(row: dict):
|
|
"""FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다."""
|
|
body = row.get("body") or {}
|
|
name = _text(row.get("title")) or _text(body.get("title"))
|
|
if not name:
|
|
return None
|
|
|
|
start, end = _yyyymmdd(body.get("eventstartdate")), _yyyymmdd(body.get("eventenddate"))
|
|
# month 는 화면의 배지다(예: "10월"). 시작일이 없으면 만들지 않는다.
|
|
month = f"{int(start[4:6])}월" if start else ""
|
|
if start and end and start != end:
|
|
period = f"{start[:4]}.{start[4:6]}.{start[6:]} ~ {end[:4]}.{end[4:6]}.{end[6:]}"
|
|
elif start:
|
|
period = f"{start[:4]}.{start[4:6]}.{start[6:]}"
|
|
else:
|
|
period = ""
|
|
|
|
homepage = _text(body.get("homepage"))
|
|
entry = {
|
|
"name": name,
|
|
"month": month,
|
|
# ★ searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고
|
|
# 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙).
|
|
"searchQuery": name,
|
|
}
|
|
if period:
|
|
entry["period"] = period
|
|
location = _text(body.get("addr1"))
|
|
if location:
|
|
entry["location"] = location
|
|
description = _text(body.get("overview"))
|
|
if description:
|
|
entry["description"] = description
|
|
# 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다.
|
|
if homepage.startswith("http://") or homepage.startswith("https://"):
|
|
entry["officialUrl"] = homepage
|
|
return entry
|
|
|
|
|
|
def _local_place(row: dict, category: str):
|
|
"""LocalPlace(주변 명소·맛집).
|
|
|
|
★ title 컬럼만 믿는다. 이 두 종류(ATTRACTION·RESTAURANT)를 채우는 수집기가 아직 없어서
|
|
body 의 모양이 정해지지 않았다 — 있지도 않은 키를 가정해 파싱하면 수집기가 붙는 날
|
|
조용히 빈 값이 나간다. 지금은 테이블 스키마가 보장하는 것(title)만 쓰고,
|
|
설명·거리는 실제 수집기가 붙을 때 그 모양을 보고 채운다."""
|
|
name = _text(row.get("title"))
|
|
if not name:
|
|
return None
|
|
return {"name": name, "category": category, "searchQuery": name}
|
|
|
|
|
|
def _local(snapshot_local: dict) -> tuple[dict, str | None]:
|
|
"""스냅샷의 지역 정보 → LocalContents.
|
|
|
|
★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 —
|
|
fact·사진과 같은 분업이다.
|
|
★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도
|
|
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다."""
|
|
contents = (snapshot_local or {}).get("contents") or []
|
|
local = {"attractions": [], "restaurants": [], "festivals": []}
|
|
synced_at = None
|
|
|
|
for row in contents:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
collected_at = _text(row.get("collected_at"))
|
|
# syncedAt 은 화면에 "○○ 갱신"으로 그대로 노출된다 — 가장 최근 수집 시각을 쓴다.
|
|
# ★ 오래된 정보를 숨기지 않는다(렌더러 타입 주석). 그래서 최신값이 아니라 '실제 최신 수집 시각'이다.
|
|
if collected_at and (synced_at is None or collected_at > synced_at):
|
|
synced_at = collected_at
|
|
|
|
content_type = row.get("content_type")
|
|
if content_type == LocalContentType.WEATHER.value:
|
|
weather = _weather(row)
|
|
# 지역당 1행이지만 방어적으로 첫 유효값만 쓴다.
|
|
if weather and "weather" not in local:
|
|
local["weather"] = weather
|
|
elif content_type == LocalContentType.FESTIVAL.value:
|
|
entry = _festival(row)
|
|
if entry:
|
|
local["festivals"].append(entry)
|
|
elif content_type == LocalContentType.ATTRACTION.value:
|
|
entry = _local_place(row, "관광지")
|
|
if entry:
|
|
local["attractions"].append(entry)
|
|
elif content_type == LocalContentType.RESTAURANT.value:
|
|
entry = _local_place(row, "맛집")
|
|
if entry:
|
|
local["restaurants"].append(entry)
|
|
# 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다.
|
|
|
|
return local, synced_at
|
|
|
|
|
|
def _publish_target(site, place_id: str, name: str) -> dict:
|
|
"""발행 주소(origin/basePath/slug).
|
|
|
|
★ sites.domain 이 있으면 그게 사장님이 고른 주소다. 없으면 **임시값**이다 —
|
|
상호명은 유일하지 않으므로 place_id 앞자리를 붙여 사이트끼리 겹치지 않게 한다.
|
|
|
|
★ **언제나 경로형**(`https://<host>/s/<slug>`)이다. 서브도메인을 쓰지 않는 이유는
|
|
사이트가 하나 늘 때마다 DNS 레코드와 TLS 인증서를 새로 만들어야 해서다 —
|
|
발행 시점에 그걸 대신 만들어 줄 방법이 없으니 서브도메인 주소는 화면에만 있고
|
|
실제로는 열리지 않는다. shared/lib/slug.ts 의 publishUrl 과 **같은 규칙**이어야
|
|
화면이 보여준 주소와 발행본의 주소가 갈리지 않는다."""
|
|
domain = _text(_get(site, "domain"))
|
|
|
|
if domain:
|
|
# domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값). 옛 데이터가 호스트 형태로
|
|
# 남아 있을 수 있어 첫 라벨만 취한다.
|
|
slug = slugify(domain.split(".")[0]) or slugify(name) or place_id
|
|
else:
|
|
name_slug = slugify(name)
|
|
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}"
|
|
|
|
return {"origin": f"https://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
|
|
|
|
|
|
def publish_slug(place, site) -> str:
|
|
"""이 사업장 사이트의 발행 슬러그.
|
|
|
|
★ 렌더 보고서(payloads/.status/<slug>.json)를 찾으려면 payload 를 만들 때와 **같은 규칙**으로
|
|
슬러그를 구해야 한다. 그래서 여기 한 곳에서만 계산하고 밖에서는 이 함수를 부른다 —
|
|
규칙을 두 군데 두면 보고서를 못 찾아 "아직 안 구워졌다"고 잘못 답하게 된다."""
|
|
return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"]
|
|
|
|
|
|
# ── payload 조립 ──────────────────────────────────────────────────────────
|
|
def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
|
|
"""스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict).
|
|
|
|
순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고,
|
|
같은 입력이면 언제나 같은 payload 가 나온다."""
|
|
snapshot = snapshot or {}
|
|
snap_place = snapshot.get("place") or {}
|
|
place_id = str(_get(place, "place_id"))
|
|
category = int(snap_place.get("category") or _get(place, "category"))
|
|
schema = get_schema(category)
|
|
name = _text(snap_place.get("name") or _get(place, "name"))
|
|
|
|
target = _publish_target(site, place_id, name)
|
|
|
|
# ── fact ─────────────────────────────────────────────
|
|
all_facts = [_fact_entry(schema.get(r.get("key")), r) for r in (snapshot.get("facts") or [])]
|
|
place_facts = [f for f in all_facts if not f["unitId"]]
|
|
unit_facts: dict[str, list] = {}
|
|
for entry in all_facts:
|
|
if entry["unitId"]:
|
|
unit_facts.setdefault(str(entry["unitId"]), []).append(entry)
|
|
|
|
# ── 사진 ─────────────────────────────────────────────
|
|
# ★ 스냅샷이 이미 걸렀다(APPROVED + alt 있음). 여기서 더 거르지 않고 모양만 바꾼다.
|
|
# ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2)
|
|
# 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다.
|
|
media = []
|
|
primary_done = False
|
|
for index, row in enumerate(snapshot.get("media") or []):
|
|
unit_id = row.get("unit_id")
|
|
# 대표 이미지(og:image)는 객실 전용 사진이 아닌 첫 장으로 한다.
|
|
is_primary = not unit_id and not primary_done
|
|
primary_done = primary_done or is_primary
|
|
media.append({
|
|
"mediaId": str(row.get("media_id") or f"m-{index}"),
|
|
"url": row.get("url"),
|
|
"alt": _text(row.get("alt_text")),
|
|
"category": row.get("label"),
|
|
"width": row.get("width"),
|
|
"height": row.get("height"),
|
|
"isPrimary": is_primary,
|
|
# 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL). 그때는 CRAWL 로 본다 —
|
|
# 재게시 권리가 결론 나면 걸러져야 할 쪽으로 기울이는 게 안전하다.
|
|
"sourceType": row.get("source_type") or SourceType.CRAWL.value,
|
|
"originUrl": row.get("origin_url"),
|
|
"unitId": unit_id,
|
|
})
|
|
media_by_unit: dict[str, list] = {}
|
|
for item in media:
|
|
if item["unitId"]:
|
|
media_by_unit.setdefault(str(item["unitId"]), []).append(item["mediaId"])
|
|
|
|
# ── 하위 단위(객실·메뉴·프로그램) ─────────────────────
|
|
units = []
|
|
for index, row in enumerate(snapshot.get("units") or []):
|
|
unit_id = str(row.get("unit_id"))
|
|
unit_name = _text(row.get("name"))
|
|
units.append({
|
|
"unitId": unit_id,
|
|
"name": unit_name,
|
|
# slug 가 URL 이 된다. 이름이 비거나 기호뿐이면 순번으로 떨어뜨린다(빈 경로를 만들지 않는다).
|
|
"slug": slugify(unit_name) or f"unit-{index + 1}",
|
|
"sortOrder": int(row.get("sort_order") or index),
|
|
"facts": unit_facts.get(unit_id, []),
|
|
"mediaIds": media_by_unit.get(unit_id, []),
|
|
})
|
|
|
|
# ── FAQ ──────────────────────────────────────────────
|
|
faqs = []
|
|
for index, row in enumerate(snapshot.get("faqs") or []):
|
|
faqs.append({
|
|
"faqId": str(row.get("faq_id") or f"faq-{index + 1}"),
|
|
"question": _text(row.get("question")),
|
|
"answer": _text(row.get("answer")),
|
|
"status": row.get("status") or FactStatus.VERIFIED.value,
|
|
"sourceType": row.get("generated_by") or SourceType.LLM.value,
|
|
"sortOrder": int(row.get("sort_order") or index),
|
|
})
|
|
|
|
# ── 채널 링크 ────────────────────────────────────────
|
|
# 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다.
|
|
# 여기서 미리 빼면 "왜 안 나오는지"가 payload 만 봐서는 안 보인다.
|
|
channel_links = []
|
|
for row in links or []:
|
|
channel = _get(row, "channel")
|
|
url = _text(_get(row, "url"))
|
|
if not url or channel is None:
|
|
continue
|
|
channel_links.append({
|
|
"channel": int(channel),
|
|
"url": url,
|
|
"title": _text(_get(row, "title")) or _CHANNEL_TITLE.get(int(channel), "채널"),
|
|
"confirmed": _get(row, "confirmed_at") is not None,
|
|
})
|
|
|
|
# ── 소개문 ───────────────────────────────────────────
|
|
# ★ 여기서 문장을 지어내지 않는다. intro fact(allow_llm=true 필드)에 있는 것만 옮긴다.
|
|
# heroHeadline·tagline 은 저장되는 자리가 없어서 비운다 — 비면 렌더러가 상호명으로 대체한다.
|
|
intro = _text(next((f["value"] for f in place_facts if f["key"] == "intro" and f["value"]), ""))
|
|
paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else []
|
|
narrative = {
|
|
"about": paragraphs,
|
|
# 요약은 첫 문단을 그대로 쓴다(요약문을 새로 생성하지 않는다).
|
|
"summary": paragraphs[0] if paragraphs else None,
|
|
}
|
|
|
|
theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value]
|
|
theme = _theme(site, theme_spec)
|
|
|
|
# ── 지역 정보 ────────────────────────────────────────
|
|
# ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙).
|
|
# 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다.
|
|
# 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다.
|
|
local, local_synced_at = _local(snapshot.get("local") or {})
|
|
if local_synced_at:
|
|
local["syncedAt"] = local_synced_at
|
|
|
|
published_at = _iso(_get(site, "published_at"))
|
|
updated_at = (
|
|
_iso(_get(version, "built_at"))
|
|
or _iso(_get(place, "content_updated_at"))
|
|
or published_at
|
|
or _iso(datetime.now(timezone.utc))
|
|
)
|
|
|
|
external_source = _get(place, "external_source")
|
|
kakao_place_id = _text(_get(place, "external_place_id")) if external_source == 1 else ""
|
|
|
|
return {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"site": {
|
|
"siteId": str(_get(site, "site_id") or ""),
|
|
"placeId": place_id,
|
|
"status": int(_get(site, "status") or SiteStatus.DRAFT.value),
|
|
"origin": target["origin"],
|
|
"basePath": target["basePath"],
|
|
"slug": target["slug"],
|
|
"publishedAt": published_at or updated_at,
|
|
"updatedAt": updated_at,
|
|
"version": int(_get(version, "version") or 1),
|
|
},
|
|
"place": {
|
|
"name": name,
|
|
"category": category,
|
|
"roadAddress": snap_place.get("road_address") or None,
|
|
"address": snap_place.get("address") or None,
|
|
# 시·도 / 시·군·구 / 읍·면. PostalAddress 의 지역 필드와 title·geo 메타가 이걸 쓴다 —
|
|
# 없으면 지역 질의("성남 소금빵")에 걸릴 자리를 통째로 버리게 된다.
|
|
**_parse_address_parts(snap_place.get("road_address"), snap_place.get("address")),
|
|
"phone": snap_place.get("phone") or None,
|
|
# 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화). 렌더러 타입은 number 라 여기서 되돌린다.
|
|
"latitude": _as_float(snap_place.get("latitude")),
|
|
"longitude": _as_float(snap_place.get("longitude")),
|
|
# ★ 스냅샷이 실제로 쓴 지역 키가 이긴다. places.region_code 가 비어 있어도
|
|
# 스냅샷이 주소에서 유도했으면(services/snapshot._local_contents) 그 값이 여기 실려야
|
|
# 렌더러의 실시간 날씨 조회가 산다 — use-live-weather 는 regionCode 없이는 fetch 하지 않고,
|
|
# 그러면 날씨 섹션이 통째로 사라진다(에디터에는 보이는데 사이트에는 없는 그 자리).
|
|
"regionCode": (
|
|
_text((snapshot.get("local") or {}).get("region_code"))
|
|
or _text(_get(place, "region_code"))
|
|
or None
|
|
),
|
|
"kakaoPlaceId": kakao_place_id or None,
|
|
},
|
|
"facts": place_facts,
|
|
"units": units,
|
|
"media": media,
|
|
"faqs": faqs,
|
|
"links": channel_links,
|
|
"local": local,
|
|
# ★ 가는 길(routes)은 비어 있다. local.routes 테이블에 행이 0이고, 그 테이블에 쓰는 코드 경로가
|
|
# 아직 어디에도 없다(모델과 DDL 만 있고 수집기·입력 API 가 없다). 여기서 조회 배선을 만들어 봐야
|
|
# 영원히 빈 결과를 도는 코드가 되고, 실제 수집기가 붙는 날 그 모양에 맞을지도 알 수 없다.
|
|
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
|
|
# 렌더러는 비면 해당 섹션을 그리지 않는다.
|
|
"routes": [],
|
|
"narrative": narrative,
|
|
"theme": theme,
|
|
}
|
|
|
|
|
|
def _as_float(value):
|
|
try:
|
|
return float(value) if value not in (None, "") else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# ── 파일 출력 ─────────────────────────────────────────────────────────────
|
|
def payload_dir() -> Path:
|
|
"""payload 출력 디렉토리. 컨테이너 밖 볼륨을 붙이기 쉬우라고 env 로 뺀다."""
|
|
return Path(os.environ.get(PAYLOAD_DIR_ENV) or DEFAULT_PAYLOAD_DIR)
|
|
|
|
|
|
def write_payload(payload: dict) -> str:
|
|
"""payload 를 `<slug>.json` 으로 쓴다. 경로를 돌려준다.
|
|
|
|
★ 임시파일에 쓰고 rename 한다 — 렌더러가 디렉토리를 통째로 읽는 구조라
|
|
반쯤 쓰인 JSON 을 집어 빌드가 깨지는 일이 없어야 한다(rename 은 같은 파일시스템에서 원자적)."""
|
|
directory = payload_dir()
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
site = payload.get("site") or {}
|
|
# slug 는 slugify 를 거쳐 경로 구분자가 남을 수 없지만, 파일명은 마지막 한 겹을 더 막는다.
|
|
stem = os.path.basename(_text(site.get("slug")) or _text(site.get("placeId")) or "site")
|
|
path = directory / f"{stem}.json"
|
|
tmp = directory / f".{stem}.json.tmp"
|
|
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
tmp.replace(path)
|
|
return str(path)
|
|
|
|
|
|
def emit_payload(place, snapshot: dict, site, version, links) -> str | None:
|
|
"""payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다.
|
|
|
|
★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고,
|
|
payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서
|
|
발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다."""
|
|
try:
|
|
payload = to_site_payload(place, snapshot, site, version, links)
|
|
path = write_payload(payload)
|
|
LOG.i(
|
|
f"[payload] {payload['site']['slug']} → {path} "
|
|
f"(fact {len(payload['facts'])} · 단위 {len(payload['units'])} · "
|
|
f"사진 {len(payload['media'])} · FAQ {len(payload['faqs'])} · 링크 {len(payload['links'])})"
|
|
)
|
|
return path
|
|
except Exception as ex: # noqa: BLE001 — 어떤 이유로도 발행을 되돌리지 않는다
|
|
LOG.w(f"[payload] 생성 실패(발행은 그대로 진행): {type(ex).__name__}: {ex}")
|
|
return None
|