[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:
parent
3910feddfb
commit
328d9e18ee
@ -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. 키는 업장이 아니라 지역이다
|
||||
|
||||
이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다.
|
||||
|
||||
@ -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 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈)
|
||||
|
||||
**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
|
||||
|
||||
@ -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 는 읽는 쪽이 옮겨간 뒤 별도 번호로 뗀다.
|
||||
|
||||
@ -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 는 읽는 코드가 옮겨간 뒤 별도 번호로 뗀다.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 이다.
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)만 걸고
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
4
solution/backend/services/external/kakao.py
vendored
4
solution/backend/services/external/kakao.py
vendored
@ -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 를 못 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다.
|
||||
|
||||
4
solution/backend/services/external/naver.py
vendored
4
solution/backend/services/external/naver.py
vendored
@ -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:
|
||||
|
||||
@ -20,6 +20,7 @@ from common.logger import LOG
|
||||
# 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다).
|
||||
_TITLE_KEY = {
|
||||
"songs": "title",
|
||||
"daily": "title",
|
||||
"people": "name",
|
||||
"chronicle": "title",
|
||||
"postcard": "line",
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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": "인물 열전",
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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 를 돌려준다 — 위저드는 그걸 기다린다.
|
||||
"""
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)},
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":"금강 나그네"}]}')
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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'},
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 \\},`));
|
||||
|
||||
@ -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: '인물 열전',
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
2230
solution/site/payloads-mockup/stay.json
Normal file
2230
solution/site/payloads-mockup/stay.json
Normal file
File diff suppressed because one or more lines are too long
2230
solution/site/payloads-mockup/stay.json.moved-from-payloads
Normal file
2230
solution/site/payloads-mockup/stay.json.moved-from-payloads
Normal file
File diff suppressed because one or more lines are too long
@ -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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터.
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
153
solution/site/src/lib/ui/use-rail-autoplay.ts
Normal file
153
solution/site/src/lib/ui/use-rail-autoplay.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}),
|
||||
|
||||
@ -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 에 전용 속성이 있다. 있으면 반드시 채운다. */
|
||||
|
||||
@ -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 엔티티로 이스케이프된 자리(& 를 낀 쿼리)는
|
||||
* 원문 그대로는 안 잡힌다.
|
||||
*/
|
||||
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;
|
||||
|
||||
14
solution/site/src/vite-env.d.ts
vendored
14
solution/site/src/vite-env.d.ts
vendored
@ -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 주석). 선언 자체를 없애 다시 생기지 않게 한다.
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user