[feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약

- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-10 14:36:00 +09:00
parent 3910feddfb
commit 328d9e18ee
58 changed files with 5625 additions and 412 deletions

View File

@ -196,12 +196,19 @@
일부러 아직 이식하지 않았다. 일부러 아직 이식하지 않았다.
- 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다. - 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다.
## 6. 지역 이야기 생성 (2026-09-09) ## 6. 지역 이야기 생성 (2026-09-09, 일력 추가 2026-09-10)
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. 가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 같은 템플릿을 `/s/stay` 시안이 이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 같은 템플릿을
골라도 그 자리가 비었다. 그래서 서버가 채운다. 골라도 그 자리가 비었다. 그래서 서버가 채운다.
**2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더
(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿
설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다.
→ 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다
(`export-prompts.mjs` 가 배열을 한 벌 더 들고 있어서 이 어긋남이 생겼다).
### 6-1. 키는 업장이 아니라 지역이다 ### 6-1. 키는 업장이 아니라 지역이다
이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다. 이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다.

View File

@ -5,6 +5,82 @@
--- ---
## 2026-09-10 — 옛 항구 템플릿을 `/s/stay` 시안에 맞춘다 (렌더러 이식)
**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라
**`stay-mockup` 워크트리의 커밋되지 않은 작업본**이었다(19파일, +601/275). 거기서만 살아 있던
변경이 이 브랜치로 넘어오지 않아, 같은 payload 를 같은 템플릿으로 구워도 화면이 갈렸다.
**대조 방법** — 시안 HTML 에 박힌 `window.__SITE_PAYLOAD__` 를 떼어 **현재 렌더러로 다시 구워**
마크업을 태그 단위로 diff 했다. 페이로드가 같으니 남는 차이는 전부 렌더러 차이다.
착수 시 실질 diff 129줄 → 이식 뒤 **4줄**.
**옮긴 것**
- `lib/ui/Carousel.tsx` + `use-rail-autoplay.ts`(신규): 자동 넘김을 훅 한 벌로. **한 번 훑고 멈춘다**
되감기(`loop`)를 빼야 embla 가 슬라이드를 개별 transform 으로 옮기지 않아 이음매 간격이 안 붙는다
- `FestivalSection`: 격자 → **계절별 캐러셀 4개**(봄·여름·가을·겨울)
- `ItinerarySection` + `items/common.tsx`: 코스마다 레일을 쌓던 것을 **탭 하나 = 레일 하나**로.
실측 payload 에서 캐러셀 20개 → 2개(1박2일·2박3일)
- `lib/format.ts`: 지도 주소에서 **쉼표를 뺀다.** 카카오 `link/to/{이름},{위도},{경도}` 는 쉼표로 칸을
가르는데 상호가 "스테이,머뭄" 이면 위도 자리에서 "머뭄" 을 읽고 **목적지를 통째로 버린다**
길찾기가 현위치만 뜨던 원인
- `seo/verify.ts`: JSON-LD 이미지가 절대 URL, HTML 은 루트 절대경로(`/assets/…`)라 **경로로도 대조**한다.
이게 없어서 사진을 미러한 사이트는 발행 게이트가 통째로 막혔다(시안 payload 재굽기가 9건으로 실패)
- 그 밖에 `GallerySection`(간격) · `VideoSection` · `UnitsTabs` · `UnitsBands` · `LocalGuideSection`(레일 간격)
· `WeatherSection` + `WeatherBand`/`tempNotes`(기온대별 한 줄) · `SiteHeader`(safe-t) · `seo/jsonld`·`head`
**이 브랜치 것을 지킨 자리** — 충돌 6곳은 손으로 갈랐다.
- `ItinerarySection`: 사장님 일정이 없으면 **서버 조립분**(`local.itineraries`)을 쓰는 폴백을 유지
- `seo/verify.ts`: 이 브랜치의 `unescaped` 대조와 시안의 경로 대조를 **둘 다** 본다
- 예약 버튼 문구는 시안(`{채널}로 예약`)이 아니라 이 브랜치의 `bookingActionLabel` 을 남겼다 —
네이버 예약 채널에서 "네이버 예약로 예약" 이 되는 것을 막는 쪽이 맞다. **남은 diff 4줄이 이것이다**
**템플릿 쪽** — `TemplateItem``defaultVariants` 를 더하고 옛 항구에 `photos: 'photos.carousel'` 을 건다.
시안의 사진 갤러리가 캐러셀인데 템플릿이 배리에이션을 지정할 자리가 없어 늘 기본으로 나갔다.
`disabledSectionTypes`(끄고 시작할 섹션) 기구도 함께 두되 **옛 항구에는 쓰지 않는다** — 예약 안내는 나간다.
**검증** — `tsc`(shared·site·frontend·admin) · eslint 통과. 시안 payload 를 현재 렌더러로 프리렌더 →
**검증 게이트 통과**, 캐러셀 11개가 시안과 같은 구성·순서. site vitest 는 7 failed / 44 passed 로
**착수 전과 같다**(stay-booking 7건은 이 작업 이전부터 실패).
⚠️ `/s/stay` 는 건드리지 않았다. 다만 `solution/site/payloads/stay.json` 이 남아 있는 한
**프리렌더 컨테이너가 기동할 때마다 목업이 그 payload 로 덮인다**(`watch-payloads.mjs` 의 `기동` 전체 재굽기).
목업은 payload 가 없어야 안전하다 — stay2·stay3 가 무사한 이유가 그것이다.
## 2026-09-10 — 일력(오늘의 한 장)을 서버 생성에 붙인다 · 종류가 늘어도 기존 지역이 따라온다
**무슨 일** — '옛 항구' 템플릿을 골라도 `/s/stay` 시안처럼 안 되는 자리를 따라갔더니 하나가
코드 문제였다. **일력만 서버가 만들지 않는다.** 렌더러에는 '오늘의 한 장' 탭이 있고
(`StorySection` 다섯 탭 중 둘째) 템플릿 설명도 "도넛판·**일력**·승차권"이라고 약속하는데,
프롬프트가 빌더(`canvas/dataSpec.ts`)에만 손으로 적혀 있어 `shared/section-prompts.ts`
없었다 — 서버는 그 종류가 있는 줄도 몰랐다. 시안에 일력이 있는 건 그때 손으로 넣었기 때문이다.
**같이 나온 두 번째 함정** — 목록이 두 벌이었다. `export-prompts.mjs` 가 종류 배열을
손으로 한 벌 더 들고 있어서, `STORY_KINDS` 에 하나를 늘려도 **뽑히지 않는다**.
프론트는 아는데 서버만 모르는 상태가 되고, 그 종류의 탭은 조용히 빈칸으로 남는다.
**세 번째 — 가드가 정확히 반대로 돈다** — `has_stories()` 는 "한 건이라도 있으면 다시 안 부른다"
였다. "같은 지역 두 번째 숙소"만 생각한 가드라, **종류가 늘어난 날** 이미 다섯이 든 지역
(52군산시)은 여섯 번째를 영영 못 받는다. 새 지역만 여섯이 되고 기존 지역은 다섯에 멈춰,
같은 템플릿을 골라도 지역에 따라 탭 수가 다른 상태가 된다.
- `shared/section-prompts.ts`: `daily` 스펙 추가(maxItems 30) · `STORY_KINDS` 를 발행본 탭 순서로
- `shared/scripts/export-prompts.mjs`: 종류 목록을 손으로 적지 않고 `STORY_KINDS` 에서 읽는다
- `frontend/canvas/dataSpec.ts`: 일력의 task·rules 를 shared 참조로 — 다섯과 같은 모양이 됐다
- `backend/story_service.py`: `has_stories``missing_kinds`**없는 종류만** 부른다.
요금 가드는 그대로다(있는 종류는 여전히 한 번도 다시 안 부른다). 읽기 실패는 "없다"로
치지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다
- `backend/enums.py` · `grounding/story.py` · `init-data/init.sql`: 여섯으로 맞춤
**검증** — `tsc --noEmit`(shared·frontend·site) · eslint 통과. 프롬프트 계약 테스트 2건 추가.
실제 payload(`stttt`)의 `local.story.daily` 에 두 건을 넣고 구워, '오늘의 한 장' 탭이
다섯 번째로 서는 것까지 확인했다.
⚠️ pytest 전체는 이 브랜치 이전부터 로컬 Postgres 인증 실패로 막혀 있다 — 새 테스트는 DB 를
안 쓰지만 세션 픽스처가 먼저 걸린다. 개별 함수를 직접 호출해 통과를 확인했다.
**아직 남은 것(코드가 아니라 데이터)** — `/s/stay-mumum-gunsan` 이 시안과 다른 나머지는
소개·객실·FAQ·영상·소식과 fact 8건이 비어서다. 사장님이 채우거나 수집이 가져와야 한다.
## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈) ## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈)
**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. **무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.

View File

@ -1,18 +1,9 @@
-- 0003 · 섹션 콘텐츠를 `sites.theme` 에서 꺼내 행으로 -- 0003 · 섹션 콘텐츠를 sites.theme 에서 꺼내 행으로
-- --
-- 지금은 색·서체(디자인)와 섹션별 콘텐츠가 `site.sites.theme` JSONB 한 칸에 같이 있다. -- theme 42,150 B 중 디자인은 636 B 뿐이고 나머지가 콘텐츠다. 크기가 아니라 쓰기 단위가
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 -- 문제였다 — 영상 주소 하나(592 B)를 고쳐도 42 KB 를 다시 쓰고, 둘이 만지면 뒤가 앞을 덮고,
-- 나머지 41,500 B 가 섹션이다. 그중 콘텐츠(sections[].data)만 39,645 B — 94%. -- 항목마다 출처·검증 상태를 물을 자리가 없다.
-- 가장 큰 섹션 하나(itinerary)가 17,990 B 로, 디자인 전체의 28 배다. -- ★ 이 테이블이 JSON import/export 의 단위다.
--
-- 크기가 문제인 게 아니다(JSONB 는 1GB 까지 든다). 문제는 **쓰기 단위**다:
-- · 사장님이 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓴다
-- · 같은 사이트를 둘이 만지면 나중 쓰기가 앞을 통째로 덮는다
-- · 항목마다 "누가 넣었나 · 확인됐나"를 물을 자리가 없다(facts 는 status 를 갖는다)
-- · 검증이 없어 렌더러에 존재하지도 않는 섹션이 남는다
-- (실측: 조이모텔에 course·schedule — 켤 수는 있는데 화면엔 아무 일도 안 일어난다)
--
-- ★ 이 테이블이 JSON import/export 의 단위다. 사이트 하나 = 이 행 묶음 + theme(디자인).
CREATE TABLE IF NOT EXISTS site.site_contents ( CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@ -34,9 +25,7 @@ CREATE TABLE IF NOT EXISTS site.site_contents (
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section
ON site.site_contents (site_id, section_id) WHERE deleted = FALSE; ON site.site_contents (site_id, section_id) WHERE deleted = FALSE;
-- 지역 이야기 — 가요·인물·연표·엽서·퀴즈는 **업장이 아니라 지역의 것**이다. -- 지역 이야기는 업장이 아니라 지역의 것이다 — 군산 이야기는 군산 숙소가 같이 쓴다.
-- 군산 이야기는 군산 숙소가 같이 쓴다. 지금은 만들 자리가 아예 없어서
-- 시안(/s/stay)에는 사람이 3만 자를 손으로 넣었다.
CREATE TABLE IF NOT EXISTS local.region_stories ( CREATE TABLE IF NOT EXISTS local.region_stories (
region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드 region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드
@ -52,9 +41,7 @@ CREATE TABLE IF NOT EXISTS local.region_stories (
CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind
ON local.region_stories (region_code, kind) WHERE deleted = FALSE; ON local.region_stories (region_code, kind) WHERE deleted = FALSE;
-- 이미 theme 에 박혀 있는 콘텐츠를 옮긴다. -- theme 의 콘텐츠를 옮긴다. data 는 문자열이라 JSONB 로 되돌린다 — 깨진 것은 건너뛴다.
-- ★ data 는 문자열로 저장돼 있다(서버가 파싱하지 않는 규약) — JSONB 로 되돌린다.
-- 깨진 JSON 은 옮기지 않는다. 원본은 theme 에 남으므로 잃지 않는다.
INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order) INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id, SELECT s.site_id,
sec->>'id', sec->>'id',
@ -71,5 +58,4 @@ WHERE s.deleted = FALSE
AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을 -- theme.sections[].data 는 읽는 쪽이 옮겨간 뒤 별도 번호로 뗀다.
-- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다.

View File

@ -1,34 +1,23 @@
-- 0004 · 공용 콘텐츠를 **한 테이블로** 되돌린다 (0002·0003 정정) -- 0004 · 공용 콘텐츠를 한 테이블로 (0002·0003 정정)
-- --
-- 0002 에서 `local.spots` 를, 0003 에서 `local.region_stories` 를 새로 팠다. 그게 틀렸다. -- 0002·0003 에서 spots·region_stories 를 새로 팠다. 풀 문제는 "place_contents 가 place_id
-- 풀어야 했던 문제는 "`place_contents` 가 place_id 키라 업장마다 복제된다" 하나였고, -- 키라 복제된다" 하나였고 실체/관계만 가르면 됐는데, local_contents 에 이미 축제 60·
-- 그건 **실체와 관계를 가르면** 끝나는 문제였다. 그런데 거기에 더해 테이블까지 새로 만들어서 -- 관광지 12·맛집 12건이 있는 걸 못 보고 옆에 하나 더 만들었다.
-- 같은 성격의 공용 콘텐츠가 세 곳으로 갈라졌다 — -- 공용 콘텐츠는 local_contents 한 벌, 관계만 place_contents 에 남긴다.
-- local_contents 에 이미 축제 60 · 관광지 12 · 맛집 12건이 들어 있었다(실측 2026-09-09).
-- `local_contents` 는 처음부터 content_type 으로 종류를 가르는 설계였다. 그걸 쓰면 됐다.
--
-- 그래서 공용 콘텐츠는 `local.local_contents` 한 벌로 모으고,
-- 업장과의 관계(거리·숨김)만 `local.place_contents` 에 남긴다. 관계까지 합치면 복제가 돌아온다.
-- ── 1. 공용 테이블에 부족했던 것 ────────────────────────────────────────── -- 좌표: 거리 계산이 읽는다. body 안에 두면 행마다 JSON 을 편다.
-- 좌표: 거리 계산과 일정 조립이 읽는다. body 안에 두면 행마다 JSON 을 파싱해야 한다.
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS latitude NUMERIC(10,7) NULL; ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS latitude NUMERIC(10,7) NULL;
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS longitude NUMERIC(10,7) NULL; ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS longitude NUMERIC(10,7) NULL;
-- 종류를 문자열로도 받는다 — 지역 이야기(songs·people·chronicle·postcard·quiz)는 -- kind: 지역 이야기(songs·people·…). 장소류는 NULL.
-- SMALLINT 코드를 새로 발급하기보다 이름 그대로가 읽힌다. 장소류는 NULL.
ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS kind VARCHAR(50) NULL; ALTER TABLE local.local_contents ADD COLUMN IF NOT EXISTS kind VARCHAR(50) NULL;
-- ★ region_code 를 nullable 로 푼다. -- 축제는 전국 공용이다 — 지역이 유일성의 근거가 되면 한 벌이 아니다.
-- 축제·관광지·맛집은 **전국 공용**이다 — 같은 축제가 시군구마다 한 행씩 생기면
-- "한 벌"이 아니다. 지역은 조회 편의로 채우되 유일성의 근거는 external_id 다.
ALTER TABLE local.local_contents ALTER COLUMN region_code DROP NOT NULL; ALTER TABLE local.local_contents ALTER COLUMN region_code DROP NOT NULL;
-- 옛 유일성: (region_code, content_type, external_id) — 같은 축제를 지역 수만큼 허용한다. -- 옛 유일성: (region_code, content_type, external_id) — 같은 축제를 지역 수만큼 허용한다.
DROP INDEX IF EXISTS local.uq_local_contents_keyed; DROP INDEX IF EXISTS local.uq_local_contents_keyed;
-- ★ 그 인덱스가 허용해 온 중복을 먼저 접는다. 실측(2026-09-09): 축제 60행이 지역 2곳에 -- 옛 인덱스가 허용해 온 중복을 먼저 접는다 — 축제 60행이 지역 2곳에 걸쳐 있었다.
-- 걸쳐 있었다 — 같은 행사가 시군구마다 한 행씩이다. 가장 최근에 수집한 것만 남긴다.
-- (접지 않고 유일 인덱스를 걸면 "could not create unique index" 로 통째로 실패한다.)
UPDATE local.local_contents lc UPDATE local.local_contents lc
SET deleted = TRUE, updated_at = now() SET deleted = TRUE, updated_at = now()
WHERE lc.deleted = FALSE WHERE lc.deleted = FALSE
@ -50,7 +39,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind
CREATE INDEX IF NOT EXISTS idx_local_contents_type_region CREATE INDEX IF NOT EXISTS idx_local_contents_type_region
ON local.local_contents (content_type, region_code) WHERE deleted = FALSE; ON local.local_contents (content_type, region_code) WHERE deleted = FALSE;
-- ── 2. spots 에 있던 실체를 공용 테이블로 접어 넣는다 ─────────────────────
INSERT INTO local.local_contents (region_code, content_type, source, external_id, title, body, INSERT INTO local.local_contents (region_code, content_type, source, external_id, title, body,
latitude, longitude, display_end_at, collected_at, status) latitude, longitude, display_end_at, collected_at, status)
SELECT s.region_code, s.content_type, s.source, s.external_id, s.title, s.body, SELECT s.region_code, s.content_type, s.source, s.external_id, s.title, s.body,
@ -63,8 +51,7 @@ WHERE s.deleted = FALSE
WHERE lc.deleted = FALSE AND lc.external_id = s.external_id AND lc.source = s.source WHERE lc.deleted = FALSE AND lc.external_id = s.external_id AND lc.source = s.source
); );
-- ── 3. 업장 ↔ 콘텐츠 관계 ──────────────────────────────────────────────── -- 옛 구조는 이름을 비켜 둔다.
-- 기존 place_contents 는 값을 통째로 들고 있던 옛 구조다. 이름을 비켜 두고 새로 만든다.
ALTER TABLE IF EXISTS local.place_contents RENAME TO place_contents_legacy; ALTER TABLE IF EXISTS local.place_contents RENAME TO place_contents_legacy;
ALTER INDEX IF EXISTS local.uq_place_contents_keyed RENAME TO uq_place_contents_legacy_keyed; ALTER INDEX IF EXISTS local.uq_place_contents_keyed RENAME TO uq_place_contents_legacy_keyed;
ALTER INDEX IF EXISTS local.idx_place_contents_place RENAME TO idx_place_contents_legacy_place; ALTER INDEX IF EXISTS local.idx_place_contents_place RENAME TO idx_place_contents_legacy_place;
@ -92,11 +79,8 @@ JOIN local.local_contents lc
WHERE ps.deleted = FALSE WHERE ps.deleted = FALSE
ON CONFLICT (place_id, local_content_id) DO NOTHING; ON CONFLICT (place_id, local_content_id) DO NOTHING;
-- ── 4. 갈라놓았던 테이블을 접는다 ────────────────────────────────────────
-- 아직 읽는 코드가 없다(0002·0003 은 테이블만 만들었다) — 지금이 지우기 가장 싸다.
DROP TABLE IF EXISTS local.place_spots; DROP TABLE IF EXISTS local.place_spots;
DROP TABLE IF EXISTS local.spots; DROP TABLE IF EXISTS local.spots;
DROP TABLE IF EXISTS local.region_stories; DROP TABLE IF EXISTS local.region_stories;
-- ★ place_contents_legacy 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 — -- legacy 는 읽는 코드가 옮겨간 뒤 별도 번호로 뗀다.
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.

View File

@ -1,34 +1,13 @@
-- 0005 · 스키마 해체하고 이름으로 소속을 말한다 -- 0005 · 스키마 해체
-- --
-- ★ 왜 (2026-09-09) -- 전용 database 라 이름 충돌이 없고(19개 전부 다름), 계정이 하나라 스키마별 GRANT 도 없다.
-- 스키마를 나누는 이유는 셋뿐이다 — ① 같은 이름을 여러 번 쓰려고 ② 권한을 덩어리로 주려고 -- 남은 건 "경계 표시"인데 places.place_id 를 참조하는 11개 테이블이 전부 place 밖에 있었다 —
-- ③ 읽는 사람에게 경계를 보여주려고. 이 프로젝트에선 ①②가 성립하지 않는다: -- 조인이 매번 넘는 선은 경계가 아니다. 접두어가 소속을 말하게 한다.
-- · `web4ai_db` 는 이 제품 전용 database 다. 다른 프로젝트는 다른 database 에 있고, -- area_contents 만 접두어가 없다 — 공유물이라는 표시다.
-- 19개 테이블 이름이 전부 다르다 — 충돌할 상대가 애초에 없다.
-- · 접속 계정이 하나다. 스키마별 GRANT 를 준 적이 없고, 실제 권한 분리는 DB 밖에서
-- 포트로 한다(솔루션 9800 / 어드민 9801, ADMIN_API_BIND=127.0.0.1).
-- ③만 남는데, 그 값을 치르는 방식이 틀렸다:
-- · 19개 중 9개가 스키마 이름을 다시 말한다(place.places · fact.facts · site.sites ·
-- local.local_contents · job.jobs …). 폴더 이름이 파일 이름에 이미 들어 있으면
-- 그 폴더는 정보를 더하지 못한다.
-- · **결정적으로, 경계가 조인 방향과 반대다.** `places.place_id` 를 참조하는 테이블이
-- 11개인데 그중 `place` 스키마 안에 있는 건 **하나도 없다**. 관계를 잇는 컬럼이
-- 매번 경계를 넘는다면 그건 경계가 아니다. 특히 업장↔지역 관계 테이블은
-- 존재 자체가 두 스키마를 잇는 것이라 어디에 둬도 틀린다.
--
-- 그래서 스키마를 없애고 **접두어가 소속을 말한다.**
-- place_* 한 업장의 것 · site_* 한 사이트의 것 · users/jobs 아무에게도 안 속함
-- area_contents 만 접두어가 없다 — **그게 공유물이라는 표시다.**
-- 이 규칙이 서면 오늘 같은 복제 사고는 이름만 봐도 걸린다(place_ 붙은 테이블에 축제가
-- 들어가 있으면 그 자체로 틀린 것이다).
--
-- ★ 데이터는 옮기지 않는다. RENAME 뿐이라 잃는 행이 없다.
-- ── 사람 · 큐 (아무에게도 안 속한다) ──────────────────────────────────────
ALTER TABLE IF EXISTS company.users SET SCHEMA public; ALTER TABLE IF EXISTS company.users SET SCHEMA public;
ALTER TABLE IF EXISTS job.jobs SET SCHEMA public; ALTER TABLE IF EXISTS job.jobs SET SCHEMA public;
-- ── 업장 ────────────────────────────────────────────────────────────────
ALTER TABLE IF EXISTS place.places SET SCHEMA public; ALTER TABLE IF EXISTS place.places SET SCHEMA public;
ALTER TABLE IF EXISTS place.place_links SET SCHEMA public; ALTER TABLE IF EXISTS place.place_links SET SCHEMA public;
ALTER TABLE IF EXISTS place.units SET SCHEMA public; ALTER TABLE IF EXISTS place.units SET SCHEMA public;
@ -36,33 +15,25 @@ ALTER TABLE IF EXISTS place.media SET SCHEMA public;
ALTER TABLE IF EXISTS fact.facts SET SCHEMA public; ALTER TABLE IF EXISTS fact.facts SET SCHEMA public;
ALTER TABLE IF EXISTS fact.faqs SET SCHEMA public; ALTER TABLE IF EXISTS fact.faqs SET SCHEMA public;
-- 이름이 무엇에 속하는지, 그리고 무엇을 담는지 말하게 한다.
ALTER TABLE IF EXISTS public.place_links RENAME TO place_channels; -- 네이버·야놀자 등 채널 ALTER TABLE IF EXISTS public.place_links RENAME TO place_channels; -- 네이버·야놀자 등 채널
ALTER TABLE IF EXISTS public.units RENAME TO place_units; -- 객실·메뉴·프로그램 ALTER TABLE IF EXISTS public.units RENAME TO place_units; -- 객실·메뉴·프로그램
ALTER TABLE IF EXISTS public.media RENAME TO place_photos; -- 실제로 사진만 담는다 ALTER TABLE IF EXISTS public.media RENAME TO place_photos; -- 실제로 사진만 담는다
ALTER TABLE IF EXISTS public.facts RENAME TO place_facts; ALTER TABLE IF EXISTS public.facts RENAME TO place_facts;
ALTER TABLE IF EXISTS public.faqs RENAME TO place_faqs; ALTER TABLE IF EXISTS public.faqs RENAME TO place_faqs;
-- ── 지역 (여럿이 나눠 쓴다) ──────────────────────────────────────────────
ALTER TABLE IF EXISTS local.local_contents SET SCHEMA public; ALTER TABLE IF EXISTS local.local_contents SET SCHEMA public;
ALTER TABLE IF EXISTS local.place_contents SET SCHEMA public; ALTER TABLE IF EXISTS local.place_contents SET SCHEMA public;
-- 접두어 없는 유일한 테이블. 공유물이라는 표시다.
ALTER TABLE IF EXISTS public.local_contents RENAME TO area_contents; ALTER TABLE IF EXISTS public.local_contents RENAME TO area_contents;
-- 값이 아니라 **관계**다 — 이름이 그걸 말해야 한다.
ALTER TABLE IF EXISTS public.place_contents RENAME TO place_area_refs; ALTER TABLE IF EXISTS public.place_contents RENAME TO place_area_refs;
-- ── 사이트 ──────────────────────────────────────────────────────────────
ALTER TABLE IF EXISTS site.sites SET SCHEMA public; ALTER TABLE IF EXISTS site.sites SET SCHEMA public;
ALTER TABLE IF EXISTS site.site_contents SET SCHEMA public; ALTER TABLE IF EXISTS site.site_contents SET SCHEMA public;
ALTER TABLE IF EXISTS site.site_versions SET SCHEMA public; ALTER TABLE IF EXISTS site.site_versions SET SCHEMA public;
ALTER TABLE IF EXISTS site.publish_logs SET SCHEMA public; ALTER TABLE IF EXISTS site.publish_logs SET SCHEMA public;
-- 섹션의 '내용'이다. site_contents 는 무엇의 콘텐츠인지 말하지 않는다.
ALTER TABLE IF EXISTS public.site_contents RENAME TO site_sections; ALTER TABLE IF EXISTS public.site_contents RENAME TO site_sections;
ALTER TABLE IF EXISTS public.publish_logs RENAME TO site_publish_logs; ALTER TABLE IF EXISTS public.publish_logs RENAME TO site_publish_logs;
-- ── 아직 남기는 것 ────────────────────────────────────────────────────── -- 빈 테이블과 이관 잔재는 0006 에서 판단한다.
-- 빈 테이블(place_aliases · routes · nearby_links · ai_check_results)과 이관 잔재
-- (place_contents_legacy)는 0006 에서 판단한다. 한 파일에 두 가지 일을 넣지 않는다.
ALTER TABLE IF EXISTS place.place_aliases SET SCHEMA public; ALTER TABLE IF EXISTS place.place_aliases SET SCHEMA public;
ALTER TABLE IF EXISTS local.routes SET SCHEMA public; ALTER TABLE IF EXISTS local.routes SET SCHEMA public;
ALTER TABLE IF EXISTS local.nearby_links SET SCHEMA public; ALTER TABLE IF EXISTS local.nearby_links SET SCHEMA public;
@ -70,13 +41,10 @@ ALTER TABLE IF EXISTS site.ai_check_results SET SCHEMA public;
ALTER TABLE IF EXISTS local.place_contents_legacy SET SCHEMA public; ALTER TABLE IF EXISTS local.place_contents_legacy SET SCHEMA public;
ALTER TABLE IF EXISTS public.routes RENAME TO place_routes; ALTER TABLE IF EXISTS public.routes RENAME TO place_routes;
ALTER TABLE IF EXISTS public.place_aliases RENAME TO place_names; ALTER TABLE IF EXISTS public.place_aliases RENAME TO place_names;
-- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 **업장**이 AI 검색에 나오는지를 -- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 업장이 대상이다.
-- 재는 것이라, 사이트를 안 만들어도 잴 수 있다. site_ 가 아니라 place_ 다.
ALTER TABLE IF EXISTS public.ai_check_results RENAME TO place_ai_checks; ALTER TABLE IF EXISTS public.ai_check_results RENAME TO place_ai_checks;
-- ── 빈 스키마 정리 ────────────────────────────────────────────────────── -- CASCADE 를 쓰지 않는다 — 빠뜨린 테이블이 있으면 여기서 실패해야 한다.
-- ★ CASCADE 를 쓰지 않는다. 옮기다 빠뜨린 테이블이 있으면 여기서 실패해야 한다 —
-- CASCADE 면 그 테이블이 조용히 함께 지워진다.
DROP SCHEMA IF EXISTS company RESTRICT; DROP SCHEMA IF EXISTS company RESTRICT;
DROP SCHEMA IF EXISTS place RESTRICT; DROP SCHEMA IF EXISTS place RESTRICT;
DROP SCHEMA IF EXISTS fact RESTRICT; DROP SCHEMA IF EXISTS fact RESTRICT;

View File

@ -1,27 +1,15 @@
-- 0006 · 한 번도 쓰지 않은 테이블과 이관 잔재를 뗀다 -- 0006 · 한 번도 쓰지 않은 테이블 제거
-- --
-- 실측(2026-09-09) — 네 테이블이 **행 0건**이다. 만들어 두고 기능이 붙지 않았다: -- 네 테이블이 행 0건이다 — 만들어 두고 기능이 안 붙었다.
-- place_names(별칭) · place_routes(가는 길) · nearby_links(주변) · place_ai_checks(AI 노출 점검) -- nearby_links 는 area_contents 와 역할이 겹친다(카카오 로컬 / TourAPI).
-- 화면에 나오지 않는 테이블을 유지비만 내며 들고 있을 이유가 없다 — 필요해지는 날
-- 그때의 요구에 맞춰 만드는 편이 낫다(지금 모양이 그때 맞을 거라는 보장도 없다).
--
-- ★ nearby_links 는 area_contents 와 역할이 겹친다. 둘 다 "업장 주변의 장소"인데
-- 하나는 카카오 로컬, 하나는 TourAPI 였다. 겹치는 자리를 남겨 두면 다음 사람이
-- "어느 쪽이 진짜냐"를 다시 묻는다 — 오늘 spots/local_contents 로 이미 겪었다.
DROP TABLE IF EXISTS public.place_names; DROP TABLE IF EXISTS public.place_names;
DROP TABLE IF EXISTS public.place_routes; DROP TABLE IF EXISTS public.place_routes;
DROP TABLE IF EXISTS public.nearby_links; DROP TABLE IF EXISTS public.nearby_links;
DROP TABLE IF EXISTS public.place_ai_checks; DROP TABLE IF EXISTS public.place_ai_checks;
-- 0004 에서 place_contents 를 관계 테이블로 바꾸며 옆으로 밀어 둔 옛 구조. -- 0004 에서 밀어 둔 옛 구조. 값은 area_contents + place_area_refs 로 옮겨졌다.
-- 값은 area_contents(198건) + place_area_refs(144건)로 전부 옮겨졌다.
DROP TABLE IF EXISTS public.place_contents_legacy; DROP TABLE IF EXISTS public.place_contents_legacy;
-- ── 발행 이력은 합치지 않는다 ────────────────────────────────────────── -- site_publish_logs 는 합치지 않는다 — 52/52 라 1:1 로 보이지만 action 이
-- 처음엔 site_versions(52) 와 site_publish_logs(52) 가 1:1 이라 합치려 했다. 아니다. -- publish 45 · rebuild 7 로 섞여 있어 구조는 1:N 이다.
-- 실측: action 분포가 publish 45 · rebuild 7 로 이미 섞여 있다. 같은 버전을 다시 굽거나
-- 내리면 그 순간 2:1 이 된다 — 지금 1:1 인 건 우연이고 **구조는 1:N** 이다.
-- 합쳤다면 두 번째 발행이 첫 기록을 덮었을 것이다.
-- 성격도 다르다: site_versions 는 "무엇을 구웠나"(스냅샷·JSON-LD), publish_logs 는
-- "언제 무슨 일이 있었나"(동작·결과·반려사유·실행자)다.

View File

@ -1,15 +1,8 @@
-- 0007 · 지역 이야기 다섯 종이 한 지역에 나란히 설 수 있게 한다 -- 0007 · 지역 이야기 다섯 종이 한 지역에 나란히 서게
-- --
-- `uq_local_contents_single (region_code, content_type) WHERE external_id IS NULL` 은 -- uq_local_contents_single 은 날씨용(지역당 한 행)인데 이야기도 external_id 가 없어 같이 걸렸다.
-- **날씨**를 위해 만든 인덱스다 — 날씨는 출처 id 가 없고 지역당 한 행이면 된다. -- 실측(52군산시): 54건 생성됐는데 첫 종류만 저장되고 넷이 unique 위반으로 떨어졌다 —
-- 그런데 지역 이야기도 external_id 가 없다(유일성의 근거가 `kind` 다). 그래서 같은 지역의 -- 잡은 성공으로 끝나고 화면만 비었다. single 은 kind 가 없는 행(=날씨)에만 건다.
-- 가요·인물·연표·엽서·퀴즈 다섯이 `(region_code, content_type=6)` 하나를 두고 부딪친다 —
-- 실측(2026-09-09, 52군산시): 생성은 54건 다 됐는데 저장은 첫 종류만 들어가고 나머지 넷이
-- unique 위반으로 떨어졌다. 잡은 "성공"으로 끝나고 화면만 비어 있다 — 조용히 틀리는 종류다.
--
-- 고치는 방향: `single` 은 **kind 가 없는 행**(=날씨)에만 건다. 이야기의 유일성은
-- 0004 가 만든 `uq_local_contents_kind (region_code, kind)` 가 이미 책임진다.
-- 인덱스 둘이 같은 행을 두고 다투지 않게, 각자 자기 몫만 보게 가른다.
DROP INDEX IF EXISTS public.uq_local_contents_single; DROP INDEX IF EXISTS public.uq_local_contents_single;

View File

@ -1,22 +1,13 @@
-- 0008 · 공용과 개인화를 이름으로 가른다 -- 0008 · 공용과 개인화를 이름으로 가른다
-- --
-- 규칙 두 줄로 정리했다(2026-09-09): -- area_* 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로 담는다.
-- area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → **렌더러 모양 그대로** 담는다. -- site_sections 개인화 전부(거리·숨김·순서·사장님 편집).
-- site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·사장님 편집).
-- --
-- 지금은 셋이 어긋나 있다: -- 지금은 body 가 TourAPI 원문 이름이고(빌드마다 바꿔 왔다), 거리·숨김이 공용 쪽에 있다.
-- 1) area_contents.body 가 TourAPI **원문 이름**(mapx·addr1·firstimage)이다. 렌더러가 읽는
-- 이름(name·location·imageUrl)으로 매번 빌드에서 바꿔 왔다 — 저장할 때 바꾸면 될 일이다.
-- 2) 거리·숨김이 place_area_refs 에 있다. 그건 **사이트마다 다른 값**이라 개인화 쪽이다.
-- 3) area_contents.kind 가 지역 이야기에만 있고 장소류는 NULL 이라, 개인화 행이 무엇을
-- 가리키는지 이름으로 알 수 없었다.
-- ── 1. 타입명을 모든 행에 채운다 ────────────────────────────────────── -- 1. 타입명을 모든 행에 채운다
-- ★ 인덱스를 **먼저** 뗀다. 옛 uq_local_contents_kind 는 (region_code, kind) 라 같은 지역의 -- 인덱스를 먼저 뗀다 — 옛 (region_code, kind) 는 같은 지역 관광지 여러 건이 전부
-- 관광지 여러 건이 전부 kind='attraction' 이 되는 순간 부딪친다 — 실측(2026-09-09)에서 -- kind='attraction' 이 되는 순간 부딪친다. 장소류 유일성은 (source, external_id) 가 잡는다.
-- `(gunsan, festival) already exists` 로 이 UPDATE 가 통째로 막혔다.
-- 지역 이야기는 "지역 × 종류 한 벌"이 맞고, 장소류의 유일성은 uq_local_contents_external
-- (source, external_id) 이 잡는다. 그래서 조건에 external_id IS NULL 을 더해 둘을 가른다.
DROP INDEX IF EXISTS public.uq_local_contents_kind; DROP INDEX IF EXISTS public.uq_local_contents_kind;
UPDATE public.area_contents SET kind = CASE content_type UPDATE public.area_contents SET kind = CASE content_type
@ -29,11 +20,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind
ON public.area_contents (region_code, kind) ON public.area_contents (region_code, kind)
WHERE deleted = FALSE AND kind IS NOT NULL AND external_id IS NULL; WHERE deleted = FALSE AND kind IS NOT NULL AND external_id IS NULL;
-- ── 2. body 를 렌더러 모양으로 ──────────────────────────────────────── -- 2. body 를 렌더러 모양으로
-- ★ 좌표는 body 에서 뺀다 — latitude/longitude 컬럼이 이미 그 자리다(0004). 일정 조립이 -- 좌표는 뺀다 — latitude/longitude 컬럼이 그 자리다(0004).
-- body.mapx 를 읽던 것을 컬럼으로 돌린다. 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다. -- lclsSystm2 는 남긴다 — 주변 맛집에서 같은 업태를 뺄 때 쓴다.
-- ★ lclsSystm2 만 남긴다. 렌더러는 안 쓰지만 서버가 쓴다(주변 맛집에서 같은 업태 제외).
-- ★ distance_m 은 여기서 사라진다. 사이트마다 다른 값이라 아래 3번으로 간다.
UPDATE public.area_contents SET body = ( UPDATE public.area_contents SET body = (
jsonb_strip_nulls(jsonb_build_object( jsonb_strip_nulls(jsonb_build_object(
'name', COALESCE(NULLIF(body->>'title', ''), title), 'name', COALESCE(NULLIF(body->>'title', ''), title),
@ -42,8 +31,7 @@ UPDATE public.area_contents SET body = (
'imageUrl', NULLIF(body->>'firstimage', ''), 'imageUrl', NULLIF(body->>'firstimage', ''),
'lclsSystm2', NULLIF(body->>'lclsSystm2', '') 'lclsSystm2', NULLIF(body->>'lclsSystm2', '')
)) ))
-- 축제는 기간·홈페이지가 더 붙는다. 화면의 배지(month)·기간 문자열은 읽을 때 만든다 — -- 축제는 날짜 원값을 남긴다 — 노출 기간 필터가 읽는다.
-- 날짜 원값을 남겨 둬야 노출 기간 필터와 어긋나지 않는다.
|| CASE WHEN content_type = 2 THEN jsonb_strip_nulls(jsonb_build_object( || CASE WHEN content_type = 2 THEN jsonb_strip_nulls(jsonb_build_object(
'eventstartdate', NULLIF(body->>'eventstartdate', ''), 'eventstartdate', NULLIF(body->>'eventstartdate', ''),
'eventenddate', NULLIF(body->>'eventenddate', ''), 'eventenddate', NULLIF(body->>'eventenddate', ''),
@ -53,13 +41,9 @@ UPDATE public.area_contents SET body = (
), updated_at = now() ), updated_at = now()
WHERE deleted = FALSE AND content_type BETWEEN 2 AND 5 AND body ? 'contentid'; WHERE deleted = FALSE AND content_type BETWEEN 2 AND 5 AND body ? 'contentid';
-- ── 3. 거리·숨김을 사이트 쪽으로 옮긴다 ─────────────────────────────── -- 3. 거리·숨김을 사이트 쪽으로
-- 사이트가 없는 업장(수집만 하고 발행 안 한 곳)은 옮길 자리가 없다 — 그때는 다음 수집이 -- 사이트가 없는 업장은 다음 수집이 다시 쓴다 — 여기서 sites 행을 만들지 않는다.
-- 사이트를 만들며 다시 쓴다. 여기서 sites 행을 만들지 않는다(발행 정책은 build_service 것이다). -- items 배열이 아니라 ref → 값 맵이다. 순서는 읽는 쪽이 정한다.
-- ★ `items` 배열이 아니라 **ref → 값 맵**이다. items 는 화면에 순서대로 서는 항목들의 모양이고
-- (songs·people·… 이 그 모양이다), 여기 담기는 건 "공용 항목 하나에 이 사이트가 덧붙인 값"
-- 조회표다. 배열로 두면 읽을 때마다 훑어야 하고 ref 가 항목마다 한 번 더 들어간다.
-- 순서도 여기서 정하지 않는다 — 정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다.
INSERT INTO public.site_sections (site_id, section_id, data, source_type, status, sort_order) INSERT INTO public.site_sections (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id, SELECT s.site_id,
'local', 'local',

View File

@ -1,12 +1,7 @@
-- 0009 · init.sql 을 현재 스키마로 다시 쓰면서 드러난 어긋남을 맞춘다 -- 0009 · init.sql 과 어긋난 것을 맞춘다
-- --
-- ★ 어떻게 찾았나 (2026-09-10) -- init.sql 은 새 DB 만, migrations 는 기존 DB 만 본다. 둘이 갈리면 새로 세운 DB 가
-- `init.sql` 이 0005 의 스키마 해체를 따라오지 않아 옛 도메인 스키마를 그대로 만들고 있었다. -- 옛 구조로 선다 — 그래서 여기서 한 번 맞춘다.
-- 그걸 현재 모습으로 다시 쓴 뒤, **빈 컨테이너에 새로 세운 DB** 와 **마이그레이션으로 따라온
-- 기존 DB** 를 `pg_dump --schema-only` 로 나란히 놓고 비교했다. 표 목록은 같았고 아래 셋이 달랐다.
-- 이 비교는 앞으로도 같은 방법으로 한다 — ORM 주석이나 기억이 아니라 두 DB 를 실제로 찍어 본다.
--
-- ★ 이 파일은 데이터를 건드리지 않는다. 이름·타입·인덱스만 맞춘다.
-- ── 1. 없는 인덱스 (실사용에 영향) ────────────────────────────────────── -- ── 1. 없는 인덱스 (실사용에 영향) ──────────────────────────────────────
-- 0003 이 기존 DB 에 site_contents 를 만들 때 유니크(uq_site_contents_section)만 걸고 -- 0003 이 기존 DB 에 site_contents 를 만들 때 유니크(uq_site_contents_section)만 걸고

View File

@ -265,23 +265,38 @@ class area_contents(MainTableMixin, MAIN_BASE):
외부 API 실패 행을 지우거나 비우지 않는다 직전 값을 그대로 유지하고 내부 알림만 낸다.""" 외부 API 실패 행을 지우거나 비우지 않는다 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "area_contents" __tablename__ = "area_contents"
# ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 —
# 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만
# 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류).
# 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다.
# ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는
# 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 —
# 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다.
__table_args__ = ( __table_args__ = (
# external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다. # 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** —
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
Index( Index(
"uq_local_contents_keyed", "uq_local_contents_external",
"region_code", "source",
"content_type",
"external_id", "external_id",
unique=True, unique=True,
postgresql_where=text("deleted = false AND external_id IS NOT NULL"), postgresql_where=text("deleted = false AND external_id IS NOT NULL"),
), ),
# external_id 가 없는 항목(날씨)은 지역 × 종류당 1행. # 지역 이야기: 한 지역에 종류당 한 벌.
Index(
"uq_local_contents_kind",
"region_code",
"kind",
unique=True,
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
),
# 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007).
Index( Index(
"uq_local_contents_single", "uq_local_contents_single",
"region_code", "region_code",
"content_type", "content_type",
unique=True, unique=True,
postgresql_where=text("deleted = false AND external_id IS NULL"), postgresql_where=text("deleted = false AND external_id IS NULL AND kind IS NULL"),
), ),
) )
@ -310,14 +325,10 @@ class area_contents(MainTableMixin, MAIN_BASE):
class place_area_refs(MainTableMixin, MAIN_BASE): class place_area_refs(MainTableMixin, MAIN_BASE):
"""업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.** """업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다.
예전에는 테이블이 값을 통째로 들고 있었다(place_contents). 키가 place_id 라서 예전엔 값을 통째로 들고 키가 place_id 업장마다 복제됐다( 곳에 144).
업장마다 TourAPI 응답이 복제됐다 실측(2026-09-09) 조이모텔 곳에 144행이고, hidden 재수집이 덮어쓰지 않는다."""
성남 중원구에 모텔이 들어오면 같은 축제가 벌이 된다.
실체는 `area_contents` , 여기에는 관계만 남긴다.
hidden 재수집이 덮어쓰지 않는다 운영자가 것을 다음 갱신이 되살리면
숨긴 의미가 없다."""
__tablename__ = "place_area_refs" __tablename__ = "place_area_refs"

View File

@ -297,8 +297,8 @@ class LocalContentType(CodeEnum):
ATTRACTION = 3 # 관광지 (12) ATTRACTION = 3 # 관광지 (12)
RESTAURANT = 4 # 음식점 (39) RESTAURANT = 4 # 음식점 (39)
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
# ★ 지역 이야기(가요·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 — # ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 —
# 군산 이야기는 군산 숙소가 같이 쓴다. 섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는, # 군산 이야기는 군산 숙소가 같이 쓴다. 섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다. # 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
STORY = 6 STORY = 6
@ -315,8 +315,9 @@ AREA_KIND = {
LocalContentType.COURSE.value: "course", LocalContentType.COURSE.value: "course",
} }
# 지역 이야기 다섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다. # 지역 이야기 여섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
STORY_KINDS = ("songs", "people", "chronicle", "postcard", "quiz") # ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
STORY_KINDS = ("songs", "daily", "people", "chronicle", "postcard", "quiz")
class LocalSource(CodeEnum): class LocalSource(CodeEnum):

View File

@ -18,10 +18,17 @@ from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config from config.server_configs import main_db_config
# 모델이 쓰는 스키마. test DB 는 비어 있을 수 있어 create_all 전에 직접 만든다. # ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는
# 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다. # 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를
# 도메인 모듈(places/facts/sites/local/reports)이 붙을 때마다 스키마를 여기에 추가한다. # 얹지도 않는다.
_SCHEMAS = ("company", "place", "fact", "local", "site", "job") #
# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데,
# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로
# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도
# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다.
def _truncate_sql() -> str:
names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables)
return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE"
def _write_url(cfg) -> str: def _write_url(cfg) -> str:
@ -81,22 +88,11 @@ async def db_engine(_test_db_lifecycle):
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. " f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다." "APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
) )
engine = create_async_engine( engine = create_async_engine(_write_url(main_db_config))
_write_url(main_db_config),
connect_args={"server_settings": {"search_path": ",".join(_SCHEMAS) + ",public"}},
)
async with engine.begin() as conn: async with engine.begin() as conn:
for sch in _SCHEMAS:
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {sch}"))
await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute( await conn.execute(text(_truncate_sql()))
text(
"TRUNCATE TABLE users, places, place_aliases, place_links, units, media, "
"facts, faqs, local_contents, routes, nearby_links, "
"sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE"
)
)
yield engine yield engine
await engine.dispose() await engine.dispose()

View File

@ -42,40 +42,6 @@ class LocalContentCRUD:
async def end(self, db, content_id): async def end(self, db, content_id):
return await self.update(db, content_id, {"status": 3}) return await self.update(db, content_id, {"status": 3})
async def list_keyed(self, db, region_code: str, content_type: int):
"""지역 × 종류의 외부 ID 있는 행 전부(축제·관광지·맛집). 동기화가 기존값과 비교할 때 쓴다."""
return await DB_SESSION_MNG.execute(
db,
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.content_type == content_type,
area_contents.external_id.isnot(None),
area_contents.deleted == False, # noqa: E712
),
)
async def upsert_keyed(self, db, values: dict):
"""외부 ID 로 식별되는 행(축제·관광지·맛집)의 삽입/갱신.
uq_local_contents_keyed 부분 유니크 인덱스에 태운다 같은 지역을 동기화해도
중복 행이 생기지 않고 기존 값만 갱신된다."""
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.content_type, area_contents.external_id],
index_where=and_(area_contents.deleted == False, area_contents.external_id.isnot(None)), # noqa: E712
set_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
"source": stmt.excluded.source,
"status": stmt.excluded.status,
"collected_at": stmt.excluded.collected_at,
"display_end_at": stmt.excluded.display_end_at,
"published_at": stmt.excluded.published_at,
"updated_at": GTime.UTC(),
},
)
return await DB_SESSION_MNG.add(db, stmt)
async def upsert_kind(self, db, values: dict): async def upsert_kind(self, db, values: dict):
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.
@ -88,7 +54,14 @@ class LocalContentCRUD:
stmt = pg_insert(area_contents).values(**values) stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.kind], index_elements=[area_contents.region_code, area_contents.kind],
index_where=and_(area_contents.deleted == False, area_contents.kind.isnot(None)), # noqa: E712 # ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가
# 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching"
# 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다.
index_where=and_(
area_contents.deleted == False, # noqa: E712
area_contents.kind.isnot(None),
area_contents.external_id.is_(None),
),
set_={ set_={
"title": stmt.excluded.title, "title": stmt.excluded.title,
"body": stmt.excluded.body, "body": stmt.excluded.body,
@ -129,7 +102,13 @@ class LocalContentCRUD:
stmt = pg_insert(area_contents).values(**values) stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.content_type], index_elements=[area_contents.region_code, area_contents.content_type],
index_where=and_(area_contents.deleted == False, area_contents.external_id.is_(None)), # noqa: E712 # ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에
# 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.**
index_where=and_(
area_contents.deleted == False, # noqa: E712
area_contents.external_id.is_(None),
area_contents.kind.is_(None),
),
set_={ set_={
"source": stmt.excluded.source, "source": stmt.excluded.source,
"body": stmt.excluded.body, "body": stmt.excluded.body,

View File

@ -22,7 +22,7 @@
- 좌표 변환(coord_to_region) : 싸다. 사업장당 1회면 충분(좌표는 바뀐다) - 좌표 변환(coord_to_region) : 싸다. 사업장당 1회면 충분(좌표는 바뀐다)
- 카테고리 검색(search_category): 비싸다. **행정구역 코드 단위로 캐싱**해야 한다. - 카테고리 검색(search_category): 비싸다. **행정구역 코드 단위로 캐싱**해야 한다.
같은 지역에 사이트가 50 생겨도 조회는 1회여야 한다. 같은 지역에 사이트가 50 생겨도 조회는 1회여야 한다.
캐싱 자체는 local 모듈(local.local_contents, 캐시 = region_code) 책임진다 캐싱 자체는 지역 모듈(area_contents, 캐시 = region_code) 책임진다
클라이언트는 캐시를 두지 않는다. 호출 전에 캐시를 먼저 보라는 뜻이다. 클라이언트는 캐시를 두지 않는다. 호출 전에 캐시를 먼저 보라는 뜻이다.
호출 횟수는 전부 LOG.i 남긴다(비용 추적). _CALL_COUNTS 프로세스 누적도 있다. 호출 횟수는 전부 LOG.i 남긴다(비용 추적). _CALL_COUNTS 프로세스 누적도 있다.
@ -400,7 +400,7 @@ class KakaoLocalClient:
비싸다(초과 건당 2 좌표 변환의 4). **반드시 행정구역 코드 단위로 캐싱해서 부른다.** 비싸다(초과 건당 2 좌표 변환의 4). **반드시 행정구역 코드 단위로 캐싱해서 부른다.**
같은 지역에 사이트가 50 생겨도 호출은 1회여야 한다. 같은 지역에 사이트가 50 생겨도 호출은 1회여야 한다.
캐싱은 클라이언트가 하지 않는다 local 모듈이 local.local_contents 캐싱은 클라이언트가 하지 않는다 지역 모듈이 area_contents
`region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다.
`region_code` 인자는 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다). `region_code` 인자는 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다).
호출측이 region_code 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다. 호출측이 region_code 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다.

View File

@ -162,7 +162,7 @@ def _extract_place_id(link: Optional[str]) -> Optional[str]:
# ---- 지역 캐시 키 -------------------------------------------------------- # ---- 지역 캐시 키 --------------------------------------------------------
# ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다. # ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다.
# 이 키가 local.local_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다. # 이 키가 area_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다.
# #
# 시도 이름은 흔들린다(강원도 ↔ 강원특별자치도). 별칭을 전부 같은 코드로 모아야 # 시도 이름은 흔들린다(강원도 ↔ 강원특별자치도). 별칭을 전부 같은 코드로 모아야
# 같은 지역이 두 키로 갈리지 않는다 — 갈리면 캐시가 무의미해진다. # 같은 지역이 두 키로 갈리지 않는다 — 갈리면 캐시가 무의미해진다.
@ -441,7 +441,7 @@ class NaverLocalClient:
거리순이 아니고, 지역 안이라는 것만 보장된다. 거리순이 아니고, 지역 안이라는 것만 보장된다.
반드시 지역 캐시를 거쳐 부른다. 같은 지역에 사이트가 50 생겨도 조회는 1회여야 한다. 반드시 지역 캐시를 거쳐 부른다. 같은 지역에 사이트가 50 생겨도 조회는 1회여야 한다.
캐싱은 클라이언트가 하지 않는다 local 모듈이 local.local_contents 캐싱은 클라이언트가 하지 않는다 지역 모듈이 area_contents
`region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다. `region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다.
`region_key_hint` 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다).""" `region_key_hint` 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다)."""
if not region_key_hint: if not region_key_hint:

View File

@ -20,6 +20,7 @@ from common.logger import LOG
# 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다). # 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다).
_TITLE_KEY = { _TITLE_KEY = {
"songs": "title", "songs": "title",
"daily": "title",
"people": "name", "people": "name",
"chronicle": "title", "chronicle": "title",
"postcard": "line", "postcard": "line",

View File

@ -447,7 +447,9 @@ class LocalContentService:
if not perplexity.is_configured(): if not perplexity.is_configured():
return return
region_code = str(getattr(place, "region_code", None) or "").strip() region_code = str(getattr(place, "region_code", None) or "").strip()
if region_code and await story_service.has_stories(region_code): # ★ "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 종류가 있으면 돈다.**
# 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds).
if region_code and not await story_service.missing_kinds(region_code):
return return
await story_service.enqueue_region_job(place) await story_service.enqueue_region_job(place)

View File

@ -9,6 +9,13 @@
"task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", "task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n" "rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
}, },
"daily": {
"kind": "daily",
"label": "오늘의 한 장",
"maxItems": 30,
"task": "[해야 할 일]\n[지역]의 이야깃거리를 30개 찾아, 하루에 하나씩 뜯어 보는 일력용 JSON 으로 만든다.\n같은 주제를 두 번 쓰지 않는다. 계절이 맞는 날짜에 배치한다(축제는 실제 개최 시기에).\n\n[스키마]\n{ \"kind\":\"daily\", \"version\":1, \"title\":\"오늘의 한 장\", \"items\":[\n { \"monthDay\":\"09-02\", \"category\":\"역사|인물|장소|음식|축제|바다|문학\",\n \"title\":\"제목\", \"body\":\"두 문장 이내\", \"season\":\"봄|여름|가을|겨울\",\n \"tags\":[\"#태그\"], \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.\n· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.\n· 같은 장소를 계절만 바꿔 반복하지 않는다.\n"
},
"people": { "people": {
"kind": "people", "kind": "people",
"label": "인물 열전", "label": "인물 열전",

View File

@ -85,6 +85,28 @@ _DEFAULT_LOOK = {
"sectionSpace": "4rem", "sectionSpace": "4rem",
} }
# '옛 항구'(stay-retro)의 생김새. 프론트 `industryData.ts` 의 `LOOK.retro` 와 **같은 값이어야 한다.**
#
# ★ 왜 서버에도 두나 — 이 값은 프론트가 소유하지만, 백엔드는 TS 를 읽을 수 없고
# 저장값이 없는 사업장에는 이 표가 곧 발행본이다. 값이 없으면 `_DEFAULT_LOOK`(고딕·둥근
# 모서리)으로 떨어져 **색만 갱지고 서체는 고딕인** 페이지가 나간다 — 옛 항구가 아니게 된다.
# ★ `texture` 가 이 템플릿의 정체성이다(갱지 결). `_DEFAULT_LOOK` 에는 이 칸 자체가 없다.
_LOOK_RETRO = {
"fontHeading": "'Gugi', 'Noto Sans KR', sans-serif",
"fontBody": "'Gowun Batang', 'Noto Serif KR', serif",
"radius": "0px",
"borderWidth": "2px",
"shadow": "4px 4px 0 rgb(27 26 21 / 0.16)",
# 간판체는 자간을 벌리면 글자가 흩어지고, 굵기가 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다.
"headingTracking": "0em",
"headingWeight": "400",
"sectionSpace": "4rem",
"texture": (
"repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),"
"repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)"
),
}
# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다. # 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다.
# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다: # ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다:
@ -101,17 +123,28 @@ _DEFAULT_LOOK = {
# locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다. # locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다.
_DEFAULT_THEME = { _DEFAULT_THEME = {
PlaceCategory.LODGING.value: { PlaceCategory.LODGING.value: {
"templateId": "stay-o2o-editorial", # ★ 숙박의 기본 템플릿은 '옛 항구'(stay-retro)다 (2026-09-10, 사장님 지시).
"fontStyle": "Modern Editorial", # 예전 값 "stay-o2o-editorial" 은 **어느 목록에도 없는 id** 였다 — 프론트가 가진
"colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff", # 숙박 템플릿은 stay-simple · stay-magazine · stay-retro 셋뿐이라, 이 값이 실린
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"}, # 발행본은 에디터로 돌아왔을 때 고른 칩이 하나도 안 맞아 늘 첫 템플릿으로 그려졌다.
"templateId": "stay-retro",
"fontStyle": "옛 간판체",
# 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2.
# 주(朱) 잉크 accent 는 레트로의 정체성이라 업종 accent 로 갈아끼우지 않는다.
"colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0",
"card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"},
"look": _LOOK_RETRO,
# ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은 # ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
# ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 # ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
# ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라
# ([+ 섹션 추가] 의 '뒤집어 보는 질문'), 켜지 않으면 지역 생성분이 어디에도 안 선다.
# 기본으로 켜지 않는 것은 의도다: 손님이 예약하러 온 화면에 퀴즈를 기본값으로
# 세우지 않는다. 넣고 싶은 사장님이 직접 넣는다.
"sections": [ "sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("event", "소식", False), ("event", "소식", False),
@ -353,7 +386,8 @@ def _theme(site, theme_spec: dict) -> dict:
if isinstance(look, dict) if isinstance(look, dict)
else {} else {}
) )
out["look"] = {**_DEFAULT_LOOK, **cleaned} # 업종 기본 look 이 있으면 그것을 바닥에 깐다(숙박 = 옛 항구). 없으면 공통 폴백이다.
out["look"] = {**theme_spec.get("look", _DEFAULT_LOOK), **cleaned}
return out return out
@ -479,6 +513,15 @@ def _yyyymmdd(value) -> str:
return digits if len(digits) == 8 else "" return digits if len(digits) == 8 else ""
# 시작 월 → 계절. 경계는 기상학 기준(3·6·9·12월 시작)이다 — 축제는 "몇 월에 가나"로 찾는다.
_SEASON_BY_MONTH = {
3: "", 4: "", 5: "",
6: "여름", 7: "여름", 8: "여름",
9: "가을", 10: "가을", 11: "가을",
12: "겨울", 1: "겨울", 2: "겨울",
}
def _festival(row: dict): def _festival(row: dict):
"""FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다.""" """FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다."""
body = row.get("body") or {} body = row.get("body") or {}
@ -504,6 +547,17 @@ def _festival(row: dict):
# 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙). # 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙).
"searchQuery": name, "searchQuery": name,
} }
if start:
# ★ 계절은 **여기서 한 번만** 정한다 (실측 2026-09-10)
# `FestivalEntry.season` 계약이 "시작일에서 한 번만 정해 payload 에 싣는다" 인데
# 아무도 안 실었다 — 그 결과 발행된 모든 사이트에서 계절 탭이 0개였다(군산·성남 모두
# 20건 전부 빈 값). 화면은 계절이 있는 것만 탭으로 세우므로, 축제가 스무 건 있어도
# "계절 없이 열리는 행사" 한 덩이로 쏟아졌다. `/s/stay` 시안에 4탭이 서 있는 건
# 그 payload 의 계절을 손으로 넣었기 때문이다.
# 렌더러에서 월을 계절로 되돌리지 않는다 — 계약 주석이 금지한 자리다(수집과 갈라진다).
entry["season"] = _SEASON_BY_MONTH[int(start[4:6])]
# 정렬·계절 산출의 근거를 기계가 읽는 형식으로도 남긴다(`period` 는 사람이 읽는 문구다).
entry["startDate"] = f"{start[:4]}-{start[4:6]}-{start[6:]}"
if period: if period:
entry["period"] = period entry["period"] = period
location = _text(body.get("location")) location = _text(body.get("location"))

View File

@ -127,13 +127,25 @@ async def generate_region_stories(region_code: str, region_label: str, kinds: li
return {"region_code": region_code, "counts": result, "notes": notes} return {"region_code": region_code, "counts": result, "notes": notes}
async def has_stories(region_code: str) -> bool: async def missing_kinds(region_code: str) -> list[str]:
"""이 지역에 이미 이야기가 있나. cache-aside 판단용 — 한 건이라도 있으면 다시 부르지 않는다.""" """이 지역에 아직 없는 이야기 종류. cache-aside 판단용.
예전엔 `has_stories` 하나였다 ** 건이라도 있으면** 다시 부르지 않았다.
가드는 "같은 지역 두 번째 숙소" 생각한 것이라, **종류가 늘어난 ** 정확히 반대로
동작한다: 이미 다섯이 들어 있는 지역은 여섯 번째(`daily`) 영영 받는다.
지역에서만 여섯이 채워지고 기존 지역은 다섯에 멈춰, 같은 템플릿을 골라도 지역에
따라 수가 다른 상태가 된다(실측 2026-09-10, 52군산시).
요금 가드는 그대로다 없는 종류만 부른다. 이미 있는 종류는 여전히 번도 다시 부른다.
"""
err, rows = await DB_SESSION_MNG.execute_lambda( err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: crud_list(s, region_code), lambda s: crud_list(s, region_code),
) )
return err == ErrorType.SUCCESS and bool(rows) if err != ErrorType.SUCCESS:
# 읽지 못했으면 "없다"고 단정하지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다.
return []
have = {str(getattr(row, "kind", "") or "") for row in rows}
return [kind for kind in prompts.kinds() if kind not in have]
async def crud_list(session, region_code: str): async def crud_list(session, region_code: str):
@ -185,12 +197,14 @@ async def run_local_sync(job: dict) -> dict:
if not perplexity.is_configured(): if not perplexity.is_configured():
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"} out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
return out return out
if await has_stories(region_code): # ★ 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). 아니면 **없는 것만** 채운다 —
# ★ 같은 지역 두 번째 숙소다. 다시 부르면 같은 답에 요금만 두 번 낸다. # 같은 지역 두 번째 숙소는 부를 것이 없어 곧바로 빠져나간다.
wanted = payload.get("kinds") or await missing_kinds(region_code)
if not wanted:
out["stories"] = {"skipped": "이미 있다"} out["stories"] = {"skipped": "이미 있다"}
return out return out
out["stories"] = await generate_region_stories(region_code, region_label, payload.get("kinds")) out["stories"] = await generate_region_stories(region_code, region_label, wanted)
return out return out
@ -213,7 +227,7 @@ async def enqueue_region_job(place) -> str | None:
"""업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다. """업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다.
dedupe **업장 단위**(`local:{place_id}`). 반경 수집이 업장마다 필요해서다 dedupe **업장 단위**(`local:{place_id}`). 반경 수집이 업장마다 필요해서다
지역 이야기의 중복 호출은 안의 `has_stories` 가드가 막는다. 지역 이야기의 중복 호출은 안의 `missing_kinds` 가드가 막는다.
부르는 곳이 둘이다: 수집 완료 직후(collect_service) 위저드의 생성 단계(place_service). 부르는 곳이 둘이다: 수집 완료 직후(collect_service) 위저드의 생성 단계(place_service).
먼저 넣은 잡이 아직 살아 있으면 enqueue_job id 돌려준다 위저드는 그걸 기다린다. 먼저 넣은 잡이 아직 살아 있으면 enqueue_job id 돌려준다 위저드는 그걸 기다린다.
""" """

View File

@ -59,7 +59,7 @@ async def _verified_facts(client, h, pid, extra=None):
async def _approved_media(db_engine, pid, alt="2층 목조 건물 외관"): async def _approved_media(db_engine, pid, alt="2층 목조 건물 외관"):
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) "
"VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"), "VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"),
{"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg", {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg",
"s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": alt, "l": "외관"}, "s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": alt, "l": "외관"},
@ -366,7 +366,7 @@ async def test_렌더_보고서의_값이_그대로_박제된다(auth_headers, c
async with db_engine.begin() as c: async with db_engine.begin() as c:
row = (await c.execute( row = (await c.execute(
text("SELECT jsonld, unique_content_count FROM site.site_versions WHERE site_version_id = :v"), text("SELECT jsonld, unique_content_count FROM site_versions WHERE site_version_id = :v"),
{"v": uuid.UUID(r["site_version_id"])}, {"v": uuid.UUID(r["site_version_id"])},
)).mappings().first() )).mappings().first()

View File

@ -29,9 +29,20 @@ async def _ready_place(client, h, category=PlaceCategory.LODGING, kakao="k1"):
async def _run_worker(job_id=None): async def _run_worker(job_id=None):
"""워커 1틱 — 큐에서 잡을 집어 실제 파이프라인을 돌린다.""" """워커를 돌려 큐를 비운다. `job_id` 를 주면 그 잡이 끝날 때까지 돈다.
1틱만 돌리면 된다. 수집이 끝나면 **지역 이야기 (LOCAL_SYNC) 뒤따라 들어온다**
틱은 그걸 집어 가고, 정작 기다리던 수집 잡은 PENDING 채로 남는다. 테스트는
`job["result"]` 읽다가 KeyError 죽는데, 화면에는 "그냥 안 끝난 것" 으로 보인다.
큐에 뒤따르는 잡이 생길 때마다 헬퍼가 조용히 어긋나므로 개수를 세지 않고 비운다.
"""
worker = Worker("test-worker", JobQueue(), build_handler(), job_deadline_sec=60) worker = Worker("test-worker", JobQueue(), build_handler(), job_deadline_sec=60)
assert await worker.process_one() is True, "워커가 집을 잡이 없다" ran = 0
for _ in range(10):
if not await worker.process_one():
break
ran += 1
assert ran, "워커가 집을 잡이 없다"
async def test_pipeline_stores_facts_as_candidates(auth_headers, client): async def test_pipeline_stores_facts_as_candidates(auth_headers, client):

View File

@ -50,7 +50,7 @@ async def _place_with_facts(client, h, n_verified=5):
async def _faq_rows(db_engine, pid): async def _faq_rows(db_engine, pid):
async with db_engine.begin() as c: async with db_engine.begin() as c:
return (await c.execute( return (await c.execute(
text("SELECT question, answer, source_fact_ids, status FROM faqs WHERE place_id = :p ORDER BY sort_order"), text("SELECT question, answer, source_fact_ids, status FROM place_faqs WHERE place_id = :p ORDER BY sort_order"),
{"p": uuid.UUID(pid)}, {"p": uuid.UUID(pid)},
)).all() )).all()
@ -154,7 +154,7 @@ async def test_regeneration_keeps_human_approved_faq(auth_headers, client, db_en
# 사람이 승인 # 사람이 승인
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("UPDATE faqs SET status = :s WHERE place_id = :p"), text("UPDATE place_faqs SET status = :s WHERE place_id = :p"),
{"s": FactStatus.VERIFIED.value, "p": uuid.UUID(pid)}, {"s": FactStatus.VERIFIED.value, "p": uuid.UUID(pid)},
) )

View File

@ -44,7 +44,7 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None):
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text( text(
"INSERT INTO facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " "INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) "
"VALUES (:fid, :pid, :uid, :key, :val, :src, :status, now())" "VALUES (:fid, :pid, :uid, :key, :val, :src, :status, now())"
), ),
{ {
@ -74,7 +74,7 @@ async def test_candidates_coexist_with_published_value(db_engine, owner_id):
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
rows = (await conn.execute( rows = (await conn.execute(
text("SELECT status FROM facts WHERE place_id = :pid AND key = 'check_in_time'"), text("SELECT status FROM place_facts WHERE place_id = :pid AND key = 'check_in_time'"),
{"pid": uuid.UUID(place_id)}, {"pid": uuid.UUID(place_id)},
)).all() )).all()
assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다" assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다"
@ -89,7 +89,7 @@ async def test_rejected_fact_frees_the_key(db_engine, owner_id):
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
rows = (await conn.execute( rows = (await conn.execute(
text("SELECT value, status FROM facts WHERE place_id = :pid ORDER BY status"), text("SELECT value, status FROM place_facts WHERE place_id = :pid ORDER BY status"),
{"pid": uuid.UUID(place_id)}, {"pid": uuid.UUID(place_id)},
)).all() )).all()
assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다" assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다"
@ -111,7 +111,7 @@ async def test_same_key_allowed_across_units(db_engine, owner_id):
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")): for uid, name in ((unit_a, "A동"), (unit_b, "B동")):
await conn.execute( await conn.execute(
text("INSERT INTO units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), text("INSERT INTO place_units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"),
{"uid": uid, "pid": uuid.UUID(place_id), "name": name}, {"uid": uid, "pid": uuid.UUID(place_id), "name": name},
) )
@ -129,7 +129,7 @@ async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id):
unit_id = uuid.uuid4() unit_id = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"), text("INSERT INTO place_units (unit_id, place_id, name) VALUES (:uid, :pid, :name)"),
{"uid": unit_id, "pid": uuid.UUID(place_id), "name": "A동"}, {"uid": unit_id, "pid": uuid.UUID(place_id), "name": "A동"},
) )
await _insert_fact(db_engine, place_id, "has_kitchen", "false", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "has_kitchen", "false", FactStatus.VERIFIED)

View File

@ -23,7 +23,7 @@ async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인
fid = uuid.uuid4() fid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO faqs (faq_id, place_id, question, answer, source_fact_ids, generated_by, status, sort_order) " text("INSERT INTO place_faqs (faq_id, place_id, question, answer, source_fact_ids, generated_by, status, sort_order) "
"VALUES (:f, :p, :q, :a, CAST(:k AS jsonb), :g, :st, :o)"), "VALUES (:f, :p, :q, :a, CAST(:k AS jsonb), :g, :st, :o)"),
{"f": fid, "p": uuid.UUID(pid), "q": question, "a": answer, "k": '["check_in_time"]', {"f": fid, "p": uuid.UUID(pid), "q": question, "a": answer, "k": '["check_in_time"]',
"g": SourceType.LLM.value, "st": FactStatus.UNVERIFIED.value, "o": order}, "g": SourceType.LLM.value, "st": FactStatus.UNVERIFIED.value, "o": order},

View File

@ -36,6 +36,22 @@ def _parse_init_sql() -> dict:
return out return out
# init.sql 에는 있고 ORM 모델은 없는 표. 마이그레이션 대장은 scripts/migrate.py 가 소유하고
# 애플리케이션 코드가 읽지 않는다 — 모델을 만들면 도메인 표처럼 보인다.
_NOT_ORM = {"public.schema_migrations"}
def _model_tables() -> set:
"""ORM 모델 → {"public.table", ...}
모델은 스키마를 적지 않는다(`Table.schema is None`) 2026-09-09 도메인 스키마를
걷어내고 public 벌로 폈기 때문이다(migrations/0005). init.sql `public.`
명시하므로 여기서 같은 모양으로 맞춰 준다. `t.schema` 그대로 쓰면 "None.users"
되어 **모든 표가 누락으로 잡힌다** 실제로 그렇게 테스트가 통째로 빨개졌다.
"""
return {f"{t.schema or 'public'}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables}
def test_init_sql_is_readable(): def test_init_sql_is_readable():
"""검증: init.sql 을 찾고 파싱할 수 있는지. """검증: init.sql 을 찾고 파싱할 수 있는지.
기대결과: 파일이 존재하고 CREATE TABLE 1 이상 파싱된다.""" 기대결과: 파일이 존재하고 CREATE TABLE 1 이상 파싱된다."""
@ -47,7 +63,7 @@ def test_every_model_table_exists_in_init_sql():
"""검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지. """검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지.
기대결과: 누락 없음 모델만 추가하고 마이그레이션을 경우를 잡는다.""" 기대결과: 누락 없음 모델만 추가하고 마이그레이션을 경우를 잡는다."""
sql_tables = set(_parse_init_sql()) sql_tables = set(_parse_init_sql())
model_tables = {f"{t.schema}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} model_tables = _model_tables()
missing = sorted(model_tables - sql_tables) missing = sorted(model_tables - sql_tables)
assert not missing, f"init.sql 에 없는 모델 테이블: {missing}" assert not missing, f"init.sql 에 없는 모델 테이블: {missing}"
@ -55,8 +71,8 @@ def test_every_model_table_exists_in_init_sql():
def test_every_init_sql_table_has_a_model(): def test_every_init_sql_table_has_a_model():
"""검증: init.sql 의 모든 테이블에 ORM 모델이 있는지. """검증: init.sql 의 모든 테이블에 ORM 모델이 있는지.
기대결과: 누락 없음 마이그레이션만 쓰고 모델을 만든 경우를 잡는다.""" 기대결과: 누락 없음 마이그레이션만 쓰고 모델을 만든 경우를 잡는다."""
sql_tables = set(_parse_init_sql()) sql_tables = set(_parse_init_sql()) - _NOT_ORM
model_tables = {f"{t.schema}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables} model_tables = _model_tables()
missing = sorted(sql_tables - model_tables) missing = sorted(sql_tables - model_tables)
assert not missing, f"ORM 모델이 없는 init.sql 테이블: {missing}" assert not missing, f"ORM 모델이 없는 init.sql 테이블: {missing}"
@ -66,10 +82,12 @@ def test_columns_match_between_model_and_init_sql():
기대결과: 완전 일치 한쪽에만 추가된 컬럼을 잡는다.""" 기대결과: 완전 일치 한쪽에만 추가된 컬럼을 잡는다."""
sql_tables = _parse_init_sql() sql_tables = _parse_init_sql()
problems = [] problems = []
compared = 0
for table in MAIN_BASE.metadata.sorted_tables: for table in MAIN_BASE.metadata.sorted_tables:
name = f"{table.schema}.{table.name}" name = f"{table.schema or 'public'}.{table.name}"
if name not in sql_tables: if name not in sql_tables:
continue continue
compared += 1
model_cols = {c.name for c in table.columns} model_cols = {c.name for c in table.columns}
sql_cols = sql_tables[name] sql_cols = sql_tables[name]
if model_cols != sql_cols: if model_cols != sql_cols:
@ -77,6 +95,11 @@ def test_columns_match_between_model_and_init_sql():
f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}" f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}"
) )
assert not problems, "컬럼 불일치:\n" + "\n".join(problems) assert not problems, "컬럼 불일치:\n" + "\n".join(problems)
# ★ 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. 실제로 그랬다 —
# 이름을 "None.users" 로 만들어 전부 건너뛰었고, 그동안 init.sql 이 조용히 어긋났다.
assert compared == len(MAIN_BASE.metadata.sorted_tables), (
f"init.sql 에서 {compared}/{len(MAIN_BASE.metadata.sorted_tables)} 개만 찾았다 — 이름 규칙이 어긋났다"
)
def test_every_table_has_soft_delete_columns(): def test_every_table_has_soft_delete_columns():

View File

@ -25,7 +25,7 @@ async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING):
async def _fact(db_engine, pid, key, value, status, unit_id=None): async def _fact(db_engine, pid, key, value, status, unit_id=None):
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) " text("INSERT INTO place_facts (fact_id, place_id, unit_id, key, value, source_type, status, collected_at) "
"VALUES (:f,:p,:u,:k,:v,:s,:st,now())"), "VALUES (:f,:p,:u,:k,:v,:s,:st,now())"),
{"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value, {"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": value,
"s": SourceType.OWNER.value, "st": status.value}, "s": SourceType.OWNER.value, "st": status.value},
@ -35,7 +35,7 @@ async def _fact(db_engine, pid, key, value, status, unit_id=None):
async def _media(db_engine, pid, url, status, alt="설명"): async def _media(db_engine, pid, url, status, alt="설명"):
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) " text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, sort_order) "
"VALUES (:m,:p,:u,:u,:s,:st,:a,0)"), "VALUES (:m,:p,:u,:u,:s,:st,:a,0)"),
{"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt}, {"m": uuid.uuid4(), "p": pid, "u": url, "s": SourceType.CRAWL.value, "st": status.value, "a": alt},
) )
@ -110,7 +110,7 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id):
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), await c.execute(text("INSERT INTO place_units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
{"u": uid, "p": pid, "n": "A동"}) {"u": uid, "p": pid, "n": "A동"})
await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid) await _fact(db_engine, pid, "max_capacity", "4", FactStatus.VERIFIED, unit_id=uid)
@ -133,17 +133,27 @@ async def test_empty_place_gives_empty_snapshot(db_engine, owner_id):
# ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다. # ★ 지역 정보가 스냅샷에 담기는 이유: site_payload 는 DB 를 다시 읽지 않는다.
# 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다. # 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다.
async def _local(db_engine, region_code, content_type, status, title="지역행사", **cols): async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols):
from common.enums import LocalSource """지역 캐시(`area_contents`)에 **지역 단위** 항목 한 행.
`external_id` 넣지 않는다. 값이 있는 (축제·관광지·맛집) 업장마다 거리가 달라
개인화(`site_sections`) 거쳐 들어오고, 스냅샷의 지역 캐시 경로는 그것들을 일부러
건너뛴다(`services/snapshot._local_contents`). 예전 픽스처는 uuid external_id
넣고 있어서, **읽는 코드가 옳게 걸러내는데도 테스트가 빨개졌다**.
지역에 같은 kind 행이다(`uq_local_contents_kind`). 여러 행이 필요한 테스트는
kind 달리 준다 지역 이야기 다섯 종이 자리다.
"""
from common.enums import LocalContentType, LocalSource
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("INSERT INTO local_contents " text("INSERT INTO area_contents "
"(local_content_id, region_code, content_type, source, external_id, title, body, status, " "(local_content_id, region_code, content_type, source, title, body, status, kind, "
" display_start_at, display_end_at, collected_at) " " display_start_at, display_end_at, collected_at) "
"VALUES (:i,:r,:ct,:src,:ext,:t,cast(:b as jsonb),:st,:ds,:de,now())"), "VALUES (:i,:r,:ct,:src,:t,cast(:b as jsonb),:st,:k,:ds,:de,now())"),
{"i": uuid.uuid4(), "r": region_code, "ct": content_type.value, "src": LocalSource.TOUR_API.value, {"i": uuid.uuid4(), "r": region_code, "ct": LocalContentType.STORY.value,
"ext": uuid.uuid4().hex, "t": title, "b": '{"title":"%s"}' % title, "st": status.value, "src": LocalSource.LLM.value, "t": title,
"b": '{"title":"%s"}' % title, "st": status.value, "k": kind,
"ds": cols.get("display_start_at"), "de": cols.get("display_end_at")}, "ds": cols.get("display_start_at"), "de": cols.get("display_end_at")},
) )
@ -160,15 +170,16 @@ async def test_only_published_local_content_enters_snapshot(db_engine, owner_id)
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: PUBLISHED 담긴다 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 기대결과: PUBLISHED 담긴다 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지' 깨진다(fact VERIFIED 거르는 것과 같은 규칙).""" '미검증 값 노출 금지' 깨진다(fact VERIFIED 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제") # kind 를 달리 준다 — 한 지역에 같은 kind 는 한 행이다(uq_local_contents_kind).
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "발행이야기")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") await _local(db_engine, "4113500", "people", LocalContentStatus.REVIEW, "검수대기이야기")
await _local(db_engine, "4113500", "chronicle", LocalContentStatus.ENDED, "종료이야기")
snap = await build_snapshot(_RegionPlace(pid, "4113500")) snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["발행이야기"]
async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id):
@ -176,56 +187,56 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, owner
기대결과: 빠진다 끝난 축제를 '이번 주말 행사' 걸어두는 것도 틀린 정보다.""" 기대결과: 빠진다 끝난 축제를 '이번 주말 행사' 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제") await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "지금이야기")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제", await _local(db_engine, "4113500", "people", LocalContentStatus.PUBLISHED, "끝난이야기",
display_end_at=now - timedelta(days=1)) display_end_at=now - timedelta(days=1))
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "아직축제", await _local(db_engine, "4113500", "chronicle", LocalContentStatus.PUBLISHED, "아직이야기",
display_start_at=now + timedelta(days=1)) display_start_at=now + timedelta(days=1))
snap = await build_snapshot(_RegionPlace(pid, "4113500")) snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["지금이야기"]
async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id):
"""검증: 지역 캐시는 region_code 로 묶인다. """검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제") await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "우리지역이야기")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제") await _local(db_engine, "5011025", "songs", LocalContentStatus.PUBLISHED, "남의지역이야기")
snap = await build_snapshot(_RegionPlace(pid, "4113500")) snap = await build_snapshot(_RegionPlace(pid, "4113500"))
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역이야기"]
async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 지역 콘텐츠를 담는다 places.region_code 채우는 기대결과: 주소에서 지역 키를 유도해 지역 콘텐츠를 담는다 places.region_code 채우는
코드가 생기기 전에 만들어진 사업장(실측 28 25) 영영 지역 정보 없이 발행되지 않게 한다.""" 코드가 생기기 전에 만들어진 사업장(실측 28 25) 영영 지역 정보 없이 발행되지 않게 한다."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus
from services.external.naver import region_key from services.external.naver import region_key
derived = region_key(_Place("x").road_address) derived = region_key(_Place("x").road_address)
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") await _local(db_engine, derived, "songs", LocalContentStatus.PUBLISHED, "양양이야기")
snap = await build_snapshot(_RegionPlace(pid, "")) snap = await build_snapshot(_RegionPlace(pid, ""))
assert snap["local"]["region_code"] == derived assert snap["local"]["region_code"] == derived
assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] assert [c["title"] for c in snap["local"]["contents"]] == ["양양이야기"]
async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장. """검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 목록 조회할 캐시 키가 없다. 지어내지 않는다.""" 기대결과: 목록 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id) pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "어딘가이야기")
place = _RegionPlace(pid, "") place = _RegionPlace(pid, "")
place.road_address = None place.road_address = None

View File

@ -7,7 +7,7 @@ import os
os.environ.setdefault("APP_ENV", "test") os.environ.setdefault("APP_ENV", "test")
from common.enums import LocalContentType # noqa: E402 from common.enums import STORY_KINDS, LocalContentType # noqa: E402
from services.grounding import story as grounding # noqa: E402 from services.grounding import story as grounding # noqa: E402
from services.prompts import story as prompts # noqa: E402 from services.prompts import story as prompts # noqa: E402
from services.site_payload import _local # noqa: E402 from services.site_payload import _local # noqa: E402
@ -22,8 +22,8 @@ def _reply(content: str, search_results=None) -> dict:
# ── 프롬프트 ──────────────────────────────────────────────────────────── # ── 프롬프트 ────────────────────────────────────────────────────────────
def test_프롬프트는_shared_산출물에서_온다(): def test_프롬프트는_shared_산출물에서_온다():
"""섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다.""" """섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
assert set(prompts.kinds()) == {"songs", "people", "chronicle", "postcard", "quiz"} assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "postcard", "quiz"}
text = prompts.build_prompt("songs", "전북 군산시") text = prompts.build_prompt("songs", "전북 군산시")
assert "[지역] 전북 군산시" in text assert "[지역] 전북 군산시" in text
assert "[지역]을 노래한 대중가요" in text # task 원문 assert "[지역]을 노래한 대중가요" in text # task 원문
@ -37,6 +37,25 @@ def test_지역_생성은_업소를_가리키지_않는다():
assert "업소가 지정되지 않았으므로" in prompts.build_prompt("songs", "전북 군산시") assert "업소가 지정되지 않았으므로" in prompts.build_prompt("songs", "전북 군산시")
def test_생성_목록과_읽는_목록이_같다():
"""산출물(section_prompts.json) · 파이썬 상수 · 항목 이름칸 셋이 어긋나면 조용히 틀린다.
`daily` 실제로 이렇게 빠져 있었다 프롬프트가 빌더에만 손으로 적혀 있어서
서버는 종류를 몰랐고, 렌더러의 자리는 영영 빈칸이었다(2026-09-10).
"""
assert tuple(prompts.kinds()) == STORY_KINDS
assert set(grounding._TITLE_KEY) == set(STORY_KINDS)
def test_일력_프롬프트가_붙는다():
"""'옛 항구' 설명이 약속한 일력 — MM-DD 스키마와 연도 금지 규칙까지 실려야 한다."""
text = prompts.build_prompt("daily", "전북 군산시")
assert "[지역] 전북 군산시" in text
assert '"kind":"daily"' in text
assert "연도를 넣지 않는다" in text
assert prompts.max_items("daily") == 30
# ── 응답 해석 ──────────────────────────────────────────────────────────── # ── 응답 해석 ────────────────────────────────────────────────────────────
def test_출처가_없으면_항목을_버린다(): def test_출처가_없으면_항목을_버린다():
payload = _reply('{"kind":"songs","items":[{"title":"금강 나그네"}]}') payload = _reply('{"kind":"songs","items":[{"title":"금강 나그네"}]}')

View File

@ -107,13 +107,23 @@ def test_lodging_place_facts():
def test_lodging_drops_fields_without_schema_slot(): def test_lodging_drops_fields_without_schema_slot():
"""검증: 스키마에 자리가 없는 TourAPI 필드(roomcount·scalelodging·foodplace). """검증: 스키마에 자리가 없는 TourAPI 필드(foodplace).
기대결과: 아무 key 로도 들어오지 않는다. 억지로 다른 key 넣으면 기대결과: 아무 key 로도 들어오지 않는다. 억지로 다른 key 넣으면
'식음료장 있음' '조식 제공' 으로 둔갑한다.""" '식음료장 있음' '조식 제공' 으로 둔갑한다.
values = {v for v in _map(ADAPTER._lodging_facts(_LODGING_INTRO)).values()}
assert "13실" not in values and "있음" not in values roomcount·scalelodging 여기서 빠져 있었다. 2026-09-07 lodging 스키마에
assert not any("11,570" in str(v) for v in values) 자리가 생겨서(total_rooms·building_scale) 이제 **버리지 않는다** 자리가 없어
버려지던 값이 TourAPI 응답의 절반이었다(실측 오블로모프 3103191).
"자리가 없으면 버린다" 규칙은 그대로고, 자리가 생겼을 뿐이다."""
facts = _map(ADAPTER._lodging_facts(_LODGING_INTRO))
values = set(facts.values())
assert "있음" not in values, "foodplace 는 자리가 없다 — 버려야 한다"
# 원문 그대로가 아니라 스키마가 정한 모양으로 들어간다.
assert facts[("total_rooms", None)] == "13", "'13실' 에서 숫자만 뽑는다(unit 은 스키마가 안다)"
assert facts[("building_scale", None)] == "대지 면적 11,570㎡", (
"규모는 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다"
)
def test_room_facts_are_unit_scoped_and_use_square_meters(): def test_room_facts_are_unit_scoped_and_use_square_meters():

View File

@ -23,7 +23,7 @@ async def _place_with_media(client, h, db_engine, n=3, kakao="v1"):
async with db_engine.begin() as c: async with db_engine.begin() as c:
for i in range(n): for i in range(n):
await c.execute( await c.execute(
text("INSERT INTO media (media_id, place_id, url, origin_url, source_type, status, sort_order) " text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, sort_order) "
"VALUES (:m, :p, :u, :u, :s, :st, :o)"), "VALUES (:m, :p, :u, :u, :s, :st, :o)"),
{"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{pid}/{i}.jpg", {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{pid}/{i}.jpg",
"s": SourceType.CRAWL.value, "st": MediaStatus.PENDING_REVIEW.value, "o": i}, "s": SourceType.CRAWL.value, "st": MediaStatus.PENDING_REVIEW.value, "o": i},
@ -34,7 +34,7 @@ async def _place_with_media(client, h, db_engine, n=3, kakao="v1"):
async def _media_rows(db_engine, pid): async def _media_rows(db_engine, pid):
async with db_engine.begin() as c: async with db_engine.begin() as c:
return (await c.execute( return (await c.execute(
text("SELECT origin_url, label, alt_text, vision_confidence, status FROM media " text("SELECT origin_url, label, alt_text, vision_confidence, status FROM place_photos "
"WHERE place_id = :p ORDER BY sort_order"), "WHERE place_id = :p ORDER BY sort_order"),
{"p": uuid.UUID(pid)}, {"p": uuid.UUID(pid)},
)).all() )).all()

View File

@ -42,7 +42,7 @@ async def test_weather_returns_stale_cache_when_upstream_fails(client, db_engine
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
from sqlalchemy import text from sqlalchemy import text
await conn.execute(text("UPDATE local.local_contents SET expires_at = :expired"), { await conn.execute(text("UPDATE area_contents SET expires_at = :expired"), {
"expired": datetime.now(timezone.utc) - timedelta(minutes=1) "expired": datetime.now(timezone.utc) - timedelta(minutes=1)
}) })

View File

@ -54,9 +54,28 @@ const LOOK = {
function templatesFor( function templatesFor(
industryId: IndustryType, industryId: IndustryType,
accent: string, accent: string,
retro: {name: string; description: string}, /**
* · , ** .**
* ( ) '노포'
* · .
*/
retro: {
name: string;
description: string;
disabledSectionTypes?: string[];
defaultVariants?: Record<string, string>;
/**
* ** 릿** .
*
* . templateId ** 릿**
* (`stores/builder.ts`). "기본을 옛 항구로" .
* `site_payload._DEFAULT_THEME` templateId ** .**
* 릿 .
*/
isDefault?: boolean;
},
): TemplateItem[] { ): TemplateItem[] {
return [ const items: TemplateItem[] = [
{ {
id: `${industryId}-simple`, id: `${industryId}-simple`,
industryId, industryId,
@ -123,8 +142,13 @@ function templatesFor(
* . . * . .
*/ */
defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'], defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'],
...(retro.disabledSectionTypes ? {disabledSectionTypes: retro.disabledSectionTypes} : {}),
...(retro.defaultVariants ? {defaultVariants: retro.defaultVariants} : {}),
}, },
]; ];
// 기본 템플릿을 맨 앞으로. 나머지 순서는 그대로 둔다.
return retro.isDefault ? [items[2], items[0], items[1]] : items;
} }
@ -161,6 +185,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
templates: templatesFor('stay', '#2563eb', { templates: templatesFor('stay', '#2563eb', {
name: '옛 항구', name: '옛 항구',
description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
// 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다.
isDefault: true,
/** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */
defaultVariants: {photos: 'photos.carousel'},
}), }),
}, },

View File

@ -216,21 +216,8 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
null, null,
2, 2,
), ),
task: `[해야 할 일] task: SECTION_PROMPTS.daily.task,
[] 30 , JSON . rules: SECTION_PROMPTS.daily.rules,
. ( ).
[]
{ "kind":"daily", "version":1, "title":"오늘의 한 장", "items":[
{ "monthDay":"09-02", "category":"역사|인물|장소|음식|축제|바다|문학",
"title":"제목", "body":"두 문장 이내", "season":"봄|여름|가을|겨울",
"tags":["#태그"], "verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[ ]
· monthDay MM-DD . .
· 30 . 12 12 .
· .`,
fields: [ fields: [
{key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', half: true}, {key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', half: true},
{key: 'category', label: '분류', half: true}, {key: 'category', label: '분류', half: true},

View File

@ -474,24 +474,37 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
* *
* . 릿 * . 릿
* 릿 . * 릿 .
* `disabledSectionTypes` ** .**
* . (`/s/stay`)
* , 릿 "이 섹션은 안 쓴다" .
* . · · SEO
* 릿 .
* ** ** . 릿
* ( ).
*/ */
selectTemplate: (templateId) => { selectTemplate: (templateId) => {
set((state) => { set((state) => {
const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId); const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId);
const wanted = template?.defaultSectionTypes ?? []; const wanted = template?.defaultSectionTypes ?? [];
const turnOff = template?.disabledSectionTypes ?? [];
const variants = template?.defaultVariants ?? {};
const added = wanted const added = wanted
.filter((type) => !state.sections.some((sec) => sec.type === type)) .filter((type) => !state.sections.some((sec) => sec.type === type))
.map(newSectionOf) .map(newSectionOf)
.filter((sec): sec is SectionItem => sec !== undefined); .filter((sec): sec is SectionItem => sec !== undefined);
const apply = (sec: SectionItem): SectionItem => {
const variantId = sec.variantId ?? variants[sec.type];
const isEnabled = wanted.includes(sec.type)
? true
: turnOff.includes(sec.type) && !sec.isLocked
? false
: sec.isEnabled;
return {...sec, isEnabled, ...(variantId ? {variantId} : {})};
};
return { return {
templateId, templateId,
colorPaletteId: null, colorPaletteId: null,
sections: [ sections: [...state.sections.map(apply), ...added.map(apply)],
...state.sections.map((sec) =>
wanted.includes(sec.type) ? {...sec, isEnabled: true} : sec,
),
...added,
],
}; };
}); });
persistTheme(); persistTheme();

View File

@ -33,7 +33,14 @@ function block(name) {
const rules = block('SECTION_PROMPT_RULES'); const rules = block('SECTION_PROMPT_RULES');
const kinds = ['songs', 'people', 'chronicle', 'postcard', 'quiz']; // ★ 목록을 여기 손으로 적지 않는다. 예전엔 배열 하나를 더 두었는데, `STORY_KINDS` 에
// 종류를 하나 늘려도 이 배열을 잊으면 **뽑히지 않는다** — 프론트는 아는데 서버만 모르는
// 상태가 되고, 그 종류의 탭은 영원히 빈칸이다(실측 2026-09-10, `daily`).
const kinds = (() => {
const m = src.match(/export const STORY_KINDS: StoryKind\[\] = \[([\s\S]*?)\];/);
if (!m) throw new Error('STORY_KINDS 를 찾지 못했다 — section-prompts.ts 의 모양이 바뀌었다');
return [...m[1].matchAll(/'([^']+)'/g)].map((hit) => hit[1]);
})();
const specs = {}; const specs = {};
for (const kind of kinds) { for (const kind of kinds) {
const body = src.match(new RegExp(`\\n ${kind}: \\{([\\s\\S]*?)\\n \\},`)); const body = src.match(new RegExp(`\\n ${kind}: \\{([\\s\\S]*?)\\n \\},`));

View File

@ -15,9 +15,24 @@
*/ */
/** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */ /** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */
export type StoryKind = 'songs' | 'people' | 'chronicle' | 'postcard' | 'quiz'; export type StoryKind = 'songs' | 'daily' | 'people' | 'chronicle' | 'postcard' | 'quiz';
export const STORY_KINDS: StoryKind[] = ['songs', 'people', 'chronicle', 'postcard', 'quiz']; /**
* '지역 이야기' (`site/sections/items/StorySection.tsx`).
*
* `daily`( ) (`canvas/dataSpec.ts`)
* .
* , '옛 항구' `/s/stay`
* ( 2026-09-10).
*/
export const STORY_KINDS: StoryKind[] = [
'songs',
'daily',
'people',
'chronicle',
'postcard',
'quiz',
];
export interface SectionPromptSpec { export interface SectionPromptSpec {
kind: StoryKind; kind: StoryKind;
@ -73,6 +88,28 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
`, `,
}, },
daily: {
kind: 'daily',
label: '오늘의 한 장',
maxItems: 30,
task: `[해야 할 일]
[] 30 , JSON .
. ( ).
[]
{ "kind":"daily", "version":1, "title":"오늘의 한 장", "items":[
{ "monthDay":"09-02", "category":"역사|인물|장소|음식|축제|바다|문학",
"title":"제목", "body":"두 문장 이내", "season":"봄|여름|가을|겨울",
"tags":["#태그"], "verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[ ]
· monthDay MM-DD . .
· 30 . 12 12 .
· .
`,
},
people: { people: {
kind: 'people', kind: 'people',
label: '인물 열전', label: '인물 열전',

View File

@ -83,6 +83,28 @@ export interface TemplateItem {
* 릿 . * 릿 .
*/ */
defaultSectionTypes?: string[]; defaultSectionTypes?: string[];
/**
* 릿 ** ** ().
*
* "넣기만 하고 빼지 않는다"(defaultSectionTypes)
* ** .** .
* , .
* 필요한가: 시안(`/s/stay`) .
* 릿 .
* `defaultSectionTypes` .
*/
disabledSectionTypes?: string[];
/**
* id. 릿 "그 섹션의 모양".
*
* (`photos.carousel`). · 릿
* , 릿 .
* 릿
* 릿 (`defaultSectionTypes` ).
* (`site/pages/HomePage.tsx`).
* , .
*/
defaultVariants?: Record<string, string>;
} }
/** /**

View File

@ -166,6 +166,7 @@ export interface ChannelLink {
import type { import type {
ChronicleItem, ChronicleItem,
DailyItem,
ItineraryItem, ItineraryItem,
PeopleItem, PeopleItem,
PostcardItem, PostcardItem,
@ -188,7 +189,7 @@ export interface LocalContents {
*/ */
itineraries?: ItineraryItem[]; itineraries?: ItineraryItem[];
/** /**
* ····. * ·····.
* *
* JSON(`theme.sections[].data`) ** .** * JSON(`theme.sections[].data`) ** .**
* (`StorySection`) . * (`StorySection`) .
@ -201,15 +202,30 @@ export interface LocalContents {
syncedAt?: string; syncedAt?: string;
} }
/** 지역 이야기 섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ /** 지역 이야기 섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */
export interface LocalStories { export interface LocalStories {
songs?: SongItem[]; songs?: SongItem[];
daily?: DailyItem[];
people?: PeopleItem[]; people?: PeopleItem[];
chronicle?: ChronicleItem[]; chronicle?: ChronicleItem[];
postcard?: PostcardItem[]; postcard?: PostcardItem[];
quiz?: QuizItem[]; quiz?: QuizItem[];
} }
/**
* ** ** .
*
* (mood) 이유: 같은 '맑음' 34 3
* . "골목을 걸어 보세요"
* .
* ( ).
* 30 . 33() .
* 25 · .
* 20 .
* 10 .
*/
export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
@ -228,6 +244,17 @@ export interface WeatherSnapshot {
* . * .
*/ */
notes?: Partial<Record<WeatherMood, string>>; notes?: Partial<Record<WeatherMood, string>>;
/**
* ** .**
*
* `notes` (2026-09-09, : "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰")
* `notes` ** ** (·CAFÉ·),
* **** .
* 4×5 + .
* ** ** ( ·).
* .
*/
tempNotes?: Partial<Record<WeatherBand, string>>;
observedAt: string; observedAt: string;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,9 +12,13 @@ import {
type MediaItem, type MediaItem,
type SitePayload, type SitePayload,
type UnitInfo, type UnitInfo,
type WeatherBand,
type WeatherMood, type WeatherMood,
} from '@o2o/shared'; } from '@o2o/shared';
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@site/seo/jsonld'; import {BOOKING_CHANNELS, UNIT_SPEC, publicLinks, unitBaseRate} from '@site/seo/jsonld';
// 링크 거르기는 jsonld 에 산다(순환 의존 회피) — 화면 쪽은 늘 derive 를 통해 쓰므로 여기서 다시 낸다.
export {publicLinks};
/** /**
* payload . * payload .
@ -179,6 +183,20 @@ export function weatherMood(condition: string | undefined): WeatherMood {
return '맑음'; return '맑음';
} }
/**
* . shared `WeatherBand` .
*
* (use-live-weather)
* HTML . `weatherMood` .
*/
export function weatherBand(temperature: number): WeatherBand {
if (temperature >= 30) return '혹서';
if (temperature >= 25) return '더움';
if (temperature >= 20) return '선선';
if (temperature >= 10) return '쌀쌀';
return '추움';
}
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
export function galleryImages(payload: SitePayload): MediaItem[] { export function galleryImages(payload: SitePayload): MediaItem[] {
return payload.media.filter((m) => m.alt?.trim() && !m.unitId); return payload.media.filter((m) => m.alt?.trim() && !m.unitId);
@ -472,6 +490,7 @@ function isSearchUrl(url: string): boolean {
return /\/p\/search\/|[?&]query=/.test(url); return /\/p\/search\/|[?&]query=/.test(url);
} }
/** /**
* *
* "이 방을 이 값에 이 창구로" . * "이 방을 이 값에 이 창구로" .

View File

@ -45,6 +45,21 @@ export function youtubeSearchUrl(query: string): string {
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
} }
/**
* .
*
* (2026-09-09, : "카카오 길찾기에서 현위치만 나오고 도착지 안나옴")
* `link/to/{이름},{위도},{경도}` `{경도},{위도},{이름}`
* . "스테이,머뭄"
* ** "머뭄" ** ()
* . `encodeURIComponent` %2C ( ).
* .
* (`goalname=`) . .
*/
function mapLabel(name: string): string {
return name.replace(/,/g, ' ').replace(/\s+/g, ' ').trim();
}
/** /**
* ****. . * ****. .
* *
@ -55,7 +70,7 @@ export function kakaoDirectionsUrl(name: string, lat?: number, lng?: number): st
if (lat == null || lng == null) { if (lat == null || lng == null) {
return `https://map.kakao.com/link/search/${encodeURIComponent(name)}`; return `https://map.kakao.com/link/search/${encodeURIComponent(name)}`;
} }
return `https://map.kakao.com/link/to/${encodeURIComponent(name)},${lat},${lng}`; return `https://map.kakao.com/link/to/${encodeURIComponent(mapLabel(name))},${lat},${lng}`;
} }
/** /**
@ -79,7 +94,7 @@ export function naverRouteUrl(
to: {name: string; lat: number; lng: number}, to: {name: string; lat: number; lng: number},
): string { ): string {
const point = (p: {name: string; lat: number; lng: number}) => const point = (p: {name: string; lat: number; lng: number}) =>
`${p.lng},${p.lat},${encodeURIComponent(p.name)}`; `${p.lng},${p.lat},${encodeURIComponent(mapLabel(p.name))}`;
// ★ 수단은 차(car)다. 오시는 길(naverDirectionsUrl)이 대중교통인 것과 반대인데, // ★ 수단은 차(car)다. 오시는 길(naverDirectionsUrl)이 대중교통인 것과 반대인데,
// 그건 "방문자 → 업소"라 대중교통을 아는 사람이 적어서였고, 여기는 일정 안의 구간이다 — // 그건 "방문자 → 업소"라 대중교통을 아는 사람이 적어서였고, 여기는 일정 안의 구간이다 —
// 선유도·장자도처럼 버스로는 답이 안 나오는 다리가 섞여 있다. // 선유도·장자도처럼 버스로는 답이 안 나오는 다리가 섞여 있다.
@ -90,7 +105,7 @@ export function naverDirectionsUrl(name: string, lat?: number, lng?: number): st
if (lat == null || lng == null) { if (lat == null || lng == null) {
return `https://map.naver.com/p/search/${encodeURIComponent(name)}`; return `https://map.naver.com/p/search/${encodeURIComponent(name)}`;
} }
return `https://map.naver.com/p/directions/-/${lng},${lat},${encodeURIComponent(name)}/-/transit`; return `https://map.naver.com/p/directions/-/${lng},${lat},${encodeURIComponent(mapLabel(name))}/-/transit`;
} }
/** /**

View File

@ -15,6 +15,7 @@
*/ */
import {useCallback, useEffect, useId, useRef, useState, type ReactNode} from 'react'; import {useCallback, useEffect, useId, useRef, useState, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react'; import useEmblaCarousel from 'embla-carousel-react';
import {AUTOPLAY_MS, useRailAutoplay} from './use-rail-autoplay';
import {ChevronLeft, ChevronRight} from 'lucide-react'; import {ChevronLeft, ChevronRight} from 'lucide-react';
interface CarouselProps { interface CarouselProps {
@ -28,11 +29,44 @@ interface CarouselProps {
dots?: boolean; dots?: boolean;
/** 화살표를 헤더 줄에 둘지(기본) 슬라이더 위에 겹칠지. */ /** 화살표를 헤더 줄에 둘지(기본) 슬라이더 위에 겹칠지. */
arrows?: 'header' | 'overlay' | 'none'; arrows?: 'header' | 'overlay' | 'none';
/** 슬라이드 사이 간격(rem). */ /**
* (rem).
*
* (2026-09-09, :
* "카로셀에서 간격정리 안된거 있는데?"). · 0.75, 1,
* 0(!).
* "붙었다" ** ** .
* ( ) 0 .
*/
gap?: number; gap?: number;
/**
* (ms). `false` . ( 2026-09-09).
*
* ** ** (2026-09-09, : "한바퀴돌면 다시 안돌게")
* ,
* . **"옆에 더 있다" **
* .
* `loop` . **** ("
* ") .
* embla loop ** transform ** ,
* flex `gap`
* . .
* flex , .
*/
autoplay?: number | false;
/**
* .
*
* ( 일정: 코스 )
* "지금 어느 묶음인가" .
*/
onSelect?: (index: number) => void;
/** 레일을 바깥에서 움직일 수 있게 조작부를 넘긴다(위 `onSelect` 와 짝이다). */
onReady?: (api: {scrollTo: (index: number) => void}) => void;
className?: string; className?: string;
} }
export function Carousel({ export function Carousel({
label, label,
children, children,
@ -41,8 +75,12 @@ export function Carousel({
dots = true, dots = true,
arrows = 'header', arrows = 'header',
gap = 1, gap = 1,
autoplay = AUTOPLAY_MS,
onSelect,
onReady,
className, className,
}: CarouselProps) { }: CarouselProps) {
const autoplayMs = autoplay === false ? 0 : autoplay;
const [emblaRef, embla] = useEmblaCarousel({ const [emblaRef, embla] = useEmblaCarousel({
align, align,
loop, loop,
@ -94,7 +132,48 @@ export function Carousel({
}; };
}, [embla]); }, [embla]);
/*
* `useRailAutoplay` ( · ).
* embla (·· ) .
*/
useRailAutoplay({
box: viewport,
interval: autoplayMs,
advance: () => {
if (!embla) return true; // 아직 안 붙었다 — 끝난 게 아니므로 계속 기다린다
// 한 화면에 다 들어가면 넘길 데가 없다. 폭이 바뀌면 장수도 바뀌므로 매번 다시 센다.
if (embla.scrollSnapList().length <= 1) return true;
if (!embla.canScrollNext()) return false;
embla.scrollNext();
return true;
},
});
const scrollTo = useCallback((index: number) => embla?.scrollTo(index), [embla]); const scrollTo = useCallback((index: number) => embla?.scrollTo(index), [embla]);
/*
* .
*
* ref ,
* .
*/
const notify = useRef(onSelect);
notify.current = onSelect;
const ready = useRef(onReady);
ready.current = onReady;
useEffect(() => {
if (!embla) return;
const tell = () => notify.current?.(embla.selectedScrollSnap());
tell();
embla.on('select', tell);
embla.on('reInit', tell);
ready.current?.({scrollTo: (index) => embla.scrollTo(index)});
return () => {
embla.off('select', tell);
embla.off('reInit', tell);
};
}, [embla]);
const prev = useCallback(() => embla?.scrollPrev(), [embla]); const prev = useCallback(() => embla?.scrollPrev(), [embla]);
const next = useCallback(() => embla?.scrollNext(), [embla]); const next = useCallback(() => embla?.scrollNext(), [embla]);

View File

@ -0,0 +1,153 @@
/**
* ** .**
*
* (2026-09-09, : "ado2 카로셀은 auto가 아닌데?")
* `Carousel` . embla
* · · .
* .
* ** .** .
*
* . .
* .
* "옆에 더 있다" .
*
* .
* - 있으면: 읽는
* - (`hold`/`release`):
* - 밖이면: 아무도 . ,
*
* - `prefers-reduced-motion`
*/
import {useEffect, useRef, type RefObject} from 'react';
/** 자동 넘김 기본 간격. 카드 한 장을 읽고 사진을 보는 데 드는 시간이다. */
export const AUTOPLAY_MS = 4500;
export interface RailAutoplay {
/**
* . ** ref .**
*
* `ref.current` null ,
* ref null .
* ref ** ** DOM .
*/
box: RefObject<HTMLElement | null>;
/** ms. 0 이면 끈다. */
interval: number;
/**
* . ** `false`** .
* (embla `scrollNext` · `scrollBy`) .
*/
advance: () => boolean;
}
export function useRailAutoplay({box, interval, advance}: RailAutoplay) {
// 매 렌더마다 새 함수가 오는 것이 정상이다 — 의존성에 넣으면 구독을 계속 떼고 다시 붙인다.
const step = useRef(advance);
step.current = advance;
useEffect(() => {
const rail = box.current;
if (!rail || interval <= 0) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let held = 0; // 손 올림·포커스가 겹칠 수 있다 — 개수로 센다
let onScreen = true;
const hold = () => {
held += 1;
};
const release = () => {
held = Math.max(0, held - 1);
};
const timer = window.setInterval(() => {
if (held > 0 || document.hidden || !onScreen) return;
if (!step.current()) window.clearInterval(timer);
}, interval);
const io = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
}, {threshold: 0.25});
io.observe(rail);
/*
* **** (2026-09-09, : "축제는 auto가 아닌거같은데")
* . `pointerenter`
* **`pointerleave` **(
* ). `held` 1
* "이 카로셀만 원래 자동이 아닌 것" .
* `(hover: hover)` .
* . **`pointercancel` **
* `pointerup` , 1 .
*/
const hoverable = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (hoverable) {
rail.addEventListener('pointerenter', hold);
rail.addEventListener('pointerleave', release);
}
/*
* **** (2026-09-09, )
* .
* ****, `focusout` 1 .
* `:focus-visible`
* , .
*/
let focusHeld = false;
const onFocusIn = (event: Event) => {
const target = event.target;
if (!(target instanceof Element) || !target.matches(':focus-visible')) return;
if (!focusHeld) {
focusHeld = true;
hold();
}
};
const onFocusOut = () => {
if (!focusHeld) return;
focusHeld = false;
release();
};
rail.addEventListener('focusin', onFocusIn);
rail.addEventListener('focusout', onFocusOut);
/*
* . pointerdown~up .
*
* ** ** (2026-09-09, )
* embla
* `pointerup` , window .
* `pointerdown` `pointerup` 1 .
* .
*/
rail.addEventListener('pointerdown', hold, true);
window.addEventListener('pointerup', release, true);
window.addEventListener('pointercancel', release, true);
return () => {
window.clearInterval(timer);
io.disconnect();
if (hoverable) {
rail.removeEventListener('pointerenter', hold);
rail.removeEventListener('pointerleave', release);
}
rail.removeEventListener('focusin', onFocusIn);
rail.removeEventListener('focusout', onFocusOut);
rail.removeEventListener('pointerdown', hold, true);
window.removeEventListener('pointerup', release, true);
window.removeEventListener('pointercancel', release, true);
};
}, [box, interval]);
}
/**
* . `false`.
*
* 8px `scrollLeft`
* ( ).
*/
export function scrollRailNext(box: HTMLElement | null, ratio = 0.85): boolean {
if (!box) return false;
const max = box.scrollWidth - box.clientWidth;
if (max <= 8 || box.scrollLeft >= max - 8) return false;
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
box.scrollBy({left: box.clientWidth * ratio, behavior: still ? 'auto' : 'smooth'});
return true;
}

View File

@ -33,13 +33,26 @@ export function useLiveWeather({
async function refresh() { async function refresh() {
try { try {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; /*
* ** .**
* `import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'` .
* `VITE_*` **
* `localhost:9800` ** (2026-09-09, `/s/stay`):
* CORS ,
* (6 27.9°C) .
* , .
* ** **(site/.env.example)
* env .
* nginx ··API (CLAUDE.md) ,
* CORS . ( ) 404 ,
* catch .
*/
const query = new URLSearchParams({ const query = new URLSearchParams({
region_code: regionCode!, region_code: regionCode!,
latitude: String(latitude), latitude: String(latitude),
longitude: String(longitude), longitude: String(longitude),
}); });
const response = await fetch(`${baseUrl}/v1/local/weather?${query}`, { const response = await fetch(`/v1/local/weather?${query}`, {
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) return; if (!response.ok) return;

View File

@ -19,7 +19,7 @@ import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format'; import {naverSearchUrl} from '@site/lib/format';
import {useLiveSeasons} from '@site/lib/use-live-seasons'; import {useLiveSeasons} from '@site/lib/use-live-seasons';
import {Section} from '@site/lib/ui'; import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
// shared 의 SEASON_ORDER 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다. // shared 의 SEASON_ORDER 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다.
const SEASONS = ['봄', '여름', '가을', '겨울']; const SEASONS = ['봄', '여름', '가을', '겨울'];
@ -97,7 +97,7 @@ export function FestivalSection() {
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold"> <h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
{season} {season}
</h3> </h3>
<FestivalGrid items={items} /> <FestivalRail items={items} label={`${season} 축제`} remount={active} />
</div> </div>
); );
})} })}
@ -107,7 +107,7 @@ export function FestivalSection() {
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold"> <h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
</h3> </h3>
<FestivalGrid items={evergreen} /> <FestivalRail items={evergreen} label="계절 없이 열리는 행사" />
</div> </div>
)} )}
</div> </div>
@ -116,25 +116,39 @@ export function FestivalSection() {
} }
/** /**
* ****, (2026-09-04, : "모바일에서 너무 길다") * **** (2026-09-09, : "계절별 축제 카로셀로")
* 390px 1,274px 1.7. . * , .
* **·** . * . Carousel .
* 400px 120px . * 0 embla .
* CSS . * `remount` (LocalGuideSection `key={shown.length}` ).
* ** ** (2026-09-09, : "축제만 왜 ui가 달라 주변맛집이랑?")
* ( · )
* 1.7 (2026-09-04). .
* ,
* (··· ) .
* · aspect-4/3 · `LocalGuideSection` .
*/ */
function FestivalGrid({items}: {items: FestivalEntry[]}) { function FestivalRail({
items,
label,
remount,
}: {
items: FestivalEntry[];
label: string;
remount?: string | null;
}) {
return ( return (
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 lg:gap-4"> <Carousel key={remount ?? 'all'} label={label}>
{items.map((festival) => ( {items.map((festival) => (
<li key={festival.name}> <CarouselSlide key={festival.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a <a
href={naverSearchUrl(festival.searchQuery)} href={naverSearchUrl(festival.searchQuery)}
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
className="panel group flex h-full overflow-hidden transition-opacity hover:opacity-85 sm:flex-col" className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
> >
{/* 사진이 없으면 빈 회색 상자 대신 이름 활자 — 주변 안내 카드와 같은 규칙이다. */} {/* 사진이 없으면 빈 회색 상자 대신 이름 활자 — 주변 안내 카드와 같은 규칙이다. */}
<span className="relative block aspect-square w-28 shrink-0 overflow-hidden sm:aspect-4/3 sm:w-auto"> <span className="relative block aspect-4/3 overflow-hidden">
{festival.imageUrl ? ( {festival.imageUrl ? (
<img <img
src={festival.imageUrl} src={festival.imageUrl}
@ -164,7 +178,7 @@ function FestivalGrid({items}: {items: FestivalEntry[]}) {
</span> </span>
</span> </span>
<span className="flex min-w-0 flex-1 flex-col gap-1 p-3 sm:p-3.5"> <span className="flex min-w-0 flex-1 flex-col gap-1 p-3.5">
<span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span> <span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span>
{/* {/*
<time> (2026-09-04, AEO '미래 날짜 표기') <time> (2026-09-04, AEO '미래 날짜 표기')
@ -187,18 +201,18 @@ function FestivalGrid({items}: {items: FestivalEntry[]}) {
</span> </span>
)} )}
{festival.description && ( {festival.description && (
<span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed sm:line-clamp-3"> <span className="text-muted line-clamp-3 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed">
{festival.description} {festival.description}
</span> </span>
)} )}
<span className="text-muted mt-auto hidden items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100 sm:flex"> <span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100">
<span> </span> <span> </span>
<ArrowUpRight className="size-3.5" /> <ArrowUpRight className="size-3.5" />
</span> </span>
</span> </span>
</a> </a>
</li> </CarouselSlide>
))} ))}
</ul> </Carousel>
); );
} }

View File

@ -3,6 +3,7 @@ import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {galleryImages} from '@site/lib/derive'; import {galleryImages} from '@site/lib/derive';
import {Section} from '@site/lib/ui'; import {Section} from '@site/lib/ui';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
/** /**
* . * .
@ -39,6 +40,17 @@ export function GallerySection() {
return () => window.removeEventListener('resize', syncTrack); return () => window.removeEventListener('resize', syncTrack);
}, [syncTrack]); }, [syncTrack]);
/*
* (`useRailAutoplay`).
* , `scrollRailNext` false .
* ( 1).
*/
useRailAutoplay({
box: track,
interval: AUTOPLAY_MS,
advance: () => scrollRailNext(track.current, 1),
});
const nudge = useCallback((dir: -1 | 1) => { const nudge = useCallback((dir: -1 | 1) => {
const box = track.current; const box = track.current;
if (!box) return; if (!box) return;
@ -96,7 +108,9 @@ export function GallerySection() {
<ul <ul
ref={track} ref={track}
onScroll={syncTrack} onScroll={syncTrack}
className="flex snap-x snap-mandatory gap-2 overflow-x-auto [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden" /* 12px 8px· 12px
( 16px, Carousel ). */
className="flex snap-x snap-mandatory gap-3 overflow-x-auto [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden"
> >
{images.map((image, index) => ( {images.map((image, index) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto"> <li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto">

View File

@ -167,7 +167,7 @@ function PlaceList({
** .** ** .**
·AI . ·AI .
*/} */}
<Carousel key={shown.length} label={title} gap={0.75}> <Carousel key={shown.length} label={title}>
{shown.map((place) => ( {shown.map((place) => (
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4"> <CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a <a

View File

@ -2,7 +2,7 @@ import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {isoDate} from '@site/lib/format'; import {isoDate} from '@site/lib/format';
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다. // 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
import {channelLabel} from '@site/lib/derive'; import {channelLabel, publicLinks} from '@site/lib/derive';
/** /**
* . * .
@ -15,7 +15,8 @@ export function SiteFooter() {
const payload = useSite(); const payload = useSite();
const {place, site} = payload; const {place, site} = payload;
// 확정된 채널만 — 확정 전 URL 은 동명 업소일 수 있다. // 확정된 채널만 — 확정 전 URL 은 동명 업소일 수 있다.
const links = payload.links.filter((link) => link.confirmed); // ★ sameAs 와 같은 목록이어야 한다 — 화면에 없는 주소가 구조화 데이터에 있으면 발행이 막힌다.
const links = publicLinks(payload);
const address = place.roadAddress ?? place.address; const address = place.roadAddress ?? place.address;
return ( return (

View File

@ -27,7 +27,7 @@ export function SiteHeader() {
return ( return (
<header <header
className="border-line sticky top-0 z-40 w-full border-b backdrop-blur-md" className="border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md"
style={{backgroundColor: 'color-mix(in srgb, var(--tpl-surface, #fff) 88%, transparent)'}} style={{backgroundColor: 'color-mix(in srgb, var(--tpl-surface, #fff) 88%, transparent)'}}
> >
<div className="shell flex h-16 items-center justify-between gap-3"> <div className="shell flex h-16 items-center justify-between gap-3">

View File

@ -11,7 +11,7 @@
*/ */
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather'; import {useLiveWeather} from '@site/lib/use-live-weather';
import {weatherMood} from '@site/lib/derive'; import {weatherBand, weatherMood} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui'; import {Card, Section} from '@site/lib/ui';
import './weather.css'; import './weather.css';
@ -35,8 +35,17 @@ export function WeatherSection() {
if (!weather) return null; if (!weather) return null;
const mood = weatherMood(weather.condition); const mood = weatherMood(weather.condition);
const band = weatherBand(weather.temperature);
// 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다. // 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다.
const line = payload.local.weather?.notes?.[mood] ?? weather.note; const line = payload.local.weather?.notes?.[mood] ?? weather.note;
/*
* (2026-09-09, : "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰서")
* (·CAFÉ) "그래서 오늘 어디를 가지" .
* 34 3 .
*
* , .
*/
const bandLine = payload.local.weather?.tempNotes?.[band];
const observed = observedText(weather.observedAt); const observed = observedText(weather.observedAt);
return ( return (
@ -73,6 +82,22 @@ export function WeatherSection() {
{line} {line}
</p> </p>
)} )}
{/*
"비 오나"
"그럼 어디 가지" . .
*/}
{bandLine && (
<p className="measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-75">
<span
className="border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
aria-hidden
>
{band}
</span>
<span>{bandLine}</span>
</p>
)}
</div> </div>
</Card> </Card>
</Section> </Section>

View File

@ -19,7 +19,7 @@
* FestivalSection . * FestivalSection .
* ** **. `useState` SSR . * ** **. `useState` SSR .
*/ */
import {useState} from 'react'; import {useCallback, useMemo, useRef, useState} from 'react';
import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared'; import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive'; import {sectionItems, sectionName} from '@site/lib/derive';
@ -61,6 +61,13 @@ function DayCard({
const day = planDay({name: item.name, startTime, stops}); const day = planDay({name: item.name, startTime, stops});
return ( return (
/*
* (2026-09-09, : "간격 다 똑같이")
* `ml-6 border-l-2` 48px .
* , ** **(DurationRail)
* .
* , .
*/
<article <article
className="w4-paper w-[320px] shrink-0 snap-center border" className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
@ -86,9 +93,14 @@ function DayCard({
</div> </div>
<div className="space-y-1.5 px-4 pt-3.5"> <div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{item.name}</h4> {/*
{/* audience h3 (2026-09-09) .
· . */} .
, .
*/}
<h3 className="serif text-lg font-bold">{item.name}</h3>
{/* ★ why·audience 는 첫 장만 — 다 채우면 같은 문구가 이틀·사흘 반복된다. */}
{first && item.audience && <p className="text-[12px] opacity-60">{item.audience}</p>}
{first && item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>} {first && item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>}
</div> </div>
@ -99,7 +111,15 @@ function DayCard({
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5"> <li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다. */} {/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다. */}
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{planned.time}</span> <span className="serif pt-2 text-[12px] tabular-nums opacity-75">{planned.time}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}> {/*
(2026-09-09, )
pb-2 13px **** 10px ,
. (10px) .
. `border-l`
, .
*/}
<div className="border-l pb-5 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && <p className="pt-1 text-[10px] opacity-50"> {planned.move} </p>} {planned.move > 0 && <p className="pt-1 text-[10px] opacity-50"> {planned.move} </p>}
<div className="flex items-start gap-2.5 pt-1"> <div className="flex items-start gap-2.5 pt-1">
{/* 사진은 있을 때만. 없으면 빈 회색 상자 대신 활자만 선다(이 팩의 규칙). */} {/* 사진은 있을 때만. 없으면 빈 회색 상자 대신 활자만 선다(이 팩의 규칙). */}
@ -198,38 +218,103 @@ export function ItinerarySection() {
</div> </div>
)} )}
{/*
= (2026-09-09, : "추천일정 1열로 만들어서 auto play")
() + .
.
0 embla key .
*/}
{tabs.map((tab) => ( {tabs.map((tab) => (
<div key={tab} hidden={tab !== active} className="space-y-10"> <div key={tab} hidden={tab !== active}>
{items <DurationRail
.filter((item) => (item.duration?.trim() || NO_DURATION) === tab) key={active}
.map((item, index) => ( tab={tab}
<div key={`${item.name}-${index}`} className="space-y-3"> items={items.filter((item) => (item.duration?.trim() || NO_DURATION) === tab)}
<div className="space-y-1"> />
{/* 코스 이름에 이미 "1박 2일" 이 들어 있으면 앞에 또 붙이지 않는다. */}
<h3 className="serif text-lg font-bold">
{item.duration && !item.name.includes(item.duration)
? `${item.duration} · ${item.name}`
: item.name}
</h3>
{/* ★ 탭 안에서는 기간이 다 같다 — 안끼리 갈리는 것은 '누구에게 맞나'다. */}
{item.audience && <p className="text-[13px] opacity-60">{item.audience}</p>}
</div> </div>
<Rail label={`${item.name} 일정`}> ))}
{daysOf(item).map((day, dayIndex) => ( </ItemSection>
);
}
/**
* ** + **.
*
* (2026-09-09, : "일렬로 되었으니까 타이틀이 안보이잖아")
* ,
* "이 기간에 무슨 코스가 있나" .
* ** ** ,
* ().
* , .
* .
* () 가른다: 저쪽은 .
* .
* .
* `audience`("차 없이 오는 손님") .
* , .
*/
function DurationRail({tab, items}: {tab: string; items: ItineraryItem[]}) {
/** 코스별 시작 위치와 길이. 레일 인덱스 ↔ 코스를 잇는 유일한 표다. */
const courses = useMemo(() => {
let cursor = 0;
return items.map((item) => {
const start = cursor;
cursor += daysOf(item).length;
return {item, start};
});
}, [items]);
const [slide, setSlide] = useState(0);
const api = useRef<{scrollTo: (index: number) => void} | null>(null);
const onReady = useCallback((next: {scrollTo: (index: number) => void}) => {
api.current = next;
}, []);
// 지금 보이는 장이 속한 코스 — 시작 위치가 그 장보다 앞인 것 중 마지막.
const currentStart = courses.reduce(
(found, course) => (course.start <= slide ? course.start : found),
0,
);
return (
<>
{courses.length > 1 && (
<ul className="border-line mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3">
{courses.map(({item, start}) => {
const on = start === currentStart;
return (
<li key={`${item.name}-${start}`}>
<button
type="button"
onClick={() => api.current?.scrollTo(start)}
aria-current={on}
className={`text-left text-[length:var(--fs-sm)] underline-offset-4 transition-opacity hover:opacity-100 ${
on ? 'font-bold underline' : 'opacity-55 hover:underline'
}`}
>
{item.name}
</button>
</li>
);
})}
</ul>
)}
<Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}>
{courses.flatMap(({item, start}, index) =>
daysOf(item).map((day, dayIndex) => (
<DayCard <DayCard
key={`${item.name}-${dayIndex}`} key={`${item.name}-${start}-${dayIndex}`}
item={item} item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`} badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime} startTime={day.startTime}
stops={day.stops} stops={day.stops}
first={dayIndex === 0} first={dayIndex === 0}
/> />
))} )),
)}
</Rail> </Rail>
</div> </>
))}
</div>
))}
</ItemSection>
); );
} }

View File

@ -33,6 +33,7 @@ import {
youtubePoster, youtubePoster,
type VideoItem, type VideoItem,
} from '@o2o/shared'; } from '@o2o/shared';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive'; import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine} from './common'; import {ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine} from './common';
@ -69,6 +70,17 @@ export function VideoSection() {
box.scrollBy({left: dir * box.clientWidth * 0.85, behavior: still ? 'auto' : 'smooth'}); box.scrollBy({left: dir * box.clientWidth * 0.85, behavior: still ? 'auto' : 'smooth'});
}, []); }, []);
/*
* ** **(`useRailAutoplay`).
* embla (2026-09-09, : "ado2 카로셀은 auto가 아닌데?").
* , `scrollRailNext` false .
*/
useRailAutoplay({
box: scroller,
interval: AUTOPLAY_MS,
advance: () => scrollRailNext(scroller.current),
});
if (parsed.items.length === 0) return null; if (parsed.items.length === 0) return null;
// 한 편뿐이면 밀 곳이 없다 — 슬라이더로 만들지 않는다. // 한 편뿐이면 밀 곳이 없다 — 슬라이더로 만들지 않는다.
@ -87,11 +99,17 @@ export function VideoSection() {
<ul <ul
ref={scroller} ref={scroller}
onScroll={syncEdges} onScroll={syncEdges}
className={`gap-5 ${ /*
* ** ** (2026-09-09, : "간격없이 붙는거 다 수정해")
* `gap-5` `gap-3` `lg:gap-0`
* (1280px): 3 0px .
* (16px) Carousel `gap` .
*/
className={
many many
? 'flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 lg:gap-0 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:gap-4 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden'
: 'grid grid-cols-1' : 'grid grid-cols-1 gap-4'
}`} }
> >
{parsed.items.map((item, index) => { {parsed.items.map((item, index) => {
const id = youtubeId(item.url); const id = youtubeId(item.url);

View File

@ -161,6 +161,8 @@ export function Rail({
children, children,
label, label,
arrows = 'header', arrows = 'header',
onSelect,
onReady,
}: { }: {
children: ReactNode; children: ReactNode;
label: string; label: string;
@ -169,9 +171,12 @@ export function Rail({
* . * .
*/ */
arrows?: 'header' | 'overlay'; arrows?: 'header' | 'overlay';
/** 지금 몇 번째 장인지 알아야 하는 레일에만 준다(추천 일정의 코스 줄). Carousel 주석 참조. */
onSelect?: (index: number) => void;
onReady?: (api: {scrollTo: (index: number) => void}) => void;
}) { }) {
return ( return (
<Carousel label={label} gap={1} arrows={arrows}> <Carousel label={label} gap={1} arrows={arrows} onSelect={onSelect} onReady={onReady}>
{children} {children}
</Carousel> </Carousel>
); );

View File

@ -152,6 +152,16 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1',
}), }),
tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}), tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}),
/*
* · 릿 (2026-09-09, : "모바일에서 노치가 투명으로 되던데")
* `viewport-fit=cover` `theme-color` ,
* ** (·)** .
* .
* `surface` .
* `bg` .
* payload (themeStyle ).
*/
tag('meta', {name: 'theme-color', content: deriveSurfaces(payload.theme.colors).surface}),
// 신선도 — AI 검색이 오래된 페이지를 뒤로 미룬다. // 신선도 — AI 검색이 오래된 페이지를 뒤로 미룬다.
tag('meta', {name: 'dateModified', content: site.updatedAt}), tag('meta', {name: 'dateModified', content: site.updatedAt}),

View File

@ -8,6 +8,7 @@ import {
sanitizeUnits, sanitizeUnits,
selectPublishable, selectPublishable,
selectPublishableFaqs, selectPublishableFaqs,
type ChannelLink,
type FactEntry, type FactEntry,
type SitePayload, type SitePayload,
type UnitInfo, type UnitInfo,
@ -97,18 +98,45 @@ function geoCoordinates(payload: SitePayload): Json | undefined {
return {'@type': 'GeoCoordinates', latitude, longitude}; return {'@type': 'GeoCoordinates', latitude, longitude};
} }
/** 대표 이미지 우선, 대체 텍스트가 있는 것만. alt 없는 이미지는 애초에 렌더도 안 한다. */ /**
* , . alt .
*
* payload ** **
* (`/assets/mirror/…`, scripts/mirror-payload-images.mjs).
* Schema.org `image`
* .
*/
function imageUrls(payload: SitePayload, limit = 6): string[] { function imageUrls(payload: SitePayload, limit = 6): string[] {
return payload.media return payload.media
.filter((m) => m.alt?.trim()) .filter((m) => m.alt?.trim())
.sort((a, b) => Number(Boolean(b.isPrimary)) - Number(Boolean(a.isPrimary))) .sort((a, b) => Number(Boolean(b.isPrimary)) - Number(Boolean(a.isPrimary)))
.slice(0, limit) .slice(0, limit)
.map((m) => m.url); .map((m) => (m.url.startsWith('/') ? joinUrl(payload.site.origin, m.url) : m.url));
} }
/** 확정된 채널만 sameAs 로 나간다 — 확정 전 URL 은 동명 업소일 수 있다. */ /**
* ** `sameAs` .**
*
* `http(s)` ( 2026-09-10, )
* TourAPI `tour://32/2819450` ** **
* (`collect_service.py`). "이 업장은 관광공사 콘텐츠 2819450 이다"
* , schema.org `sameAs`( ) .
* JSON-LD
* ** ** ( 3).
* ** ** . .
* . (SiteFooter) sameAs(seo/jsonld)
* .
*/
export function publicLinks(payload: SitePayload): ChannelLink[] {
return payload.links.filter((link) => link.confirmed && /^https?:\/\//i.test(link.url ?? ''));
}
/**
* sameAs URL .
* (SiteFooter) ** ** (`publicLinks`) .
*/
function sameAs(payload: SitePayload): string[] { function sameAs(payload: SitePayload): string[] {
return payload.links.filter((link) => link.confirmed && link.url).map((link) => link.url); return publicLinks(payload).map((link) => link.url);
} }
/** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */ /** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */

View File

@ -125,6 +125,22 @@ function* iterScalars(node: unknown, path: string[] = []): Generator<[string[],
yield [path, node as Scalar]; yield [path, node as Scalar];
} }
/** 주소로 볼 값인지 — 절대 URL 이거나 루트 절대경로. 본문 텍스트가 아니라 속성에 있다. */
function isUrlLike(token: string): boolean {
return token.startsWith('http://') || token.startsWith('https://') || token.startsWith('/');
}
/** 절대 URL 에서 오리진을 뗀 경로. 절대경로는 그대로 돌려준다. */
function pathOf(token: string): string {
if (token.startsWith('/')) return token;
try {
const {pathname, search} = new URL(token);
return `${pathname}${search}`;
} catch {
return token;
}
}
/** 숫자를 화면 표기(1,000 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */ /** 숫자를 화면 표기(1,000 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */
function asShown(value: Scalar): string[] { function asShown(value: Scalar): string[] {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@ -174,10 +190,27 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value); const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다. // URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (token.startsWith('http://') || token.startsWith('https://')) { if (isUrlLike(token)) {
if (!html.includes(token) && !unescaped.includes(token)) { /*
report(`${prop}: '${token}' 이 화면에 없다`); * (2026-09-09)
* HTML ** **(`/assets/…`)
* JSON-LD URL (jsonld `imageUrl`).
* "화면에 없다" (2026-09-09):
* .
* (HTML ·JSON-LD ) .
* `unescaped` HTML (&amp; )
* .
*/
const tokenPath = pathOf(token);
if (
html.includes(token) ||
unescaped.includes(token) ||
html.includes(tokenPath) ||
unescaped.includes(tokenPath)
) {
continue;
} }
report(`${prop}: '${token}' 이 화면에 없다`);
continue; continue;
} }
if (asShown(value).some((shown) => text.includes(shown))) continue; if (asShown(value).some((shown) => text.includes(shown))) continue;

View File

@ -1,9 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { /*
readonly VITE_API_BASE_URL?: string; * ** **(site/.env.example).
} * SitePayload payload HTML
* . `VITE_API_BASE_URL` ,
interface ImportMeta { * `localhost:9800`
readonly env: ImportMetaEnv; * (2026-09-09, use-live-weather.ts ). .
} */