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

View File

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

View File

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

View File

@ -1,34 +1,13 @@
-- 0005 · 스키마 해체하고 이름으로 소속을 말한다
-- 0005 · 스키마 해체
--
-- ★ 왜 (2026-09-09)
-- 스키마를 나누는 이유는 셋뿐이다 — ① 같은 이름을 여러 번 쓰려고 ② 권한을 덩어리로 주려고
-- ③ 읽는 사람에게 경계를 보여주려고. 이 프로젝트에선 ①②가 성립하지 않는다:
-- · `web4ai_db` 는 이 제품 전용 database 다. 다른 프로젝트는 다른 database 에 있고,
-- 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 뿐이라 잃는 행이 없다.
-- 전용 database 라 이름 충돌이 없고(19개 전부 다름), 계정이 하나라 스키마별 GRANT 도 없다.
-- 남은 건 "경계 표시"인데 places.place_id 를 참조하는 11개 테이블이 전부 place 밖에 있었다 —
-- 조인이 매번 넘는 선은 경계가 아니다. 접두어가 소속을 말하게 한다.
-- area_contents 만 접두어가 없다 — 공유물이라는 표시다.
-- ── 사람 · 큐 (아무에게도 안 속한다) ──────────────────────────────────────
ALTER TABLE IF EXISTS company.users 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.place_links 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.faqs SET SCHEMA public;
-- 이름이 무엇에 속하는지, 그리고 무엇을 담는지 말하게 한다.
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.media RENAME TO place_photos; -- 실제로 사진만 담는다
ALTER TABLE IF EXISTS public.facts RENAME TO place_facts;
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.place_contents SET SCHEMA public;
-- 접두어 없는 유일한 테이블. 공유물이라는 표시다.
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 site.sites 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.publish_logs SET SCHEMA public;
-- 섹션의 '내용'이다. site_contents 는 무엇의 콘텐츠인지 말하지 않는다.
ALTER TABLE IF EXISTS public.site_contents RENAME TO site_sections;
ALTER TABLE IF EXISTS public.publish_logs RENAME TO site_publish_logs;
-- ── 아직 남기는 것 ──────────────────────────────────────────────────────
-- 빈 테이블(place_aliases · routes · nearby_links · ai_check_results)과 이관 잔재
-- (place_contents_legacy)는 0006 에서 판단한다. 한 파일에 두 가지 일을 넣지 않는다.
-- 빈 테이블과 이관 잔재는 0006 에서 판단한다.
ALTER TABLE IF EXISTS place.place_aliases SET SCHEMA public;
ALTER TABLE IF EXISTS local.routes 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 public.routes RENAME TO place_routes;
ALTER TABLE IF EXISTS public.place_aliases RENAME TO place_names;
-- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 **업장**이 AI 검색에 나오는지를
-- 재는 것이라, 사이트를 안 만들어도 잴 수 있다. site_ 가 아니라 place_ 다.
-- ai_check_results 는 place_id 가 키다 — 사이트가 아니라 업장이 대상이다.
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 place RESTRICT;
DROP SCHEMA IF EXISTS fact RESTRICT;

View File

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

View File

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

View File

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

View File

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

View File

@ -265,23 +265,38 @@ class area_contents(MainTableMixin, MAIN_BASE):
외부 API 실패 행을 지우거나 비우지 않는다 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__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__ = (
# external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다.
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** —
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
Index(
"uq_local_contents_keyed",
"region_code",
"content_type",
"uq_local_contents_external",
"source",
"external_id",
unique=True,
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(
"uq_local_contents_single",
"region_code",
"content_type",
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):
"""업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.**
"""업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다.
예전에는 테이블이 값을 통째로 들고 있었다(place_contents). 키가 place_id 라서
업장마다 TourAPI 응답이 복제됐다 실측(2026-09-09) 조이모텔 곳에 144행이고,
성남 중원구에 모텔이 들어오면 같은 축제가 벌이 된다.
실체는 `area_contents` , 여기에는 관계만 남긴다.
hidden 재수집이 덮어쓰지 않는다 운영자가 것을 다음 갱신이 되살리면
숨긴 의미가 없다."""
예전엔 값을 통째로 들고 키가 place_id 업장마다 복제됐다( 곳에 144).
hidden 재수집이 덮어쓰지 않는다."""
__tablename__ = "place_area_refs"

View File

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

View File

@ -18,10 +18,17 @@ from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config
# 모델이 쓰는 스키마. test DB 는 비어 있을 수 있어 create_all 전에 직접 만든다.
# 또 TRUNCATE 가 unqualified 테이블명을 쓰므로 이 스키마들을 search_path 에 얹어 해석시킨다.
# 도메인 모듈(places/facts/sites/local/reports)이 붙을 때마다 스키마를 여기에 추가한다.
_SCHEMAS = ("company", "place", "fact", "local", "site", "job")
# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는
# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를
# 얹지도 않는다.
#
# ★ 비울 표는 **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:
@ -81,22 +88,11 @@ async def db_engine(_test_db_lifecycle):
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
)
engine = create_async_engine(
_write_url(main_db_config),
connect_args={"server_settings": {"search_path": ",".join(_SCHEMAS) + ",public"}},
)
engine = create_async_engine(_write_url(main_db_config))
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
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute(
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"
)
)
await conn.execute(text(_truncate_sql()))
yield engine
await engine.dispose()

View File

@ -42,40 +42,6 @@ class LocalContentCRUD:
async def end(self, db, content_id):
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):
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.
@ -88,7 +54,14 @@ class LocalContentCRUD:
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
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_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
@ -129,7 +102,13 @@ class LocalContentCRUD:
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
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_={
"source": stmt.excluded.source,
"body": stmt.excluded.body,

View File

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

View File

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

View File

@ -447,7 +447,9 @@ class LocalContentService:
if not perplexity.is_configured():
return
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
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://...\"} } ] }",
"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": {
"kind": "people",
"label": "인물 열전",

View File

@ -85,6 +85,28 @@ _DEFAULT_LOOK = {
"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 다.
_DEFAULT_THEME = {
PlaceCategory.LODGING.value: {
"templateId": "stay-o2o-editorial",
"fontStyle": "Modern Editorial",
"colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff",
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
# ★ 숙박의 기본 템플릿은 '옛 항구'(stay-retro)다 (2026-09-10, 사장님 지시).
# 예전 값 "stay-o2o-editorial" 은 **어느 목록에도 없는 id** 였다 — 프론트가 가진
# 숙박 템플릿은 stay-simple · stay-magazine · stay-retro 셋뿐이라, 이 값이 실린
# 발행본은 에디터로 돌아왔을 때 고른 칩이 하나도 안 맞아 늘 첫 템플릿으로 그려졌다.
"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)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
# ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
# ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라
# ([+ 섹션 추가] 의 '뒤집어 보는 질문'), 켜지 않으면 지역 생성분이 어디에도 안 선다.
# 기본으로 켜지 않는 것은 의도다: 손님이 예약하러 온 화면에 퀴즈를 기본값으로
# 세우지 않는다. 넣고 싶은 사장님이 직접 넣는다.
"sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("event", "소식", False),
@ -353,7 +386,8 @@ def _theme(site, theme_spec: dict) -> dict:
if isinstance(look, dict)
else {}
)
out["look"] = {**_DEFAULT_LOOK, **cleaned}
# 업종 기본 look 이 있으면 그것을 바닥에 깐다(숙박 = 옛 항구). 없으면 공통 폴백이다.
out["look"] = {**theme_spec.get("look", _DEFAULT_LOOK), **cleaned}
return out
@ -479,6 +513,15 @@ def _yyyymmdd(value) -> str:
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):
"""FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다."""
body = row.get("body") or {}
@ -504,6 +547,17 @@ def _festival(row: dict):
# 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙).
"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:
entry["period"] = period
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}
async def has_stories(region_code: str) -> bool:
"""이 지역에 이미 이야기가 있나. cache-aside 판단용 — 한 건이라도 있으면 다시 부르지 않는다."""
async def missing_kinds(region_code: str) -> list[str]:
"""이 지역에 아직 없는 이야기 종류. cache-aside 판단용.
예전엔 `has_stories` 하나였다 ** 건이라도 있으면** 다시 부르지 않았다.
가드는 "같은 지역 두 번째 숙소" 생각한 것이라, **종류가 늘어난 ** 정확히 반대로
동작한다: 이미 다섯이 들어 있는 지역은 여섯 번째(`daily`) 영영 받는다.
지역에서만 여섯이 채워지고 기존 지역은 다섯에 멈춰, 같은 템플릿을 골라도 지역에
따라 수가 다른 상태가 된다(실측 2026-09-10, 52군산시).
요금 가드는 그대로다 없는 종류만 부른다. 이미 있는 종류는 여전히 번도 다시 부른다.
"""
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
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):
@ -185,12 +197,14 @@ async def run_local_sync(job: dict) -> dict:
if not perplexity.is_configured():
out["stories"] = {"skipped": "PERPLEXITY_API_KEY 미설정"}
return out
if await has_stories(region_code):
# ★ 같은 지역 두 번째 숙소다. 다시 부르면 같은 답에 요금만 두 번 낸다.
# ★ 잡이 종류를 지정했으면 그대로 따른다(재생성·보정용). 아니면 **없는 것만** 채운다 —
# 같은 지역 두 번째 숙소는 부를 것이 없어 곧바로 빠져나간다.
wanted = payload.get("kinds") or await missing_kinds(region_code)
if not wanted:
out["stories"] = {"skipped": "이미 있다"}
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
@ -213,7 +227,7 @@ async def enqueue_region_job(place) -> str | None:
"""업장의 지역 데이터 잡을 큐에 넣고 job_id 를 돌려준다. 지역을 모르면 넣지 않는다.
dedupe **업장 단위**(`local:{place_id}`). 반경 수집이 업장마다 필요해서다
지역 이야기의 중복 호출은 안의 `has_stories` 가드가 막는다.
지역 이야기의 중복 호출은 안의 `missing_kinds` 가드가 막는다.
부르는 곳이 둘이다: 수집 완료 직후(collect_service) 위저드의 생성 단계(place_service).
먼저 넣은 잡이 아직 살아 있으면 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 with db_engine.begin() as c:
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)"),
{"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": "외관"},
@ -366,7 +366,7 @@ async def test_렌더_보고서의_값이_그대로_박제된다(auth_headers, c
async with db_engine.begin() as c:
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"])},
)).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):
"""워커 1틱 — 큐에서 잡을 집어 실제 파이프라인을 돌린다."""
"""워커를 돌려 큐를 비운다. `job_id` 를 주면 그 잡이 끝날 때까지 돈다.
1틱만 돌리면 된다. 수집이 끝나면 **지역 이야기 (LOCAL_SYNC) 뒤따라 들어온다**
틱은 그걸 집어 가고, 정작 기다리던 수집 잡은 PENDING 채로 남는다. 테스트는
`job["result"]` 읽다가 KeyError 죽는데, 화면에는 "그냥 안 끝난 것" 으로 보인다.
큐에 뒤따르는 잡이 생길 때마다 헬퍼가 조용히 어긋나므로 개수를 세지 않고 비운다.
"""
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):

View File

@ -50,7 +50,7 @@ async def _place_with_facts(client, h, n_verified=5):
async def _faq_rows(db_engine, pid):
async with db_engine.begin() as c:
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)},
)).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:
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)},
)

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:
await conn.execute(
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())"
),
{
@ -74,7 +74,7 @@ async def test_candidates_coexist_with_published_value(db_engine, owner_id):
async with db_engine.begin() as conn:
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)},
)).all()
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:
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)},
)).all()
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:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")):
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},
)
@ -129,7 +129,7 @@ async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id):
unit_id = uuid.uuid4()
async with db_engine.begin() as conn:
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동"},
)
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()
async with db_engine.begin() as conn:
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)"),
{"f": fid, "p": uuid.UUID(pid), "q": question, "a": answer, "k": '["check_in_time"]',
"g": SourceType.LLM.value, "st": FactStatus.UNVERIFIED.value, "o": order},

View File

@ -36,6 +36,22 @@ def _parse_init_sql() -> dict:
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():
"""검증: init.sql 을 찾고 파싱할 수 있는지.
기대결과: 파일이 존재하고 CREATE TABLE 1 이상 파싱된다."""
@ -47,7 +63,7 @@ def test_every_model_table_exists_in_init_sql():
"""검증: ORM 모델의 모든 테이블이 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)
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():
"""검증: init.sql 의 모든 테이블에 ORM 모델이 있는지.
기대결과: 누락 없음 마이그레이션만 쓰고 모델을 만든 경우를 잡는다."""
sql_tables = set(_parse_init_sql())
model_tables = {f"{t.schema}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables}
sql_tables = set(_parse_init_sql()) - _NOT_ORM
model_tables = _model_tables()
missing = sorted(sql_tables - model_tables)
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()
problems = []
compared = 0
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:
continue
compared += 1
model_cols = {c.name for c in table.columns}
sql_cols = sql_tables[name]
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)}"
)
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():

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 with db_engine.begin() as c:
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())"),
{"f": uuid.uuid4(), "p": pid, "u": unit_id, "k": key, "v": 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 with db_engine.begin() as c:
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)"),
{"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)
uid = uuid.uuid4()
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동"})
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 를 다시 읽지 않는다.
# 거기서 지역 캐시를 읽으면 발행 시점과 렌더 시점 사이에 값이 바뀌어 '스냅샷과 다른 페이지'가 나온다.
async def _local(db_engine, region_code, content_type, status, title="지역행사", **cols):
from common.enums import LocalSource
async def _local(db_engine, region_code, kind, status, title="지역이야기", **cols):
"""지역 캐시(`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:
await c.execute(
text("INSERT INTO local_contents "
"(local_content_id, region_code, content_type, source, external_id, title, body, status, "
text("INSERT INTO area_contents "
"(local_content_id, region_code, content_type, source, title, body, status, kind, "
" display_start_at, display_end_at, collected_at) "
"VALUES (:i,:r,:ct,:src,:ext,:t,cast(:b as jsonb),:st,:ds,:de,now())"),
{"i": uuid.uuid4(), "r": region_code, "ct": content_type.value, "src": LocalSource.TOUR_API.value,
"ext": uuid.uuid4().hex, "t": title, "b": '{"title":"%s"}' % title, "st": status.value,
"VALUES (:i,:r,:ct,:src,:t,cast(:b as jsonb),:st,:k,:ds,:de,now())"),
{"i": uuid.uuid4(), "r": region_code, "ct": LocalContentType.STORY.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")},
)
@ -160,15 +170,16 @@ async def test_only_published_local_content_enters_snapshot(db_engine, owner_id)
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: PUBLISHED 담긴다 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지' 깨진다(fact VERIFIED 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType
from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제")
# kind 를 달리 준다 — 한 지역에 같은 kind 는 한 행이다(uq_local_contents_kind).
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "발행이야기")
await _local(db_engine, "4113500", "people", LocalContentStatus.REVIEW, "검수대기이야기")
await _local(db_engine, "4113500", "chronicle", LocalContentStatus.ENDED, "종료이야기")
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):
@ -176,56 +187,56 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, owner
기대결과: 빠진다 끝난 축제를 '이번 주말 행사' 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone
from common.enums import LocalContentStatus, LocalContentType
from common.enums import LocalContentStatus
now = datetime.now(timezone.utc)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "지금이야기")
await _local(db_engine, "4113500", "people", LocalContentStatus.PUBLISHED, "끝난이야기",
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))
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):
"""검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
from common.enums import LocalContentStatus
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
await _local(db_engine, "4113500", "songs", LocalContentStatus.PUBLISHED, "우리지역이야기")
await _local(db_engine, "5011025", "songs", LocalContentStatus.PUBLISHED, "남의지역이야기")
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):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 지역 콘텐츠를 담는다 places.region_code 채우는
코드가 생기기 전에 만들어진 사업장(실측 28 25) 영영 지역 정보 없이 발행되지 않게 한다."""
from common.enums import LocalContentStatus, LocalContentType
from common.enums import LocalContentStatus
from services.external.naver import region_key
derived = region_key(_Place("x").road_address)
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, ""))
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):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 목록 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
from common.enums import LocalContentStatus
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.road_address = None

View File

@ -7,7 +7,7 @@ import os
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.prompts import story as prompts # 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_산출물에서_온다():
"""섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
assert set(prompts.kinds()) == {"songs", "people", "chronicle", "postcard", "quiz"}
"""섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "postcard", "quiz"}
text = prompts.build_prompt("songs", "전북 군산시")
assert "[지역] 전북 군산시" in text
assert "[지역]을 노래한 대중가요" in text # task 원문
@ -37,6 +37,25 @@ def test_지역_생성은_업소를_가리키지_않는다():
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_출처가_없으면_항목을_버린다():
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():
"""검증: 스키마에 자리가 없는 TourAPI 필드(roomcount·scalelodging·foodplace).
"""검증: 스키마에 자리가 없는 TourAPI 필드(foodplace).
기대결과: 아무 key 로도 들어오지 않는다. 억지로 다른 key 넣으면
'식음료장 있음' '조식 제공' 으로 둔갑한다."""
values = {v for v in _map(ADAPTER._lodging_facts(_LODGING_INTRO)).values()}
assert "13실" not in values and "있음" not in values
assert not any("11,570" in str(v) for v in values)
'식음료장 있음' '조식 제공' 으로 둔갑한다.
roomcount·scalelodging 여기서 빠져 있었다. 2026-09-07 lodging 스키마에
자리가 생겨서(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():

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:
for i in range(n):
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)"),
{"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},
@ -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 with db_engine.begin() as c:
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"),
{"p": uuid.UUID(pid)},
)).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:
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)
})

View File

@ -54,9 +54,28 @@ const LOOK = {
function templatesFor(
industryId: IndustryType,
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[] {
return [
const items: TemplateItem[] = [
{
id: `${industryId}-simple`,
industryId,
@ -123,8 +142,13 @@ function templatesFor(
* . .
*/
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', {
name: '옛 항구',
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,
2,
),
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 .
· .`,
task: SECTION_PROMPTS.daily.task,
rules: SECTION_PROMPTS.daily.rules,
fields: [
{key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', 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) => {
set((state) => {
const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId);
const wanted = template?.defaultSectionTypes ?? [];
const turnOff = template?.disabledSectionTypes ?? [];
const variants = template?.defaultVariants ?? {};
const added = wanted
.filter((type) => !state.sections.some((sec) => sec.type === type))
.map(newSectionOf)
.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 {
templateId,
colorPaletteId: null,
sections: [
...state.sections.map((sec) =>
wanted.includes(sec.type) ? {...sec, isEnabled: true} : sec,
),
...added,
],
sections: [...state.sections.map(apply), ...added.map(apply)],
};
});
persistTheme();

View File

@ -33,7 +33,14 @@ function block(name) {
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 = {};
for (const kind of kinds) {
const body = src.match(new RegExp(`\\n ${kind}: \\{([\\s\\S]*?)\\n \\},`));

View File

@ -15,9 +15,24 @@
*/
/** 아이템 종류. `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 {
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: {
kind: 'people',
label: '인물 열전',

View File

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

View File

@ -45,6 +45,21 @@ export function youtubeSearchUrl(query: string): string {
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) {
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},
): string {
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)이 대중교통인 것과 반대인데,
// 그건 "방문자 → 업소"라 대중교통을 아는 사람이 적어서였고, 여기는 일정 안의 구간이다 —
// 선유도·장자도처럼 버스로는 답이 안 나오는 다리가 섞여 있다.
@ -90,7 +105,7 @@ export function naverDirectionsUrl(name: string, lat?: number, lng?: number): st
if (lat == null || lng == null) {
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 useEmblaCarousel from 'embla-carousel-react';
import {AUTOPLAY_MS, useRailAutoplay} from './use-rail-autoplay';
import {ChevronLeft, ChevronRight} from 'lucide-react';
interface CarouselProps {
@ -28,11 +29,44 @@ interface CarouselProps {
dots?: boolean;
/** 화살표를 헤더 줄에 둘지(기본) 슬라이더 위에 겹칠지. */
arrows?: 'header' | 'overlay' | 'none';
/** 슬라이드 사이 간격(rem). */
/**
* (rem).
*
* (2026-09-09, :
* "카로셀에서 간격정리 안된거 있는데?"). · 0.75, 1,
* 0(!).
* "붙었다" ** ** .
* ( ) 0 .
*/
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;
}
export function Carousel({
label,
children,
@ -41,8 +75,12 @@ export function Carousel({
dots = true,
arrows = 'header',
gap = 1,
autoplay = AUTOPLAY_MS,
onSelect,
onReady,
className,
}: CarouselProps) {
const autoplayMs = autoplay === false ? 0 : autoplay;
const [emblaRef, embla] = useEmblaCarousel({
align,
loop,
@ -94,7 +132,48 @@ export function Carousel({
};
}, [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]);
/*
* .
*
* 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 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() {
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({
region_code: regionCode!,
latitude: String(latitude),
longitude: String(longitude),
});
const response = await fetch(`${baseUrl}/v1/local/weather?${query}`, {
const response = await fetch(`/v1/local/weather?${query}`, {
signal: controller.signal,
});
if (!response.ok) return;

View File

@ -19,7 +19,7 @@ import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
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 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다.
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">
{season}
</h3>
<FestivalGrid items={items} />
<FestivalRail items={items} label={`${season} 축제`} remount={active} />
</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>
<FestivalGrid items={evergreen} />
<FestivalRail items={evergreen} label="계절 없이 열리는 행사" />
</div>
)}
</div>
@ -116,25 +116,39 @@ export function FestivalSection() {
}
/**
* ****, (2026-09-04, : "모바일에서 너무 길다")
* 390px 1,274px 1.7. .
* **·** .
* 400px 120px .
* CSS .
* **** (2026-09-09, : "계절별 축제 카로셀로")
* , .
* . Carousel .
* 0 embla .
* `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 (
<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) => (
<li key={festival.name}>
<CarouselSlide key={festival.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a
href={naverSearchUrl(festival.searchQuery)}
target="_blank"
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 ? (
<img
src={festival.imageUrl}
@ -164,7 +178,7 @@ function FestivalGrid({items}: {items: FestivalEntry[]}) {
</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>
{/*
<time> (2026-09-04, AEO '미래 날짜 표기')
@ -187,18 +201,18 @@ function FestivalGrid({items}: {items: FestivalEntry[]}) {
</span>
)}
{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}
</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>
<ArrowUpRight className="size-3.5" />
</span>
</span>
</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 {galleryImages} from '@site/lib/derive';
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);
}, [syncTrack]);
/*
* (`useRailAutoplay`).
* , `scrollRailNext` false .
* ( 1).
*/
useRailAutoplay({
box: track,
interval: AUTOPLAY_MS,
advance: () => scrollRailNext(track.current, 1),
});
const nudge = useCallback((dir: -1 | 1) => {
const box = track.current;
if (!box) return;
@ -96,7 +108,9 @@ export function GallerySection() {
<ul
ref={track}
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) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto">

View File

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

View File

@ -2,7 +2,7 @@ import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
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 {place, site} = payload;
// 확정된 채널만 — 확정 전 URL 은 동명 업소일 수 있다.
const links = payload.links.filter((link) => link.confirmed);
// ★ sameAs 와 같은 목록이어야 한다 — 화면에 없는 주소가 구조화 데이터에 있으면 발행이 막힌다.
const links = publicLinks(payload);
const address = place.roadAddress ?? place.address;
return (

View File

@ -27,7 +27,7 @@ export function SiteHeader() {
return (
<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)'}}
>
<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 {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 './weather.css';
@ -35,8 +35,17 @@ export function WeatherSection() {
if (!weather) return null;
const mood = weatherMood(weather.condition);
const band = weatherBand(weather.temperature);
// 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다.
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);
return (
@ -73,6 +82,22 @@ export function WeatherSection() {
{line}
</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>
</Card>
</Section>

View File

@ -19,7 +19,7 @@
* FestivalSection .
* ** **. `useState` SSR .
*/
import {useState} from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
@ -61,6 +61,13 @@ function DayCard({
const day = planDay({name: item.name, startTime, stops});
return (
/*
* (2026-09-09, : "간격 다 똑같이")
* `ml-6 border-l-2` 48px .
* , ** **(DurationRail)
* .
* , .
*/
<article
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
@ -86,9 +93,14 @@ function DayCard({
</div>
<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>}
</div>
@ -99,7 +111,15 @@ function DayCard({
<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>
<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>}
<div className="flex items-start gap-2.5 pt-1">
{/* 사진은 있을 때만. 없으면 빈 회색 상자 대신 활자만 선다(이 팩의 규칙). */}
@ -198,38 +218,103 @@ export function ItinerarySection() {
</div>
)}
{/*
= (2026-09-09, : "추천일정 1열로 만들어서 auto play")
() + .
.
0 embla key .
*/}
{tabs.map((tab) => (
<div key={tab} hidden={tab !== active} className="space-y-10">
{items
.filter((item) => (item.duration?.trim() || NO_DURATION) === tab)
.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-3">
<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>
<Rail label={`${item.name} 일정`}>
{daysOf(item).map((day, dayIndex) => (
<DayCard
key={`${item.name}-${dayIndex}`}
item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime}
stops={day.stops}
first={dayIndex === 0}
/>
))}
</Rail>
</div>
))}
<div key={tab} hidden={tab !== active}>
<DurationRail
key={active}
tab={tab}
items={items.filter((item) => (item.duration?.trim() || NO_DURATION) === tab)}
/>
</div>
))}
</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
key={`${item.name}-${start}-${dayIndex}`}
item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime}
stops={day.stops}
first={dayIndex === 0}
/>
)),
)}
</Rail>
</>
);
}

View File

@ -33,6 +33,7 @@ import {
youtubePoster,
type VideoItem,
} from '@o2o/shared';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
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'});
}, []);
/*
* ** **(`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;
// 한 편뿐이면 밀 곳이 없다 — 슬라이더로 만들지 않는다.
@ -87,11 +99,17 @@ export function VideoSection() {
<ul
ref={scroller}
onScroll={syncEdges}
className={`gap-5 ${
/*
* ** ** (2026-09-09, : "간격없이 붙는거 다 수정해")
* `gap-5` `gap-3` `lg:gap-0`
* (1280px): 3 0px .
* (16px) Carousel `gap` .
*/
className={
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'
: 'grid grid-cols-1'
}`}
? '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 gap-4'
}
>
{parsed.items.map((item, index) => {
const id = youtubeId(item.url);

View File

@ -161,6 +161,8 @@ export function Rail({
children,
label,
arrows = 'header',
onSelect,
onReady,
}: {
children: ReactNode;
label: string;
@ -169,9 +171,12 @@ export function Rail({
* .
*/
arrows?: 'header' | 'overlay';
/** 지금 몇 번째 장인지 알아야 하는 레일에만 준다(추천 일정의 코스 줄). Carousel 주석 참조. */
onSelect?: (index: number) => void;
onReady?: (api: {scrollTo: (index: number) => void}) => void;
}) {
return (
<Carousel label={label} gap={1} arrows={arrows}>
<Carousel label={label} gap={1} arrows={arrows} onSelect={onSelect} onReady={onReady}>
{children}
</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',
}),
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 검색이 오래된 페이지를 뒤로 미룬다.
tag('meta', {name: 'dateModified', content: site.updatedAt}),

View File

@ -8,6 +8,7 @@ import {
sanitizeUnits,
selectPublishable,
selectPublishableFaqs,
type ChannelLink,
type FactEntry,
type SitePayload,
type UnitInfo,
@ -97,18 +98,45 @@ function geoCoordinates(payload: SitePayload): Json | undefined {
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[] {
return payload.media
.filter((m) => m.alt?.trim())
.sort((a, b) => Number(Boolean(b.isPrimary)) - Number(Boolean(a.isPrimary)))
.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[] {
return payload.links.filter((link) => link.confirmed && link.url).map((link) => link.url);
return publicLinks(payload).map((link) => link.url);
}
/** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */

View File

@ -125,6 +125,22 @@ function* iterScalars(node: unknown, path: string[] = []): Generator<[string[],
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 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */
function asShown(value: Scalar): string[] {
if (typeof value === 'number' && Number.isFinite(value)) {
@ -174,10 +190,27 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (token.startsWith('http://') || token.startsWith('https://')) {
if (!html.includes(token) && !unescaped.includes(token)) {
report(`${prop}: '${token}' 이 화면에 없다`);
if (isUrlLike(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;
}
if (asShown(value).some((shown) => text.includes(shown))) continue;

View File

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