From 328d9e18ee878b758d18d7bac09fab629c7ea6bf Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 10 Sep 2026 14:36:00 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EC=9D=B4=EC=95=BC=EA=B8=B0=20=EC=83=9D=EC=84=B1=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EB=B3=B8=20=EC=84=B9=EC=85=98=20=EC=86=90?= =?UTF-8?q?=EC=A7=88=20=C2=B7=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A3=BC=EC=84=9D=20=EC=B6=95=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: 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) --- docs/DECISIONS.md | 9 +- docs/DEVLOG.md | 76 + .../migrations/0003_site_contents.sql | 30 +- .../migrations/0004_local_contents_unify.sql | 38 +- .../migrations/0005_flatten_schemas.sql | 48 +- .../migrations/0006_prune_unused.sql | 24 +- .../migrations/0007_story_rows_per_kind.sql | 15 +- .../0008_personalization_to_site_sections.sql | 42 +- .../migrations/0009_align_with_init_sql.sql | 11 +- .../backend/common/database/model/models.py | 37 +- solution/backend/common/enums.py | 9 +- solution/backend/conftest.py | 30 +- solution/backend/crud/local_content_crud.py | 51 +- solution/backend/services/external/kakao.py | 4 +- solution/backend/services/external/naver.py | 4 +- solution/backend/services/grounding/story.py | 1 + .../backend/services/local_content_service.py | 4 +- .../services/prompts/section_prompts.json | 7 + solution/backend/services/site_payload.py | 66 +- solution/backend/services/story_service.py | 28 +- solution/backend/tests/test_build_publish.py | 4 +- .../backend/tests/test_collect_pipeline.py | 15 +- solution/backend/tests/test_copy_api.py | 4 +- solution/backend/tests/test_fact_schema.py | 10 +- solution/backend/tests/test_faq_api.py | 2 +- solution/backend/tests/test_schema_ddl.py | 31 +- solution/backend/tests/test_snapshot.py | 69 +- .../backend/tests/test_story_generation.py | 25 +- .../backend/tests/test_tour_api_adapter.py | 20 +- solution/backend/tests/test_vision_api.py | 4 +- solution/backend/tests/test_weather_api.py | 2 +- solution/frontend/src/data/industryData.ts | 32 +- .../src/features/builder/canvas/dataSpec.ts | 17 +- solution/frontend/src/stores/builder.ts | 25 +- solution/shared/scripts/export-prompts.mjs | 9 +- solution/shared/src/lib/section-prompts.ts | 41 +- solution/shared/src/types/builder.ts | 22 + solution/shared/src/types/site-payload.ts | 31 +- solution/site/payloads-mockup/stay.json | 2230 +++++++++++++++++ .../stay.json.moved-from-payloads | 2230 +++++++++++++++++ solution/site/src/lib/derive.ts | 21 +- solution/site/src/lib/format.ts | 21 +- solution/site/src/lib/ui/Carousel.tsx | 81 +- solution/site/src/lib/ui/use-rail-autoplay.ts | 153 ++ solution/site/src/lib/use-live-weather.ts | 17 +- .../site/src/sections/FestivalSection.tsx | 50 +- solution/site/src/sections/GallerySection.tsx | 16 +- .../site/src/sections/LocalGuideSection.tsx | 2 +- solution/site/src/sections/SiteFooter.tsx | 5 +- solution/site/src/sections/SiteHeader.tsx | 2 +- solution/site/src/sections/WeatherSection.tsx | 27 +- .../src/sections/items/ItinerarySection.tsx | 153 +- .../site/src/sections/items/VideoSection.tsx | 26 +- solution/site/src/sections/items/common.tsx | 7 +- solution/site/src/seo/head.ts | 10 + solution/site/src/seo/jsonld.ts | 36 +- solution/site/src/seo/verify.ts | 39 +- solution/site/src/vite-env.d.ts | 14 +- 58 files changed, 5625 insertions(+), 412 deletions(-) create mode 100644 solution/site/payloads-mockup/stay.json create mode 100644 solution/site/payloads-mockup/stay.json.moved-from-payloads create mode 100644 solution/site/src/lib/ui/use-rail-autoplay.ts diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ec820d4..e7b38b4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. 키는 업장이 아니라 지역이다 이 다섯은 업장의 사실이 아니라 **도시의 사실**이다. 군산 이야기는 군산 숙소가 같이 쓴다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a172cad..ebcc8c9 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈) **무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. diff --git a/postgres-init/migrations/0003_site_contents.sql b/postgres-init/migrations/0003_site_contents.sql index b081699..b1c222c 100644 --- a/postgres-init/migrations/0003_site_contents.sql +++ b/postgres-init/migrations/0003_site_contents.sql @@ -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 는 읽는 쪽이 옮겨간 뒤 별도 번호로 뗀다. diff --git a/postgres-init/migrations/0004_local_contents_unify.sql b/postgres-init/migrations/0004_local_contents_unify.sql index 46df64d..755000d 100644 --- a/postgres-init/migrations/0004_local_contents_unify.sql +++ b/postgres-init/migrations/0004_local_contents_unify.sql @@ -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 는 읽는 코드가 옮겨간 뒤 별도 번호로 뗀다. diff --git a/postgres-init/migrations/0005_flatten_schemas.sql b/postgres-init/migrations/0005_flatten_schemas.sql index 1d949f0..ba0198a 100644 --- a/postgres-init/migrations/0005_flatten_schemas.sql +++ b/postgres-init/migrations/0005_flatten_schemas.sql @@ -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; diff --git a/postgres-init/migrations/0006_prune_unused.sql b/postgres-init/migrations/0006_prune_unused.sql index 25fd606..2c97167 100644 --- a/postgres-init/migrations/0006_prune_unused.sql +++ b/postgres-init/migrations/0006_prune_unused.sql @@ -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 이다. diff --git a/postgres-init/migrations/0007_story_rows_per_kind.sql b/postgres-init/migrations/0007_story_rows_per_kind.sql index d242a60..8317694 100644 --- a/postgres-init/migrations/0007_story_rows_per_kind.sql +++ b/postgres-init/migrations/0007_story_rows_per_kind.sql @@ -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; diff --git a/postgres-init/migrations/0008_personalization_to_site_sections.sql b/postgres-init/migrations/0008_personalization_to_site_sections.sql index 6b5cfdb..ed8627c 100644 --- a/postgres-init/migrations/0008_personalization_to_site_sections.sql +++ b/postgres-init/migrations/0008_personalization_to_site_sections.sql @@ -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', diff --git a/postgres-init/migrations/0009_align_with_init_sql.sql b/postgres-init/migrations/0009_align_with_init_sql.sql index c6c656b..dc8d996 100644 --- a/postgres-init/migrations/0009_align_with_init_sql.sql +++ b/postgres-init/migrations/0009_align_with_init_sql.sql @@ -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)만 걸고 diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index fba9520..62e7bd4 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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" diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index b0c6c41..defab18 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -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): diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index ec606ed..3e66d99 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -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() diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 9d06460..4c0a717 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -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, diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index 5fcde48..c581890 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -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 를 못 주면 캐시 없이 부르고 있다는 뜻이라 경고를 남긴다. diff --git a/solution/backend/services/external/naver.py b/solution/backend/services/external/naver.py index 2ed64e1..34bbda4 100644 --- a/solution/backend/services/external/naver.py +++ b/solution/backend/services/external/naver.py @@ -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: diff --git a/solution/backend/services/grounding/story.py b/solution/backend/services/grounding/story.py index fedb0eb..1c6441a 100644 --- a/solution/backend/services/grounding/story.py +++ b/solution/backend/services/grounding/story.py @@ -20,6 +20,7 @@ from common.logger import LOG # 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다). _TITLE_KEY = { "songs": "title", + "daily": "title", "people": "name", "chronicle": "title", "postcard": "line", diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 85f5595..1cff4c1 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -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) diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 9535833..0c8408a 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -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": "인물 열전", diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index e51d959..2c4f061 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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")) diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index ee34a61..dcaab57 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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 를 돌려준다 — 위저드는 그걸 기다린다. """ diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 87c5f78..844fc4f 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -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() diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 5ca4391..204d021 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -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): diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 03f1586..e0e090f 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -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)}, ) diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py index a357071..e4ff3d3 100644 --- a/solution/backend/tests/test_fact_schema.py +++ b/solution/backend/tests/test_fact_schema.py @@ -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) diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index 50b0ad0..691318b 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -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}, diff --git a/solution/backend/tests/test_schema_ddl.py b/solution/backend/tests/test_schema_ddl.py index 9707077..44923e0 100644 --- a/solution/backend/tests/test_schema_ddl.py +++ b/solution/backend/tests/test_schema_ddl.py @@ -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(): diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index 38ca3e5..a39b7a3 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -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 diff --git a/solution/backend/tests/test_story_generation.py b/solution/backend/tests/test_story_generation.py index c9267fa..2195fa4 100644 --- a/solution/backend/tests/test_story_generation.py +++ b/solution/backend/tests/test_story_generation.py @@ -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":"금강 나그네"}]}') diff --git a/solution/backend/tests/test_tour_api_adapter.py b/solution/backend/tests/test_tour_api_adapter.py index 5b82c13..a99fa63 100644 --- a/solution/backend/tests/test_tour_api_adapter.py +++ b/solution/backend/tests/test_tour_api_adapter.py @@ -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(): diff --git a/solution/backend/tests/test_vision_api.py b/solution/backend/tests/test_vision_api.py index c7e37fd..5851682 100644 --- a/solution/backend/tests/test_vision_api.py +++ b/solution/backend/tests/test_vision_api.py @@ -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() diff --git a/solution/backend/tests/test_weather_api.py b/solution/backend/tests/test_weather_api.py index 22be982..2e6f38f 100644 --- a/solution/backend/tests/test_weather_api.py +++ b/solution/backend/tests/test_weather_api.py @@ -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) }) diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index ce60a5a..e26bc81 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -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; + /** + * 이 업종의 **기본 템플릿**으로 세운다 — 목록 맨 앞에 온다. + * + * ★ 순서가 곧 기본값이다. 에디터는 저장된 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 = { templates: templatesFor('stay', '#2563eb', { name: '옛 항구', description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', + // 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다. + isDefault: true, + /** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */ + defaultVariants: {photos: 'photos.carousel'}, }), }, diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index b7fcd1d..708ac45 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -216,21 +216,8 @@ export const SECTION_DATA_SPEC: Record = { 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}, diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index b5c5da7..2adaa0d 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -474,24 +474,37 @@ export const useBuilderStore = create((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(); diff --git a/solution/shared/scripts/export-prompts.mjs b/solution/shared/scripts/export-prompts.mjs index b426ab3..f8bbb63 100644 --- a/solution/shared/scripts/export-prompts.mjs +++ b/solution/shared/scripts/export-prompts.mjs @@ -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 \\},`)); diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index dbbd325..f67a1bb 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -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 = { `, }, + 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: '인물 열전', diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 0db7924..d06bf7f 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -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; } /** diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 5f730e9..8b017f5 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -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>; + /** + * 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.** + * + * ★ `notes` 와 갈라 둔다 (2026-09-09, 사장님: "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰") + * `notes` 는 하늘을 보고 **집 안에서** 뭘 할지 적은 문장이고(마당·CAFÉ·기와), + * 이쪽은 기온을 보고 **밖에서** 어디를 갈지 적은 문장이다. 축이 둘이라 한 칸에 + * 담으면 4×5 스무 문장을 사장님이 적어야 한다 — 두 줄로 나란히 내면 넷+다섯이면 된다. + * ★ 여기 적는 곳은 이 사이트에 **이미 있는 자리**여야 한다(주변 안내·축제). + * 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다. + */ + tempNotes?: Partial>; observedAt: string; } diff --git a/solution/site/payloads-mockup/stay.json b/solution/site/payloads-mockup/stay.json new file mode 100644 index 0000000..0feb41a --- /dev/null +++ b/solution/site/payloads-mockup/stay.json @@ -0,0 +1,2230 @@ +{ + "schemaVersion": 1, + "site": { + "siteId": "e929e6b4-e502-4761-9786-0bb82178b375", + "placeId": "66894a1b-a331-43f1-942c-0ae064a704d6", + "status": 3, + "origin": "http://localhost", + "basePath": "/s/stay", + "slug": "stay", + "publishedAt": "2026-09-03T04:38:32.290153+00:00", + "updatedAt": "2026-09-02T23:31:05.719599+00:00", + "version": 13 + }, + "place": { + "name": "스테이,머뭄", + "category": 1, + "roadAddress": "전북 군산시 절골길 18", + "address": "전북 군산시 신흥동 63-18", + "addressRegion": "전북", + "addressLocality": "군산시", + "addressSubLocality": null, + "addressRegionCode": "KR-45", + "phone": "0507-1497-0983", + "latitude": 35.9864175, + "longitude": 126.7061254, + "regionCode": "52군산시", + "kakaoPlaceId": null + }, + "facts": [ + { + "key": "parking", + "label": "주차 가능", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.678003+00:00", + "verifiedAt": "2026-08-28T02:12:21.402109+00:00" + }, + { + "key": "intro", + "label": "숙소 소개", + "value": "편안한 침구와 좋은 위치로 편안한 잠자리", + "unit": null, + "type": "text", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.547669+00:00", + "verifiedAt": "2026-08-28T02:12:21.922925+00:00" + }, + { + "key": "wifi", + "label": "와이파이", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.757070+00:00", + "verifiedAt": "2026-08-28T02:12:22.356600+00:00" + }, + { + "key": "check_out_time", + "label": "체크아웃 시간", + "value": "11:00", + "unit": null, + "type": "time", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "cancel_policy", + "label": "취소·환불 규정", + "value": "입실 당일 취소와 노쇼(No Show)는 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", + "unit": null, + "type": "text", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "cooking_allowed", + "label": "취사 가능", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "pet_allowed", + "label": "반려동물 동반", + "value": "false", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "smoking", + "label": "흡연 가능", + "value": "false", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "extra_person_fee", + "label": "인원 추가 요금", + "value": "20000", + "unit": "원", + "type": "number", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "check_in_time", + "label": "체크인 시간", + "value": "15:00", + "unit": null, + "type": "time", + "scope": "place", + "status": 3, + "sourceType": 1, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T23:31:05.719599+00:00" + } + ], + "units": [ + { + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "name": "A동", + "slug": "a동", + "sortOrder": 0, + "facts": [ + { + "key": "room_type", + "label": "객실 타입", + "value": "독채 · 일본식 가정집", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "standard_capacity", + "label": "기준 인원", + "value": "2", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "max_capacity", + "label": "최대 인원", + "value": "4", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bed_type", + "label": "침대 구성", + "value": "퀸 사이즈 침대 1개", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bathroom_count", + "label": "욕실 수", + "value": "1", + "unit": "개", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "has_kitchen", + "label": "주방 여부", + "value": "true", + "unit": null, + "type": "bool", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "view", + "label": "전망", + "value": "정원", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "weekday_price", + "label": "주중 요금", + "value": "198000", + "unit": "원", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "room_intro", + "label": "객실 소개", + "value": "일본식 가정집 느낌으로 꾸민 독채입니다. 디딤돌 마당과 독립된 정원, 창고형 카페 공간이 딸려 있습니다.", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + } + ], + "mediaIds": [] + }, + { + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "name": "B동", + "slug": "b동", + "sortOrder": 1, + "facts": [ + { + "key": "room_type", + "label": "객실 타입", + "value": "독채 · 현대식 모던", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "standard_capacity", + "label": "기준 인원", + "value": "2", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "max_capacity", + "label": "최대 인원", + "value": "4", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bed_type", + "label": "침대 구성", + "value": "킹 사이즈 침대 1개", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://nol.yanolja.com/stay/domestic/10068088", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bathroom_count", + "label": "욕실 수", + "value": "1", + "unit": "개", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "has_kitchen", + "label": "주방 여부", + "value": "true", + "unit": null, + "type": "bool", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "view", + "label": "전망", + "value": "정원", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "weekday_price", + "label": "주중 요금", + "value": "198000", + "unit": "원", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "room_intro", + "label": "객실 소개", + "value": "아늑하고 깔끔한 현대식 모던 스타일 독채입니다. 독립된 정원과 창고형 카페 공간이 딸려 있습니다.", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + } + ], + "mediaIds": [] + } + ], + "media": [ + { + "mediaId": "1330beb8-09d3-47fc-a410-f2e9598d13fe", + "url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "alt": "목재 서까래 천장과 미닫이창, 테이블이 있는 거실 공간", + "category": "거실", + "width": null, + "height": null, + "isPrimary": true, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "unitId": null + }, + { + "mediaId": "ad35769c-5299-4d39-b80d-5d88f1e1de14", + "url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "alt": "소파와 탁자, 벽난로 조명이 있는 서까래 천장의 거실", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "unitId": null + }, + { + "mediaId": "83a2543f-4366-4427-b694-a7b1968a61cf", + "url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "alt": "싱크대와 냉장고, 전자레인지가 구비된 실내 주방 공간", + "category": "주방", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "unitId": null + }, + { + "mediaId": "990a89ed-9517-426c-8c17-fd9bf3c1a44d", + "url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "alt": "주방 싱크대 앞에서 포즈를 취하고 있는 여성의 모습", + "category": "주방", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "unitId": null + }, + { + "mediaId": "b60e19c4-26dd-4ac5-a135-5496ca0a1471", + "url": "https://clip-service-phinf.pstatic.net/MjAyNjA3MzFfMjY4/MDAxNzg1NDc0MTE1NDk2.rBM4u6Y4G2x28WBTkBInTC4w0mCkt7SOWWUiFo0vNpQg.9B08LAonur-VaPCjpNLrn7Nh6VSHkt-uljDLRwIYuIUg.JPEG/thumbnail-9F1357F4-D8C1-412C-AF03-E53E1CCD3F14.jpg", + "alt": "해변과 푸른 바다, 산이 어우러진 해안가 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://clip-service-phinf.pstatic.net/MjAyNjA3MzFfMjY4/MDAxNzg1NDc0MTE1NDk2.rBM4u6Y4G2x28WBTkBInTC4w0mCkt7SOWWUiFo0vNpQg.9B08LAonur-VaPCjpNLrn7Nh6VSHkt-uljDLRwIYuIUg.JPEG/thumbnail-9F1357F4-D8C1-412C-AF03-E53E1CCD3F14.jpg", + "unitId": null + }, + { + "mediaId": "5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", + "url": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", + "alt": "벽면에 설치된 그림 화면과 벽난로 조명 장식", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", + "unitId": null + }, + { + "mediaId": "2fbcd37e-260f-4a4f-b00f-3e997e978c02", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", + "alt": "창밖으로 조명을 비춘 정원 나무와 석등이 보이는 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", + "unitId": null + }, + { + "mediaId": "d95857a0-44ad-422b-86de-00dee8aca08f", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", + "alt": "목재 서까래 천장에 설치된 조형적인 디자인의 조명 기구", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", + "unitId": null + }, + { + "mediaId": "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", + "alt": "실내 벽면 홈 공간에 놓인 흰색 화병과 식물 장식", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", + "unitId": null + }, + { + "mediaId": "4981b572-10e2-4138-ba1d-a5d7defb4b4a", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", + "alt": "실내 창문을 통해 바라본 푸른 하늘과 푸른 나무 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", + "unitId": null + } + ], + "faqs": [ + { + "faqId": "8d48a94d-1a19-456f-b1a8-4144ddcb2186", + "question": "체크인과 체크아웃 시간은 언제인가요?", + "answer": "체크인은 15:00부터이며, 체크아웃은 11:00까지입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 0 + }, + { + "faqId": "8f14678b-3589-42e9-8896-d214c0093784", + "question": "객실 기준 인원과 최대 인원, 인원 추가 요금은 어떻게 되나요?", + "answer": "A동과 B동 모두 기준 인원은 2명이며, 최대 4명까지 투숙하실 수 있습니다. 인원 추가 요금은 20,000원입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 1 + }, + { + "faqId": "a13785c0-a627-44b5-b593-fac39712d6d4", + "question": "객실 내 취사가 가능한가요?", + "answer": "네, 숙소 내 취사가 가능하며 A동과 B동 모두 주방 시설을 갖추고 있습니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 2 + }, + { + "faqId": "0eb535db-135f-484b-b0a4-1323744ace75", + "question": "주차와 와이파이 이용이 가능한가요?", + "answer": "네, 주차와 와이파이 모두 이용 가능합니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 3 + }, + { + "faqId": "cc268677-ad26-4685-a6a6-10a293658591", + "question": "반려동물 동반이나 실내 흡연이 가능한가요?", + "answer": "반려동물 동반은 불가하며, 전 구역 흡연이 불가능합니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 4 + }, + { + "faqId": "4b8ac158-7648-4b2c-aa1a-9c6ca99b1223", + "question": "A동과 B동의 침대 구성과 객실 특징은 무엇인가요?", + "answer": "A동은 퀸 사이즈 침대 1개가 구비된 일본식 가정집 느낌의 독채이며 디딤돌 마당, 정원, 창고형 카페 공간이 있습니다. B동은 킹 사이즈 침대 1개가 구비된 현대식 모던 독채로 독립된 정원과 창고형 카페 공간을 갖추고 있습니다. 두 객실 모두 욕실은 1개씩 마련되어 있습니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 5 + }, + { + "faqId": "8bd75b48-49c3-475f-99c1-f5e64f49bedd", + "question": "객실 요금은 얼마인가요?", + "answer": "A동과 B동의 주중 요금은 각각 198,000원입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 6 + }, + { + "faqId": "7b2bbfeb-761c-4a15-b410-43e18a69bb9e", + "question": "예약 취소 및 환불 규정은 어떻게 되나요?", + "answer": "입실 당일 취소와 노쇼(No Show)의 경우 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 7 + } + ], + "links": [ + { + "channel": 3, + "url": "https://m.place.naver.com/place/1133638931/home", + "title": "스테이,머뭄 네이버 플레이스", + "confirmed": true + } + ], + "local": { + "attractions": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "category": "관광지", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "location": "전북특별자치도 군산시 구영1길 17", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/70/3351370_image2_1.jpg", + "distanceText": "13m", + "distanceMeters": 13 + }, + { + "name": "초원사진관", + "category": "관광지", + "searchQuery": "초원사진관", + "location": "전북특별자치도 군산시 구영2길 12-1 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/53/3039553_image2_1.jpg", + "distanceText": "248m", + "distanceMeters": 248 + }, + { + "name": "군산 영화의 거리", + "category": "관광지", + "searchQuery": "군산 영화의 거리", + "location": "전북특별자치도 군산시 구영6길 60 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/44/3522144_image2_1.jpg", + "distanceText": "318m", + "distanceMeters": 318 + }, + { + "name": "우체통거리", + "category": "관광지", + "searchQuery": "우체통거리", + "location": "전북특별자치도 군산시 우체통거리2길 19 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/3549620_image2_1.jpg", + "distanceText": "566m", + "distanceMeters": 566 + }, + { + "name": "군산근대미술관", + "category": "관광지", + "searchQuery": "군산근대미술관", + "location": "전북특별자치도 군산시 해망로 230", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/51/2657651_image2_1.jpg", + "distanceText": "708m", + "distanceMeters": 708 + }, + { + "name": "옛 군산세관", + "category": "관광지", + "searchQuery": "옛 군산세관", + "location": "전북특별자치도 군산시 해망로 244-7", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/19/3530419_image2_1.jpg", + "distanceText": "728m", + "distanceMeters": 728 + }, + { + "name": "근대건축관 (구)조선은행군산지점", + "category": "관광지", + "searchQuery": "근대건축관 (구)조선은행군산지점", + "location": "전북특별자치도 군산시 해망로 214", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/19/3351519_image2_1.jpg", + "distanceText": "788m", + "distanceMeters": 788 + }, + { + "name": "군산 시간여행마을", + "category": "관광지", + "searchQuery": "군산 시간여행마을", + "location": "전북특별자치도 군산시 내항1길 8", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/32/2642432_image2_1.jpg", + "distanceText": "1.1km", + "distanceMeters": 1097 + }, + { + "name": "월명호수", + "category": "관광지", + "searchQuery": "월명호수", + "location": "전북특별자치도 군산시 소룡동 1071-3", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/22/3055522_image2_1.jpg", + "distanceText": "1.6km", + "distanceMeters": 1633 + }, + { + "name": "은적사(군산)", + "category": "관광지", + "searchQuery": "은적사(군산)", + "location": "전북특별자치도 군산시 설림3길 49 (소룡동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/14/3055614_image2_1.jpg", + "distanceText": "2.3km", + "distanceMeters": 2271 + }, + { + "name": "서천문화예술창작공간", + "category": "관광지", + "searchQuery": "서천문화예술창작공간", + "location": "충청남도 서천군 장항읍 장산로 323", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/80/3381180_image2_1.JPG", + "distanceText": "2.6km", + "distanceMeters": 2596 + }, + { + "name": "경암동 철길마을", + "category": "관광지", + "searchQuery": "경암동 철길마을", + "location": "전북특별자치도 군산시 경촌4길 14", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/50/3080250_image2_1.JPG", + "distanceText": "2.7km", + "distanceMeters": 2734 + }, + { + "name": "장항항", + "category": "관광지", + "searchQuery": "장항항", + "location": "충청남도 서천군 장항읍 장산로 232", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/82/2786282_image2_1.jpg", + "distanceText": "3.1km", + "distanceMeters": 3146 + }, + { + "name": "금강하굿둑놀이동산", + "category": "관광지", + "searchQuery": "금강하굿둑놀이동산", + "location": "충청남도 서천군 마서면 장산로855번길 13", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/64/3567064_image2_1.jpg", + "distanceText": "4.5km", + "distanceMeters": 4502 + }, + { + "name": "금강2경 도보여행길", + "category": "관광지", + "searchQuery": "금강2경 도보여행길", + "location": "충청남도 서천군 마서면 장산로855번길 56-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/65/3560165_image2_1.jpg", + "distanceText": "4.6km", + "distanceMeters": 4612 + }, + { + "name": "금강하구둑 관광지", + "category": "관광지", + "searchQuery": "금강하구둑 관광지", + "location": "충청남도 서천군 마서면 장산로855번길 56-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/91/3438991_image2_1.png", + "distanceText": "4.6km", + "distanceMeters": 4612 + }, + { + "name": "장항송림자연휴양림", + "category": "관광지", + "searchQuery": "장항송림자연휴양림", + "location": "충청남도 서천군 장항읍 장항산단로34번길 122-16", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/72/3529172_image2_1.jpg", + "distanceText": "5.1km", + "distanceMeters": 5065 + }, + { + "name": "금강하구둑(군산)", + "category": "관광지", + "searchQuery": "금강하구둑(군산)", + "location": "전북특별자치도 군산시 성산면 철새로 120", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/85/3416685_image2_1.JPG", + "distanceText": "5.1km", + "distanceMeters": 5073 + }, + { + "name": "이영춘가옥", + "category": "관광지", + "searchQuery": "이영춘가옥", + "location": "전북특별자치도 군산시 동개정길 7", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/14/2658214_image2_1.jpg", + "distanceText": "5.1km", + "distanceMeters": 5095 + }, + { + "name": "금강하구 철새도래지 (서천)", + "category": "관광지", + "searchQuery": "금강하구 철새도래지 (서천)", + "location": "충청남도 서천군 마서면 장산로 916", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/31/2786231_image2_1.jpg", + "distanceText": "5.2km", + "distanceMeters": 5188 + } + ], + "restaurants": [ + { + "name": "도란", + "category": "맛집", + "searchQuery": "도란", + "location": "전북특별자치도 군산시 구영신창길 61 (월명동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/33/3423333_image2_1.jpg", + "distanceText": "135m", + "distanceMeters": 135 + }, + { + "name": "물밀소", + "category": "맛집", + "searchQuery": "물밀소", + "location": "전북특별자치도 군산시 구영7길 23", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/03/2794403_image2_1.JPG", + "distanceText": "297m", + "distanceMeters": 297 + }, + { + "name": "일흥옥", + "category": "맛집", + "searchQuery": "일흥옥", + "location": "전북특별자치도 군산시 구영7길 25 (월명동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/60/2866960_image2_1.jpg", + "distanceText": "300m", + "distanceMeters": 300 + }, + { + "name": "명월갈비", + "category": "맛집", + "searchQuery": "명월갈비", + "location": "전북특별자치도 군산시 구영7길 59 명월갈비", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/48/2924648_image2_1.jpg", + "distanceText": "351m", + "distanceMeters": 351 + }, + { + "name": "만남스넥", + "category": "맛집", + "searchQuery": "만남스넥", + "location": "전북특별자치도 군산시 대학로 67-3 만남스넥", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/62/2877162_image2_1.jpg", + "distanceText": "405m", + "distanceMeters": 405 + }, + { + "name": "동국사다온", + "category": "맛집", + "searchQuery": "동국사다온", + "location": "전북특별자치도 군산시 동국사길 16 (금광동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/38/3423338_image2_1.jpg", + "distanceText": "418m", + "distanceMeters": 418 + }, + { + "name": "국제반점", + "category": "맛집", + "searchQuery": "국제반점", + "location": "전북특별자치도 군산시 구영6길 100 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/46/2877146_image2_1.jpg", + "distanceText": "467m", + "distanceMeters": 467 + }, + { + "name": "영화원", + "category": "맛집", + "searchQuery": "영화원", + "location": "전북특별자치도 군산시 구영5길 112 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/76/2877176_image2_1.jpg", + "distanceText": "495m", + "distanceMeters": 495 + }, + { + "name": "틈(TEUM)", + "category": "맛집", + "searchQuery": "틈(TEUM)", + "location": "전북특별자치도 군산시 구영6길 125-1 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/50/2794350_image2_1.JPG", + "distanceText": "573m", + "distanceMeters": 573 + }, + { + "name": "미즈커피", + "category": "맛집", + "searchQuery": "미즈커피", + "location": "전북특별자치도 군산시 해망로 232 (장미동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/2774220_image2_1.jpg", + "distanceText": "696m", + "distanceMeters": 696 + }, + { + "name": "[백년가게]운정식당", + "category": "맛집", + "searchQuery": "[백년가게]운정식당", + "location": "전북특별자치도 군산시 중정길 8-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/43/2616343_image2_1.jpg", + "distanceText": "748m", + "distanceMeters": 748 + }, + { + "name": "빈해원", + "category": "맛집", + "searchQuery": "빈해원", + "location": "전북특별자치도 군산시 동령길 57", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/40/3575640_image2_1.jpg", + "distanceText": "749m", + "distanceMeters": 749 + }, + { + "name": "복성루", + "category": "맛집", + "searchQuery": "복성루", + "location": "전북특별자치도 군산시 월명로 382", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/09/3083009_image2_1.png", + "distanceText": "1.3km", + "distanceMeters": 1251 + }, + { + "name": "왕산반점", + "category": "맛집", + "searchQuery": "왕산반점", + "location": "전북특별자치도 군산시 대학로 209", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/73/2752473_image2_1.jpg", + "distanceText": "1.5km", + "distanceMeters": 1499 + }, + { + "name": "리투스카페", + "category": "맛집", + "searchQuery": "리투스카페", + "location": "전북특별자치도 군산시 해망로 118", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/52/2877152_image2_1.jpg", + "distanceText": "1.6km", + "distanceMeters": 1605 + }, + { + "name": "수송반점", + "category": "맛집", + "searchQuery": "수송반점", + "location": "전북특별자치도 군산시 동메3길 23 수송반점", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/88/2866888_image2_1.jpg", + "distanceText": "1.7km", + "distanceMeters": 1739 + }, + { + "name": "군산활어회센타", + "category": "맛집", + "searchQuery": "군산활어회센타", + "location": "전북특별자치도 군산시 문화로 41", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/2923820_image2_1.jpg", + "distanceText": "1.9km", + "distanceMeters": 1854 + }, + { + "name": "진성원", + "category": "맛집", + "searchQuery": "진성원", + "location": "전북특별자치도 군산시 구암3.1로 70-1 진성원", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/79/2866979_image2_1.jpg", + "distanceText": "1.9km", + "distanceMeters": 1934 + }, + { + "name": "서우식당", + "category": "맛집", + "searchQuery": "서우식당", + "location": "전북특별자치도 군산시 나운안2길 9-6 서우식당", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/68/2877168_image2_1.jpg", + "distanceText": "2.0km", + "distanceMeters": 1980 + }, + { + "name": "송가짬뽕", + "category": "맛집", + "searchQuery": "송가짬뽕", + "location": "전북특별자치도 군산시 동수송안길 55 (수송동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/54/2924754_image2_1.jpg", + "distanceText": "2.2km", + "distanceMeters": 2150 + } + ], + "festivals": [ + { + "name": "군산시간여행축제", + "month": "10월", + "searchQuery": "군산시간여행축제", + "period": "2026.10.02 ~ 2026.10.05", + "location": "전북특별자치도 군산시 중앙로1가", + "distanceText": "522m", + "distanceMeters": 522, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/48/3561448_image2_1.jpg" + }, + { + "name": "군산짬뽕페스티벌", + "month": "10월", + "searchQuery": "군산짬뽕페스티벌", + "period": "2026.10.09 ~ 2026.10.11", + "location": "전북특별자치도 군산시 장미동 13-1", + "distanceText": "792m", + "distanceMeters": 792, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/49/4107749_image2_1.jpg" + }, + { + "name": "제5회 호러 홀로그램 페스티벌", + "month": "9월", + "searchQuery": "제5회 호러 홀로그램 페스티벌", + "period": "2026.09.10 ~ 2026.09.20", + "location": "전북특별자치도 익산시 성당면 함낭로 207", + "distanceText": "22.3km", + "distanceMeters": 22279, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/00/4103000_image2_1.jpg" + }, + { + "name": "NS푸드페스타", + "month": "10월", + "searchQuery": "NS푸드페스타", + "period": "2026.10.16 ~ 2026.10.17", + "location": "전북특별자치도 익산시 함열읍 익산대로78길 137", + "distanceText": "23.5km", + "distanceMeters": 23507, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/11/4096811_image2_1.jpg" + }, + { + "name": "김제지평선축제", + "month": "10월", + "searchQuery": "김제지평선축제", + "period": "2026.10.01 ~ 2026.10.05", + "location": "전북특별자치도 김제시 부량면 벽골제로 442", + "distanceText": "29.0km", + "distanceMeters": 29003, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/3516354_image2_1.jpg" + }, + { + "name": "부안붉은노을축제", + "month": "10월", + "searchQuery": "부안붉은노을축제", + "period": "2026.10.16 ~ 2026.10.18", + "location": "전북특별자치도 부안군 변산면 대항리", + "distanceText": "38.2km", + "distanceMeters": 38172, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/06/4107306_image2_1.jpg" + }, + { + "name": "제9회 전주국제단편영화제", + "month": "9월", + "searchQuery": "제9회 전주국제단편영화제", + "period": "2026.09.17 ~ 2026.09.21", + "location": "전북특별자치도 전주시 완산구 전주객사3길 72 (고사동)", + "distanceText": "43.4km", + "distanceMeters": 43392, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/4106254_image2_1.jpg" + }, + { + "name": "전주한옥마을 전통연희 퍼레이드-노상놀이야", + "month": "4월", + "searchQuery": "전주한옥마을 전통연희 퍼레이드-노상놀이야", + "period": "2026.04.18 ~ 2026.10.31", + "location": "전북특별자치도 전주시 완산구 태조로 44 (풍남동3가)", + "distanceText": "44.3km", + "distanceMeters": 44333, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/58/4067258_image2_1.JPG" + }, + { + "name": "완주 와일드&로컬푸드축제", + "month": "10월", + "searchQuery": "완주 와일드&로컬푸드축제", + "period": "2026.10.02 ~ 2026.10.04", + "location": "전북특별자치도 완주군 고산면 고산휴양림로 89", + "distanceText": "47.3km", + "distanceMeters": 47267, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/49/4062849_image2_1.JPG" + }, + { + "name": "완주 상관 워터풀파티", + "month": "9월", + "searchQuery": "완주 상관 워터풀파티", + "period": "2026.09.12 ~ 2026.09.13", + "location": "전북특별자치도 완주군 상관면 춘향로 4355", + "distanceText": "54.1km", + "distanceMeters": 54066, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/30/4103130_image2_1.png" + }, + { + "name": "정읍 구절초 꽃축제", + "month": "10월", + "searchQuery": "정읍 구절초 꽃축제", + "period": "2026.10.08 ~ 2026.10.18", + "location": "전북특별자치도 정읍시 산내면 청정로 926-89", + "distanceText": "55.7km", + "distanceMeters": 55733, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/56/4071256_image2_1.JPG" + }, + { + "name": "고창모양성제", + "month": "10월", + "searchQuery": "고창모양성제", + "period": "2026.10.15 ~ 2026.10.19", + "location": "전북특별자치도 고창군 고창읍 모양성로 11", + "distanceText": "61.5km", + "distanceMeters": 61521, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/30/3519730_image2_1.jpg" + }, + { + "name": "임실N치즈축제", + "month": "10월", + "searchQuery": "임실N치즈축제", + "period": "2026.10.08 ~ 2026.10.11", + "location": "전북특별자치도 임실군 성수면 도인2길 50", + "distanceText": "66.6km", + "distanceMeters": 66648, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/3377054_image2_1.png" + }, + { + "name": "트레저헌터 in 진안", + "month": "8월", + "searchQuery": "트레저헌터 in 진안", + "period": "2026.08.29 ~ 2026.09.19", + "location": "전북특별자치도 진안군 진안읍 외사양길 16-19", + "distanceText": "68.1km", + "distanceMeters": 68052, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/08/4096408_image2_1.jpeg" + }, + { + "name": "진안홍삼축제", + "month": "9월", + "searchQuery": "진안홍삼축제", + "period": "2026.09.18 ~ 2026.09.20", + "location": "전북특별자치도 진안군 진안읍 마이산로 160", + "distanceText": "68.2km", + "distanceMeters": 68236, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/75/4092075_image2_1.jpg" + }, + { + "name": "순창장류축제", + "month": "10월", + "searchQuery": "순창장류축제", + "period": "2026.10.15 ~ 2026.10.18", + "location": "전북특별자치도 순창군 순창읍 장류로 55", + "distanceText": "77.9km", + "distanceMeters": 77866, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/59/3535559_image2_1.JPG" + }, + { + "name": "장수한우랑사과랑축제", + "month": "9월", + "searchQuery": "장수한우랑사과랑축제", + "period": "2026.09.10 ~ 2026.09.13", + "location": "전북특별자치도 장수군 장수읍 한누리로 378", + "distanceText": "82.8km", + "distanceMeters": 82797, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/79/4095779_image2_1.jpg" + }, + { + "name": "무주반딧불축제", + "month": "9월", + "searchQuery": "무주반딧불축제", + "period": "2026.09.04 ~ 2026.09.12", + "location": "전북특별자치도 무주군 무주읍 한풍루로 326-17", + "distanceText": "86.1km", + "distanceMeters": 86065, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/66/3520566_image2_1.JPG" + } + ], + "courses": [], + "weather": { + "temperature": 25.0, + "condition": "구름많음", + "observedAt": "2026-09-09T12:45" + }, + "story": { + "quiz": [ + { + "hint": "군산문화관광은 군산을 근대역사와 해양중심의 도시로 소개한다.", + "level": "초등", + "topic": "군산문화관광", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "왜 전북특별자치도 군산시는 근대역사와 바다를 함께 떠올리게 될까?", + "verified": "확인" + }, + { + "hint": "군산 시간여행 코스에는 근대역사박물관과 근대건축관, 초원사진관 등이 묶여 있다.", + "level": "중등", + "topic": "시간여행 코스", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "어떻게 전북특별자치도 군산시는 옛 건물과 이야기를 여행 자원으로 살리고 있을까?", + "verified": "확인" + }, + { + "hint": "군산근대역사박물관은 군산의 근대문화자원을 활용해 과거와 현재, 미래를 잇는다고 소개한다.", + "level": "초등", + "topic": "군산근대역사박물관", + "source": { + "url": "https://museum.gunsan.go.kr/", + "name": "군산근대역사박물관" + }, + "question": "왜 전북특별자치도 군산시는 박물관을 중심으로 도시의 과거를 보여 주려 할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광의 시간여행 코스에는 진포해양공원, 뜬다리 부두, 해망굴이 포함되어 있다.", + "level": "중등", + "topic": "해양·항만 유산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "어떻게 전북특별자치도 군산시는 바다와 항만의 흔적을 시민과 여행자에게 전할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 말랭이마을과 경암동 철길마을이 관광명소로 소개된다.", + "level": "초등", + "topic": "도심 마을 관광", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2080", + "name": "군산문화관광 관광명소" + }, + "question": "왜 전북특별자치도 군산시는 구도심의 골목과 마을을 함께 돌아보게 만들까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 일제강점기 군산역사관과 3.1운동 100주년 기념관이 소개된다.", + "level": "중등", + "topic": "기억·교육 공간", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2080", + "name": "군산문화관광 관광명소" + }, + "question": "어떻게 전북특별자치도 군산시는 일제강점기 기억을 교육과 여행으로 연결할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광은 동국사 대웅전을 시간여행 코스에 포함하고 있다.", + "level": "초등", + "topic": "동국사", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "왜 전북특별자치도 군산시는 동국사를 중요한 배움의 장소로 볼 수 있을까?", + "verified": "확인" + }, + { + "hint": "군산시 문화관광에는 국가지정 문화유산, 도지정 문화유산, 국가등록 문화유산 등의 항목이 따로 보인다.", + "level": "어른", + "topic": "문화유산 분류", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "어떻게 전북특별자치도 군산시는 문화유산을 여러 종류로 나누어 알릴까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 선유도 망주봉 일원과 선유도 고려유적이 따로 안내된다.", + "level": "중등", + "topic": "선유도", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "왜 전북특별자치도 군산시는 선유도와 주변 섬 이야기를 함께 소개할까?", + "verified": "확인" + }, + { + "hint": "군산시향토유산으로 민살풀이 춤이 소개된다.", + "level": "어른", + "topic": "향토유산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2073", + "name": "군산문화관광 향토유산" + }, + "question": "어떻게 전북특별자치도 군산시는 지역의 무형유산을 다음 세대에 전하려 할까?", + "verified": "확인" + }, + { + "hint": "군산근대역사박물관은 동절기와 하절기 운영 시간이 다르다고 안내한다.", + "level": "중등", + "topic": "군산근대역사박물관 운영", + "source": { + "url": "https://museum.gunsan.go.kr/new/board/index.do;jsessionid=59B38B660C0A78ECA24DE10835D1110D?id=788&bbs_id=3&page=18&search=&searchDiv=&type=&act=view", + "name": "군산근대역사박물관 보도자료" + }, + "question": "왜 전북특별자치도 군산시는 박물관 운영 시간을 계절에 따라 달리할까?", + "verified": "확인" + }, + { + "hint": "군산시에는 33건의 지정 문화재가 있다는 설명이 보인다.", + "level": "어른", + "topic": "지정 문화재", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc/GC05700562", + "name": "군산 문화유산 설명" + }, + "question": "어떻게 전북특별자치도 군산시는 지정 문화재를 도시 이야기와 연결해 볼 수 있을까?", + "verified": "확인" + } + ], + "postcard": [ + { + "line": "군산은 근대역사와 해양중심의 도시다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "hashtags": [ + "#군산", + "#전북특별자치도" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산시 누리집엔 시간여행마을이 보인다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 누리집" + }, + "hashtags": [ + "#군산", + "#시간여행마을" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 근대역사박물관이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#근대역사" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 초원사진관이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#초원사진관" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 동국사 대웅전이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#동국사" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 해망굴이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#해망굴" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 내항 뜬다리 부두가 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#내항" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 내항 역사문화공간이 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#역사문화" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 어청도 등대가 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#어청도" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산시청 주소는 시청로 17이다", + "place": "군산시청", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 대표" + }, + "hashtags": [ + "#군산", + "#군산시청" + ], + "postmark": "군산시청", + "verified": "확인" + }, + { + "line": "군산시청은 조촌동에 있다", + "place": "조촌동", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 대표" + }, + "hashtags": [ + "#군산", + "#조촌동" + ], + "postmark": "조촌", + "verified": "확인" + }, + { + "line": "군산에는 국가등록문화유산이 여럿 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#국가유산" + ], + "postmark": "군산", + "verified": "확인" + } + ], + "chronicle": [ + { + "year": 1380, + "title": "금강 일대에서 왜구 격퇴", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "고려 우왕 6년, 금강에서 왜구의 대규모 선박을 최무선의 화포로 격멸한 기록이 전해진다. 군산 해역이 외세 방어의 전초로 기억되는 계기다.", + "verified": "확인" + }, + { + "year": 1403, + "title": "조선 초기 옥구현 체제 정착", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "군산 지역은 조선 초기에 옥구현 중심의 행정 체계로 이어졌다. 현재 군산의 행정사 출발점으로 볼 수 있다.", + "verified": "확인" + }, + { + "year": 1899, + "title": "군산항 개항과 옥구부 설치", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "군산항이 개항되면서 옥구부가 설치되었다. 근대 군산의 성장과 항만도시화가 본격화된 전환점이다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1910, + "title": "군산부로 개편", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "개항장 체제의 변화 속에서 옥구부가 군산부로 바뀌었다. 도시의 행정 중심이 군산항 일대로 더 분명히 굳어졌다.", + "verified": "확인" + }, + { + "year": 1914, + "title": "행정구역 재편으로 옥구군·군산부 체계 형성", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "일제강점기 행정구역 개편으로 군산부와 옥구군 체계가 자리 잡았다. 이후 군산은 항만과 배후농촌이 맞물린 구조로 발전했다.", + "verified": "확인" + }, + { + "year": 1946, + "title": "해방 후 행정동 재편", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "왜식 동명 변경으로 군산의 도심 행정 구역이 새롭게 정비되었다. 해방 직후 도시 정체성을 다시 세운 과정이다.", + "verified": "확인" + }, + { + "year": 1949, + "title": "군산부에서 군산시로 개칭", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "지방자치법 시행에 따라 군산부가 군산시로 바뀌었다. 오늘의 군산시 행정 체계가 시작된 해로 볼 수 있다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1974, + "title": "군산외항 부두 건설 시작", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "1970년대 중반부터 군산외항 제1·2·3부두 건설이 이어졌다. 항만 기능 확장은 군산의 산업도시 성격을 강화했다.", + "verified": "확인" + }, + { + "year": 1988, + "title": "군산국가산업단지 조성", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "1988년부터 1994년까지 군산국가산업단지가 조성되었다. 제조업 기반이 커지며 도시 경제의 축이 바뀌었다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1990, + "title": "금강하구둑 준공", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "금강하구둑이 준공되며 전북과 충남을 잇는 대형 수리 시설이 완성되었다. 군산의 수변·교통 환경에 큰 변화를 준 사건이다.", + "verified": "확인" + }, + { + "year": 1995, + "title": "옥구군과 통합해 도농통합 군산시 출범", + "source": { + "url": "https://museum.gunsan.go.kr/content/sub04/04_01.jsp", + "name": "군산의 역사문화" + }, + "summary": "군산시와 옥구군이 통합되어 도농통합형 군산시가 탄생했다. 현재의 광역적 생활권과 행정 범위가 형성된 전환점이다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1999, + "title": "군산개항 100주년", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "군산은 개항 100주년을 맞아 근대 항만도시의 역사를 재조명했다. 개항의 의미가 도시의 역사 서사로 다시 강조된 시기다.", + "verified": "확인" + } + ], + "people": [ + { + "name": "채만식", + "role": "소설가", + "years": "1902–1950", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산에서 태어난 한국 근대문학의 대표 작가로, 일제강점기 군산을 배경으로 한 작품으로도 널리 알려져 있다.", + "verified": "확인", + "imageQuery": "채만식 초상" + }, + { + "name": "김수미", + "role": "배우", + "years": "1949–2024", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출생으로 소개되는 한국의 대표적 배우로, 오랜 기간 영화와 방송에서 활동했다.", + "verified": "확인", + "imageQuery": "김수미 초상" + }, + { + "name": "박명수", + "role": "방송인·코미디언·가수", + "years": "1970–", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출신으로 소개되는 방송인으로, 예능 프로그램을 통해 전국적 인지도를 얻었다.", + "verified": "확인", + "imageQuery": "박명수 초상" + }, + { + "name": "송새벽", + "role": "배우", + "years": "1979–", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출생으로 소개되는 배우로, 영화와 드라마에서 개성 있는 조연 연기로 이름을 알렸다.", + "verified": "확인", + "imageQuery": "송새벽 초상" + }, + { + "name": "고은", + "role": "시인", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc?search=5ca8d72b-38ad-4d29-88c6-31d3c7f95582", + "name": "디지털군산문화대전" + }, + "oneLine": "군산과 깊이 얽힌 대표 문인으로, 군산 문학 자료에서 군산을 대표하는 문학인 가운데 한 명으로 소개된다.", + "verified": "확인필요", + "imageQuery": "고은 시인 초상" + }, + { + "name": "이만흥", + "role": "영화인", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화사의 초기 전개와 관련해 언급되는 인물로, 지역 영화 문화의 개척자로 소개된다.", + "verified": "확인필요", + "imageQuery": "이만흥 군산 영화인" + }, + { + "name": "이강천", + "role": "영화감독", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화 문화의 여명기 인물로 함께 거론되며, 지역 영화사 서술에서 자주 등장한다.", + "verified": "확인필요", + "imageQuery": "이강천 영화감독" + }, + { + "name": "이집길", + "role": "배우", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화사 초기 인물로 소개되며, 지역 영화의 출발점과 연결되어 언급된다.", + "verified": "확인필요", + "imageQuery": "이집길 배우" + }, + { + "name": "정만채", + "role": "교육자·사회운동가", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc?search=5ca8d72b-38ad-4d29-88c6-31d3c7f95582", + "name": "디지털군산문화대전" + }, + "oneLine": "군산에서 동산학원을 설립하고 군산 시사회를 조직한 인물로, 지역 교육과 사회 활동에 흔적을 남겼다.", + "verified": "확인", + "imageQuery": "정만채 군산" + }, + { + "name": "김종량", + "role": "언론인", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc/GC05701157", + "name": "디지털군산문화대전" + }, + "oneLine": "전라북도 군산 지역 출신 언론인으로 소개된다.", + "verified": "확인", + "imageQuery": "김종량 언론인 군산" + } + ], + "songs": [ + { + "year": 1963, + "story": "군산항을 정면으로 제목에 내건 초기 대중가요다. 항구의 이별 정서와 지역 지명이 함께 드러난다.", + "title": "잘 있거라 군산항", + "artist": "안다성", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "손석우", + "lyricist": "손석우", + "verified": "확인" + }, + { + "year": 1965, + "story": "군산항을 배경으로 한 이별 노래로 전해진다. 1960년대 군산항 노래들 가운데 하나로 묶여 소개된다.", + "title": "헤여진 군산항", + "artist": "박제연", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "손운선", + "lyricist": "송운선", + "verified": "확인" + }, + { + "year": 1966, + "story": "군산항을 중심 배경으로 삼은 1960년대 트로트 계열의 곡이다. 항구와 이별의 정서가 함께 읽힌다.", + "title": "님 떠난 군산항", + "artist": "이미자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "백영호", + "lyricist": "조형식", + "verified": "확인" + }, + { + "year": 1966, + "story": "군산과 맞닿은 금강을 배경으로 한 노래다. 지명과 강을 함께 품은 지역 서정가로 소개된다.", + "title": "금강 나그네", + "artist": "이미자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "박춘석", + "lyricist": "이병훈", + "verified": "확인" + }, + { + "year": 1967, + "story": "군산항구를 제목에 넣은 곡으로, 1960년대 군산 관련 가요 묶음에 포함된다. 항구 도시의 이별 정서를 다룬 노래로 볼 수 있다.", + "title": "잘 있거라 군산항구", + "artist": "최정자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "유금춘", + "lyricist": "최치수", + "verified": "확인" + }, + { + "year": 1969, + "story": "군산을 제목에 둔 1960년대 후반의 지역 가요다. 항구 도시 특유의 유흥가·이별 분위기를 떠올리게 하는 유형의 노래로 묶인다.", + "title": "군산 부르스", + "artist": "유주용", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "강도미", + "lyricist": "반야월", + "verified": "확인" + }, + { + "year": 1969, + "story": "군산항의 부두를 직접 배경으로 삼은 노래다. 부두와 이별이라는 키워드가 결합된 항구 노래다.", + "title": "이별의 군산항 부두", + "artist": "최정자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "유금춘", + "lyricist": "최치수", + "verified": "확인" + }, + { + "year": 1984, + "story": "1980년대에 나온 군산항 제목의 곡이다. 항구 도시 군산의 밤과 이별 정서를 전면에 둔 노래로 소개된다.", + "title": "군산항 부르스", + "artist": "최하우", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "김성근", + "lyricist": "최하우", + "verified": "확인" + } + ] + }, + "itineraries": [ + { + "name": "1박 2일", + "duration": "1박 2일", + "days": [ + { + "label": "첫째 날", + "stops": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "latitude": 35.986301, + "longitude": 126.7061578 + }, + { + "name": "도란", + "searchQuery": "도란", + "latitude": 35.9868704, + "longitude": 126.7075355 + }, + { + "name": "물밀소", + "searchQuery": "물밀소", + "latitude": 35.9851317, + "longitude": 126.7090576 + }, + { + "name": "군산시간여행축제", + "searchQuery": "군산시간여행축제", + "latitude": 35.987791, + "longitude": 126.711671 + } + ] + }, + { + "label": "둘째 날", + "stops": [ + { + "name": "초원사진관", + "searchQuery": "초원사진관", + "latitude": 35.987737, + "longitude": 126.7083826 + }, + { + "name": "명월갈비", + "searchQuery": "명월갈비", + "latitude": 35.9864982, + "longitude": 126.7100742 + }, + { + "name": "일흥옥", + "searchQuery": "일흥옥", + "latitude": 35.98524, + "longitude": 126.7091715 + }, + { + "name": "군산짬뽕페스티벌", + "searchQuery": "군산짬뽕페스티벌", + "latitude": 35.9896779, + "longitude": 126.7139462 + } + ] + } + ] + }, + { + "name": "2박 3일", + "duration": "2박 3일", + "days": [ + { + "label": "첫째 날", + "stops": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "latitude": 35.986301, + "longitude": 126.7061578 + }, + { + "name": "도란", + "searchQuery": "도란", + "latitude": 35.9868704, + "longitude": 126.7075355 + }, + { + "name": "물밀소", + "searchQuery": "물밀소", + "latitude": 35.9851317, + "longitude": 126.7090576 + }, + { + "name": "군산시간여행축제", + "searchQuery": "군산시간여행축제", + "latitude": 35.987791, + "longitude": 126.711671 + } + ] + }, + { + "label": "둘째 날", + "stops": [ + { + "name": "초원사진관", + "searchQuery": "초원사진관", + "latitude": 35.987737, + "longitude": 126.7083826 + }, + { + "name": "명월갈비", + "searchQuery": "명월갈비", + "latitude": 35.9864982, + "longitude": 126.7100742 + }, + { + "name": "일흥옥", + "searchQuery": "일흥옥", + "latitude": 35.98524, + "longitude": 126.7091715 + }, + { + "name": "군산짬뽕페스티벌", + "searchQuery": "군산짬뽕페스티벌", + "latitude": 35.9896779, + "longitude": 126.7139462 + } + ] + }, + { + "label": "셋째 날", + "stops": [ + { + "name": "군산 영화의 거리", + "searchQuery": "군산 영화의 거리", + "latitude": 35.9866621, + "longitude": 126.7096909 + }, + { + "name": "만남스넥", + "searchQuery": "만남스넥", + "latitude": 35.9842568, + "longitude": 126.7097992 + }, + { + "name": "동국사다온", + "searchQuery": "동국사다온", + "latitude": 35.9828792, + "longitude": 126.707755 + }, + { + "name": "제5회 호러 홀로그램 페스티벌", + "searchQuery": "제5회 호러 홀로그램 페스티벌", + "latitude": 36.0693015, + "longitude": 126.9316832 + } + ] + } + ] + } + ], + "syncedAt": "2026-09-09T09:00:27.066565+00:00" + }, + "routes": [], + "narrative": { + "about": [ + "편안한 침구와 좋은 위치로 편안한 잠자리" + ], + "summary": "편안한 침구와 좋은 위치로 편안한 잠자리" + }, + "theme": { + "templateId": "stay-retro", + "colors": { + "primary": "#1b1a15", + "secondary": "#4c4739", + "bg": "#e4dac0", + "card": "#efe7d3", + "text": "#1b1a15", + "accent": "#bf2f1b" + }, + "fontStyle": "옛 간판체", + "sections": [ + { + "id": "hero", + "name": "히어로", + "enabled": true, + "locked": true + }, + { + "id": "intro", + "name": "소개", + "enabled": true, + "locked": false + }, + { + "id": "rooms", + "name": "객실 안내", + "enabled": true, + "locked": false + }, + { + "id": "info", + "name": "기본 정보", + "enabled": true, + "locked": true + }, + { + "id": "booking", + "name": "실시간 예약", + "enabled": true, + "locked": false + }, + { + "id": "video", + "name": "영상으로 보기", + "enabled": true, + "locked": false, + "data": "{\n \"kind\": \"video\",\n \"version\": 1,\n \"title\": \"영상으로 보기\",\n \"subtitle\": \"사진으로는 다 안 보이는 것\",\n \"items\": [\n {\n \"url\": \"https://youtube.com/shorts/c2ZdwhaB7S4\",\n \"caption\": \"머뭄의 하루\",\n \"verified\": \"확인\"\n },\n {\n \"url\": \"https://youtube.com/shorts/_bbo3dVgOXs\",\n \"caption\": \"마당과 창고형 카페 공간\",\n \"verified\": \"확인\"\n },\n {\n \"url\": \"https://youtube.com/shorts/qrCG4rOeJvs\",\n \"caption\": \"밤의 머뭄\",\n \"verified\": \"확인\"\n }\n ]\n}" + }, + { + "id": "photos", + "name": "사진 갤러리", + "enabled": true, + "locked": false, + "variantId": "photos.carousel" + }, + { + "id": "map", + "name": "오시는 길", + "enabled": true, + "locked": true + }, + { + "id": "local", + "name": "지역 정보", + "enabled": true, + "locked": false + }, + { + "id": "itinerary", + "name": "추천 일정", + "enabled": true, + "locked": false, + "data": "{\n \"kind\": \"itinerary\",\n \"version\": 1,\n \"title\": \"추천 일정\",\n \"subtitle\": \"며칠 묵느냐에 따라 다르게 돕니다\",\n \"items\": [\n {\n \"name\": \"탁류길 코스\",\n \"duration\": \"반나절\",\n \"audience\": \"걷기 좋아하는 손님\",\n \"why\": \"소설 『탁류』가 지나간 자리를 그대로 따라 걷습니다.\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"채만식문학관\",\n \"note\": \"금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.\",\n \"searchQuery\": \"군산 채만식문학관\",\n \"moveMinutes\": 12\n },\n {\n \"name\": \"군산 내항 부잔교\",\n \"note\": \"조수에 오르내리던 뜬다리. 이 코스의 중심입니다.\",\n \"searchQuery\": \"군산 내항 부잔교\",\n \"moveMinutes\": 9\n },\n {\n \"name\": \"째보선창\",\n \"note\": \"항구 노동의 기억이 남은 선창입니다.\",\n \"searchQuery\": \"군산 째보선창\",\n \"moveMinutes\": 7\n },\n {\n \"name\": \"초원사진관\",\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\",\n \"moveMinutes\": 11\n },\n {\n \"name\": \"이성당\",\n \"note\": \"코스의 끝. 단팥빵 하나로 반나절을 닫습니다.\",\n \"searchQuery\": \"군산 이성당\",\n \"moveMinutes\": 6\n }\n ],\n \"verified\": \"확인\",\n \"source\": {\n \"name\": \"군산 스탬프투어\",\n \"url\": \"https://www.gunsanstamp.kr/\"\n }\n },\n {\n \"name\": \"아리랑길 코스\",\n \"duration\": \"반나절\",\n \"audience\": \"걷기 좋아하는 손님\",\n \"why\": \"개항기 건물만 골라 짧게 도는 길입니다.\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"구 군산세관 본관\",\n \"note\": \"1908년에 지어진 벽돌 건물입니다.\",\n \"searchQuery\": \"구 군산세관 본관\",\n \"moveMinutes\": 8\n },\n {\n \"name\": \"군산근대미술관\",\n \"note\": \"옛 은행 금고가 그대로 남아 있습니다.\",\n \"searchQuery\": \"군산근대미술관\",\n \"moveMinutes\": 5\n },\n {\n \"name\": \"해망굴\",\n \"note\": \"굴 하나에 두 시대가 겹칩니다.\",\n \"searchQuery\": \"군산 해망굴\",\n \"moveMinutes\": 14\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산 스탬프투어\",\n \"url\": \"https://www.gunsanstamp.kr/\"\n }\n },\n {\n \"name\": \"개항장 골목 한 바퀴\",\n \"duration\": \"반나절\",\n \"audience\": \"처음 온 손님\",\n \"why\": \"해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.\",\n \"startTime\": \"09:30\",\n \"stops\": [\n {\n \"name\": \"군산근대역사박물관\",\n \"minutes\": 90,\n \"moveMinutes\": 12,\n \"note\": \"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\n \"searchQuery\": \"군산근대역사박물관\"\n },\n {\n \"name\": \"초원사진관\",\n \"minutes\": 30,\n \"moveMinutes\": 10,\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\"\n },\n {\n \"name\": \"이성당\",\n \"minutes\": 40,\n \"moveMinutes\": 8,\n \"note\": \"단팥빵 하나로 오후를 엽니다.\",\n \"searchQuery\": \"군산 이성당\"\n },\n {\n \"name\": \"경암동 철길마을\",\n \"minutes\": 60,\n \"moveMinutes\": 18,\n \"note\": \"해 질 무렵 빛이 가장 좋습니다.\",\n \"searchQuery\": \"군산 경암동 철길마을\"\n }\n ],\n \"verified\": \"확인\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n },\n {\n \"name\": \"비 오면 여기로\",\n \"duration\": \"반나절\",\n \"audience\": \"장마철에 온 손님\",\n \"why\": \"실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.\",\n \"season\": \"여름\",\n \"startTime\": \"10:30\",\n \"stops\": [\n {\n \"name\": \"군산근대건축관\",\n \"minutes\": 60,\n \"moveMinutes\": 10,\n \"note\": \"옛 은행 건물 안이 통째로 전시입니다.\",\n \"searchQuery\": \"군산근대건축관\"\n },\n {\n \"name\": \"한일옥\",\n \"minutes\": 60,\n \"moveMinutes\": 8,\n \"note\": \"무국 한 그릇으로 몸을 데웁니다.\",\n \"searchQuery\": \"군산 한일옥\"\n },\n {\n \"name\": \"째보선창 근처 카페\",\n \"minutes\": 90,\n \"moveMinutes\": 10,\n \"note\": \"비 그칠 때까지 앉아 있기 좋습니다.\",\n \"searchQuery\": \"군산 째보선창 카페\"\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n },\n {\n \"name\": \"섬까지 다녀오는 1박 2일\",\n \"duration\": \"1박 2일\",\n \"audience\": \"차를 가져온 손님\",\n \"why\": \"첫날은 도심에서 저녁을, 이튿날은 다리 건너 바다를 봅니다.\",\n \"days\": [\n {\n \"label\": \"첫째 날\",\n \"startTime\": \"17:00\",\n \"stops\": [\n {\n \"name\": \"체크인 · 스테이 머뭄\",\n \"minutes\": 40,\n \"note\": \"짐을 풀고 골목을 한 바퀴 돕니다.\"\n },\n {\n \"name\": \"저녁 · 빈해원\",\n \"minutes\": 70,\n \"note\": \"옛 중식당 건물 자체가 문화재입니다.\",\n \"searchQuery\": \"군산 빈해원\"\n },\n {\n \"name\": \"밤 산책 · 군산 내항\",\n \"minutes\": 50,\n \"note\": \"조명이 들어온 부잔교를 봅니다.\",\n \"searchQuery\": \"군산 내항\"\n },\n {\n \"name\": \"머뭄으로 · 스테이 머뭄\",\n \"minutes\": 60,\n \"note\": \"마당 평상에 앉으면 항구 소리가 들립니다.\"\n }\n ]\n },\n {\n \"label\": \"둘째 날\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"고군산군도 선유도\",\n \"minutes\": 150,\n \"moveMinutes\": 45,\n \"note\": \"다리로 건너갑니다. 차로 이동.\",\n \"searchQuery\": \"군산 선유도\"\n },\n {\n \"name\": \"장자도\",\n \"minutes\": 60,\n \"moveMinutes\": 12,\n \"note\": \"섬과 섬 사이를 걸어 넘습니다.\",\n \"searchQuery\": \"군산 장자도\"\n }\n ]\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"대한민국 구석구석\",\n \"url\": \"https://korean.visitkorea.or.kr/\"\n }\n },\n {\n \"name\": \"천천히 도는 2박 3일\",\n \"duration\": \"2박 3일\",\n \"audience\": \"오래 머무는 손님\",\n \"why\": \"하루에 한 가지씩만 봅니다. 서두르지 않는 사람에게 맞습니다.\",\n \"days\": [\n {\n \"label\": \"첫째 날\",\n \"startTime\": \"15:00\",\n \"stops\": [\n {\n \"name\": \"저녁 · 빈해원\",\n \"minutes\": 70,\n \"note\": \"옛 중식당 건물 자체가 문화재입니다.\",\n \"searchQuery\": \"군산 빈해원\"\n },\n {\n \"name\": \"밤 산책 · 군산 내항\",\n \"minutes\": 50,\n \"note\": \"조명이 들어온 부잔교를 봅니다.\",\n \"searchQuery\": \"군산 내항\"\n },\n {\n \"name\": \"머뭄으로 · 스테이 머뭄\",\n \"minutes\": 60,\n \"note\": \"마당 평상에 앉으면 항구 소리가 들립니다.\"\n }\n ]\n },\n {\n \"label\": \"둘째 날\",\n \"startTime\": \"09:30\",\n \"stops\": [\n {\n \"name\": \"군산근대역사박물관\",\n \"minutes\": 90,\n \"moveMinutes\": 12,\n \"note\": \"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\n \"searchQuery\": \"군산근대역사박물관\"\n },\n {\n \"name\": \"초원사진관\",\n \"minutes\": 30,\n \"moveMinutes\": 10,\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\"\n },\n {\n \"name\": \"이성당\",\n \"minutes\": 40,\n \"moveMinutes\": 8,\n \"note\": \"단팥빵 하나로 오후를 엽니다.\",\n \"searchQuery\": \"군산 이성당\"\n },\n {\n \"name\": \"경암동 철길마을\",\n \"minutes\": 60,\n \"moveMinutes\": 18,\n \"note\": \"해 질 무렵 빛이 가장 좋습니다.\",\n \"searchQuery\": \"군산 경암동 철길마을\"\n }\n ]\n },\n {\n \"label\": \"셋째 날\",\n \"startTime\": \"11:00\",\n \"stops\": [\n {\n \"name\": \"채만식문학관\",\n \"minutes\": 80,\n \"moveMinutes\": 20,\n \"note\": \"금강 하구 옆입니다. 차로 이동.\",\n \"searchQuery\": \"군산 채만식문학관\"\n },\n {\n \"name\": \"째보선창\",\n \"minutes\": 40,\n \"moveMinutes\": 15,\n \"note\": \"소설 속 노동의 자리입니다.\",\n \"searchQuery\": \"군산 째보선창\"\n }\n ]\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n }\n ]\n}" + }, + { + "id": "songs", + "name": "가요 다방", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"songs\", \"version\": 1, \"items\": [{\"title\": \"금강 나그네\", \"artist\": \"이미자\", \"lyricist\": \"이병훈\", \"composer\": \"박춘석\", \"year\": 1966, \"label\": \"지구레코드\", \"labelColor\": \"#d4551f\", \"story\": \"군산항을 모티브로 한 곡. 금강 하구와 항구의 이별 정서를 담았습니다.\", \"connection\": \"머뭄에서 걸어 10분, 내항 부잔교가 이 노래의 배경입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}, {\"title\": \"님 떠난 군산항\", \"artist\": \"이미자\", \"labelColor\": \"#1d3c74\", \"story\": \"군산항의 이별 정서를 담은 곡. 발표 연도와 작사·작곡은 아직 확인 중입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}, {\"title\": \"헤어진 군산항\", \"artist\": \"박제연\", \"labelColor\": \"#2a6053\", \"story\": \"1960년대 군산을 노래한 곡으로 전해집니다. 항구도시의 정서를 담았습니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}], \"title\": \"가요 다방\", \"subtitle\": \"이 도시를 노래한 곡들\"}" + }, + { + "id": "daily", + "name": "오늘의 한 장", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"daily\", \"version\": 1, \"items\": [{\"monthDay\": \"09-01\", \"category\": \"장소\", \"title\": \"군산 내항 부잔교\", \"body\": \"조수 차가 큰 항구에서 배를 대기 위해 물 위에 띄운 다리입니다. 미곡 반출의 통로이기도 했습니다.\", \"season\": \"가을\", \"tags\": [\"#군산내항\", \"#부잔교\"], \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"monthDay\": \"09-02\", \"category\": \"축제\", \"title\": \"군산시간여행축제\", \"body\": \"근대문화도시의 정체성을 축제로 풀어냅니다. 밤에 걷는 골목이 낮과 다릅니다.\", \"season\": \"가을\", \"tags\": [\"#시간여행축제\"], \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"monthDay\": \"09-03\", \"category\": \"음식\", \"title\": \"이성당\", \"body\": \"오래된 빵집 하나가 도시의 이력이 된 자리입니다. 단팥빵 앞의 줄이 그 증거입니다.\", \"season\": \"가을\", \"tags\": [\"#이성당\"], \"verified\": \"확인\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}, {\"monthDay\": \"09-04\", \"category\": \"문학\", \"title\": \"채만식문학관\", \"body\": \"금강 하구 옆에 선 문학관입니다. 『탁류』의 무대를 창밖으로 바로 볼 수 있습니다.\", \"season\": \"가을\", \"tags\": [\"#채만식\", \"#탁류\"], \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"monthDay\": \"09-05\", \"category\": \"바다\", \"title\": \"고군산군도\", \"body\": \"다리로 이어진 예순 개 남짓의 섬입니다. 차로 건너 선유도까지 갈 수 있습니다.\", \"season\": \"가을\", \"tags\": [\"#고군산군도\", \"#선유도\"], \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"오늘의 한 장\", \"subtitle\": \"하루에 하나씩 뜯어 보는 군산\"}" + }, + { + "id": "people", + "name": "인물 열전", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"people\", \"version\": 1, \"items\": [{\"name\": \"채만식\", \"aka\": \"백릉\", \"years\": \"1902–1950\", \"role\": \"소설가\", \"oneLine\": \"군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.\", \"imageQuery\": \"채만식문학관 채만식 초상\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"name\": \"고은\", \"years\": \"1933–\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"name\": \"박화요비\", \"role\": \"가수\", \"oneLine\": \"군산 출신으로 소개되는 가수입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"name\": \"진희경\", \"role\": \"배우\", \"oneLine\": \"군산 출신으로 알려진 배우입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}], \"title\": \"인물 열전\", \"subtitle\": \"이 도시가 배출한 이름들\"}" + }, + { + "id": "chronicle", + "name": "시간의 골목", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"chronicle\", \"version\": 1, \"items\": [{\"year\": 1380, \"title\": \"진포대첩\", \"summary\": \"최무선의 화포가 왜구 함대를 무너뜨린 해. 군산 해양사의 출발점으로 꼽힙니다.\", \"place\": \"진포시비공원\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1899, \"title\": \"군산 개항\", \"summary\": \"항구가 열리며 도시의 성격이 한 번에 바뀝니다.\", \"place\": \"군산 내항\", \"turning\": true, \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1908, \"title\": \"구 군산세관 본관 준공\", \"summary\": \"지금도 서 있는 벽돌 건물입니다. 개항기 행정의 자리였습니다.\", \"place\": \"구 군산세관 본관\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1926, \"title\": \"내항 부잔교 축조\", \"summary\": \"조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로였습니다.\", \"place\": \"군산 내항 부잔교\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1937, \"title\": \"『탁류』 연재 시작\", \"summary\": \"채만식이 이 도시를 소설의 무대로 세웁니다.\", \"place\": \"채만식문학관\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"year\": 1990, \"title\": \"금강하굿둑 준공\", \"summary\": \"강과 바다의 경계가 사람 손으로 그어집니다.\", \"place\": \"금강하굿둑\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 2010, \"title\": \"새만금 방조제 개통\", \"summary\": \"간척으로 해안선이 다시 그려집니다.\", \"place\": \"새만금 방조제\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"시간의 골목\", \"subtitle\": \"이 도시를 만든 해들\"}" + }, + { + "id": "postcard", + "name": "오늘의 엽서", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"postcard\", \"version\": 1, \"items\": [{\"line\": \"부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.\", \"hashtags\": [\"#군산\", \"#내항\", \"#부잔교\"], \"place\": \"군산 내항\", \"postmark\": \"군산 內港\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"line\": \"짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.\", \"hashtags\": [\"#군산짬뽕\", \"#항구도시\"], \"place\": \"군산 중앙로\", \"postmark\": \"군산 中央\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"line\": \"단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.\", \"hashtags\": [\"#이성당\", \"#오래된가게\"], \"place\": \"이성당\", \"postmark\": \"군산 中央\", \"verified\": \"확인\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}, {\"line\": \"섬과 섬 사이를 다리로 건너면, 바다가 길이 된다.\", \"hashtags\": [\"#고군산군도\", \"#선유도\"], \"place\": \"고군산군도\", \"postmark\": \"고군산\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"오늘의 엽서\", \"subtitle\": \"그대로 붙여 쓰는 한 문장\"}" + }, + { + "id": "quiz", + "name": "뒤집어 보는 질문", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"quiz\", \"version\": 1, \"items\": [{\"question\": \"부잔교는 왜 물 위에 뜨도록 만들었을까?\", \"hint\": \"서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.\", \"topic\": \"군산 내항\", \"level\": \"초등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"『탁류』의 “탁류”는 무엇을 가리키는 말일까?\", \"hint\": \"금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.\", \"topic\": \"채만식문학관\", \"level\": \"중등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"question\": \"해망굴은 무엇을 위해 뚫린 굴일까?\", \"hint\": \"근대 교통로였고, 전쟁기에는 다른 쓰임도 있었습니다. 굴 하나에 두 시대가 겹칩니다.\", \"topic\": \"해망굴\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"고군산군도의 ‘고(古)’ 는 왜 붙었을까?\", \"hint\": \"지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.\", \"topic\": \"고군산군도\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"뒤집어 보는 질문\", \"subtitle\": \"뒤집으면 힌트가 나옵니다\"}" + }, + { + "id": "faq", + "name": "자주 묻는 질문", + "enabled": true, + "locked": false + }, + { + "id": "event", + "name": "소식", + "enabled": true, + "locked": false + }, + { + "id": "festival", + "name": "계절별 축제", + "enabled": true, + "locked": false + }, + { + "id": "story", + "name": "지역 이야기", + "enabled": true, + "locked": false + }, + { + "id": "weather", + "name": "날씨", + "enabled": true, + "locked": false + } + ], + "look": { + "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)", + "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)" + } + } +} \ No newline at end of file diff --git a/solution/site/payloads-mockup/stay.json.moved-from-payloads b/solution/site/payloads-mockup/stay.json.moved-from-payloads new file mode 100644 index 0000000..0feb41a --- /dev/null +++ b/solution/site/payloads-mockup/stay.json.moved-from-payloads @@ -0,0 +1,2230 @@ +{ + "schemaVersion": 1, + "site": { + "siteId": "e929e6b4-e502-4761-9786-0bb82178b375", + "placeId": "66894a1b-a331-43f1-942c-0ae064a704d6", + "status": 3, + "origin": "http://localhost", + "basePath": "/s/stay", + "slug": "stay", + "publishedAt": "2026-09-03T04:38:32.290153+00:00", + "updatedAt": "2026-09-02T23:31:05.719599+00:00", + "version": 13 + }, + "place": { + "name": "스테이,머뭄", + "category": 1, + "roadAddress": "전북 군산시 절골길 18", + "address": "전북 군산시 신흥동 63-18", + "addressRegion": "전북", + "addressLocality": "군산시", + "addressSubLocality": null, + "addressRegionCode": "KR-45", + "phone": "0507-1497-0983", + "latitude": 35.9864175, + "longitude": 126.7061254, + "regionCode": "52군산시", + "kakaoPlaceId": null + }, + "facts": [ + { + "key": "parking", + "label": "주차 가능", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.678003+00:00", + "verifiedAt": "2026-08-28T02:12:21.402109+00:00" + }, + { + "key": "intro", + "label": "숙소 소개", + "value": "편안한 침구와 좋은 위치로 편안한 잠자리", + "unit": null, + "type": "text", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.547669+00:00", + "verifiedAt": "2026-08-28T02:12:21.922925+00:00" + }, + { + "key": "wifi", + "label": "와이파이", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://m.place.naver.com/place/1133638931/home", + "critical": false, + "required": false, + "unitId": null, + "collectedAt": "2026-08-28T02:10:08.757070+00:00", + "verifiedAt": "2026-08-28T02:12:22.356600+00:00" + }, + { + "key": "check_out_time", + "label": "체크아웃 시간", + "value": "11:00", + "unit": null, + "type": "time", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "cancel_policy", + "label": "취소·환불 규정", + "value": "입실 당일 취소와 노쇼(No Show)는 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", + "unit": null, + "type": "text", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "cooking_allowed", + "label": "취사 가능", + "value": "true", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "pet_allowed", + "label": "반려동물 동반", + "value": "false", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "smoking", + "label": "흡연 가능", + "value": "false", + "unit": null, + "type": "bool", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "extra_person_fee", + "label": "인원 추가 요금", + "value": "20000", + "unit": "원", + "type": "number", + "scope": "place", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "check_in_time", + "label": "체크인 시간", + "value": "15:00", + "unit": null, + "type": "time", + "scope": "place", + "status": 3, + "sourceType": 1, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": null, + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T23:31:05.719599+00:00" + } + ], + "units": [ + { + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "name": "A동", + "slug": "a동", + "sortOrder": 0, + "facts": [ + { + "key": "room_type", + "label": "객실 타입", + "value": "독채 · 일본식 가정집", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "standard_capacity", + "label": "기준 인원", + "value": "2", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "max_capacity", + "label": "최대 인원", + "value": "4", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bed_type", + "label": "침대 구성", + "value": "퀸 사이즈 침대 1개", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bathroom_count", + "label": "욕실 수", + "value": "1", + "unit": "개", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "has_kitchen", + "label": "주방 여부", + "value": "true", + "unit": null, + "type": "bool", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "view", + "label": "전망", + "value": "정원", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "weekday_price", + "label": "주중 요금", + "value": "198000", + "unit": "원", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "room_intro", + "label": "객실 소개", + "value": "일본식 가정집 느낌으로 꾸민 독채입니다. 디딤돌 마당과 독립된 정원, 창고형 카페 공간이 딸려 있습니다.", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "71dd18bd-8bd0-4e46-a4b2-419b972d6175", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + } + ], + "mediaIds": [] + }, + { + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "name": "B동", + "slug": "b동", + "sortOrder": 1, + "facts": [ + { + "key": "room_type", + "label": "객실 타입", + "value": "독채 · 현대식 모던", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "standard_capacity", + "label": "기준 인원", + "value": "2", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "max_capacity", + "label": "최대 인원", + "value": "4", + "unit": "명", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": true, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bed_type", + "label": "침대 구성", + "value": "킹 사이즈 침대 1개", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://nol.yanolja.com/stay/domestic/10068088", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "bathroom_count", + "label": "욕실 수", + "value": "1", + "unit": "개", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://blog.naver.com/dhdktltm915/223494254125", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "has_kitchen", + "label": "주방 여부", + "value": "true", + "unit": null, + "type": "bool", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "view", + "label": "전망", + "value": "정원", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "weekday_price", + "label": "주중 요금", + "value": "198000", + "unit": "원", + "type": "number", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": true, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + }, + { + "key": "room_intro", + "label": "객실 소개", + "value": "아늑하고 깔끔한 현대식 모던 스타일 독채입니다. 독립된 정원과 창고형 카페 공간이 딸려 있습니다.", + "unit": null, + "type": "text", + "scope": "unit", + "status": 3, + "sourceType": 3, + "sourceUrl": "https://travel.interpark.com/checkinnow/goods/GY0002361440", + "critical": false, + "required": false, + "unitId": "916dc29b-241c-41a8-90de-51c3a9ddb947", + "collectedAt": "2026-09-02T13:27:53.241391+00:00", + "verifiedAt": "2026-09-02T13:27:53.241391+00:00" + } + ], + "mediaIds": [] + } + ], + "media": [ + { + "mediaId": "1330beb8-09d3-47fc-a410-f2e9598d13fe", + "url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "alt": "목재 서까래 천장과 미닫이창, 테이블이 있는 거실 공간", + "category": "거실", + "width": null, + "height": null, + "isPrimary": true, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg", + "unitId": null + }, + { + "mediaId": "ad35769c-5299-4d39-b80d-5d88f1e1de14", + "url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "alt": "소파와 탁자, 벽난로 조명이 있는 서까래 천장의 거실", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg", + "unitId": null + }, + { + "mediaId": "83a2543f-4366-4427-b694-a7b1968a61cf", + "url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "alt": "싱크대와 냉장고, 전자레인지가 구비된 실내 주방 공간", + "category": "주방", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg", + "unitId": null + }, + { + "mediaId": "990a89ed-9517-426c-8c17-fd9bf3c1a44d", + "url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "alt": "주방 싱크대 앞에서 포즈를 취하고 있는 여성의 모습", + "category": "주방", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg", + "unitId": null + }, + { + "mediaId": "b60e19c4-26dd-4ac5-a135-5496ca0a1471", + "url": "https://clip-service-phinf.pstatic.net/MjAyNjA3MzFfMjY4/MDAxNzg1NDc0MTE1NDk2.rBM4u6Y4G2x28WBTkBInTC4w0mCkt7SOWWUiFo0vNpQg.9B08LAonur-VaPCjpNLrn7Nh6VSHkt-uljDLRwIYuIUg.JPEG/thumbnail-9F1357F4-D8C1-412C-AF03-E53E1CCD3F14.jpg", + "alt": "해변과 푸른 바다, 산이 어우러진 해안가 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://clip-service-phinf.pstatic.net/MjAyNjA3MzFfMjY4/MDAxNzg1NDc0MTE1NDk2.rBM4u6Y4G2x28WBTkBInTC4w0mCkt7SOWWUiFo0vNpQg.9B08LAonur-VaPCjpNLrn7Nh6VSHkt-uljDLRwIYuIUg.JPEG/thumbnail-9F1357F4-D8C1-412C-AF03-E53E1CCD3F14.jpg", + "unitId": null + }, + { + "mediaId": "5b0adc6e-f03b-459d-9b0b-d0748f1e33d0", + "url": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", + "alt": "벽면에 설치된 그림 화면과 벽난로 조명 장식", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://video-phinf.pstatic.net/20241223_179/1734893614235FhY4h_JPEG/PXr9o3iPYJ_03.jpg", + "unitId": null + }, + { + "mediaId": "2fbcd37e-260f-4a4f-b00f-3e997e978c02", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", + "alt": "창밖으로 조명을 비춘 정원 나무와 석등이 보이는 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjUw/MDAxNzg1NzYwMjA0NTIy.XcTmqR10iQ8w9sFRfFRWo7CfQtNOoT6aRMo0VErTJLkg.EX6iDVemzmjovYRBJwJKdVWWipxu_JOixkmW1_APE_cg.JPEG/8034BC2C-FC29-4480-8BEC-40F0BED744CB.jpeg", + "unitId": null + }, + { + "mediaId": "d95857a0-44ad-422b-86de-00dee8aca08f", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", + "alt": "목재 서까래 천장에 설치된 조형적인 디자인의 조명 기구", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMjA3/MDAxNzg1NzYwMjA0OTI5.uMbalnmqu8lUYeUCS26c2luQ_pvQubkznSFblDcSV6Eg.gaRskjAm5Rud_L9lYS3xIQvd2m8-0R75DF6WJg2asrog.JPEG/628C312C-B7CA-4178-9B10-855CB16A9889.jpeg", + "unitId": null + }, + { + "mediaId": "0e5a2de5-384d-41fc-9ff4-258f9caa6b2c", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", + "alt": "실내 벽면 홈 공간에 놓인 흰색 화병과 식물 장식", + "category": "거실", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfMTg2/MDAxNzg1NzYwMjA0NjMw.5Ct417Oiyb5wlINojIkcVMLUy0QNbPoh8FhXvLT0Gcwg.QHSM9xd3ri3P9HaSeUWES1x8RMFcc-GpmpJTgoMaQkgg.JPEG/EEE3CB7C-55A9-4AC1-9574-D703537FF1C8.jpeg", + "unitId": null + }, + { + "mediaId": "4981b572-10e2-4138-ba1d-a5d7defb4b4a", + "url": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", + "alt": "실내 창문을 통해 바라본 푸른 하늘과 푸른 나무 풍경", + "category": "전망", + "width": null, + "height": null, + "isPrimary": false, + "sourceType": 3, + "originUrl": "https://pup-review-phinf.pstatic.net/MjAyNjA4MDNfOTMg/MDAxNzg1NzYwMjA1MTU1.Ulli7HXvsNrTkk_i3QnJknX7WBheTrnJRF6ARjyfQ14g.Vp9808LNn4hDRPFja1prpM7wuvVPBhqjpZZlec6Q-fMg.JPEG/F51B7821-B061-4173-BD07-E914E00B92AD.jpeg", + "unitId": null + } + ], + "faqs": [ + { + "faqId": "8d48a94d-1a19-456f-b1a8-4144ddcb2186", + "question": "체크인과 체크아웃 시간은 언제인가요?", + "answer": "체크인은 15:00부터이며, 체크아웃은 11:00까지입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 0 + }, + { + "faqId": "8f14678b-3589-42e9-8896-d214c0093784", + "question": "객실 기준 인원과 최대 인원, 인원 추가 요금은 어떻게 되나요?", + "answer": "A동과 B동 모두 기준 인원은 2명이며, 최대 4명까지 투숙하실 수 있습니다. 인원 추가 요금은 20,000원입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 1 + }, + { + "faqId": "a13785c0-a627-44b5-b593-fac39712d6d4", + "question": "객실 내 취사가 가능한가요?", + "answer": "네, 숙소 내 취사가 가능하며 A동과 B동 모두 주방 시설을 갖추고 있습니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 2 + }, + { + "faqId": "0eb535db-135f-484b-b0a4-1323744ace75", + "question": "주차와 와이파이 이용이 가능한가요?", + "answer": "네, 주차와 와이파이 모두 이용 가능합니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 3 + }, + { + "faqId": "cc268677-ad26-4685-a6a6-10a293658591", + "question": "반려동물 동반이나 실내 흡연이 가능한가요?", + "answer": "반려동물 동반은 불가하며, 전 구역 흡연이 불가능합니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 4 + }, + { + "faqId": "4b8ac158-7648-4b2c-aa1a-9c6ca99b1223", + "question": "A동과 B동의 침대 구성과 객실 특징은 무엇인가요?", + "answer": "A동은 퀸 사이즈 침대 1개가 구비된 일본식 가정집 느낌의 독채이며 디딤돌 마당, 정원, 창고형 카페 공간이 있습니다. B동은 킹 사이즈 침대 1개가 구비된 현대식 모던 독채로 독립된 정원과 창고형 카페 공간을 갖추고 있습니다. 두 객실 모두 욕실은 1개씩 마련되어 있습니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 5 + }, + { + "faqId": "8bd75b48-49c3-475f-99c1-f5e64f49bedd", + "question": "객실 요금은 얼마인가요?", + "answer": "A동과 B동의 주중 요금은 각각 198,000원입니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 6 + }, + { + "faqId": "7b2bbfeb-761c-4a15-b410-43e18a69bb9e", + "question": "예약 취소 및 환불 규정은 어떻게 되나요?", + "answer": "입실 당일 취소와 노쇼(No Show)의 경우 숙박요금의 100%가 부과되어 환불되지 않습니다. 그 이전 취소 수수료는 예약하신 채널의 취소 규정을 따릅니다.", + "status": 3, + "sourceType": 4, + "sortOrder": 7 + } + ], + "links": [ + { + "channel": 3, + "url": "https://m.place.naver.com/place/1133638931/home", + "title": "스테이,머뭄 네이버 플레이스", + "confirmed": true + } + ], + "local": { + "attractions": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "category": "관광지", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "location": "전북특별자치도 군산시 구영1길 17", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/70/3351370_image2_1.jpg", + "distanceText": "13m", + "distanceMeters": 13 + }, + { + "name": "초원사진관", + "category": "관광지", + "searchQuery": "초원사진관", + "location": "전북특별자치도 군산시 구영2길 12-1 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/53/3039553_image2_1.jpg", + "distanceText": "248m", + "distanceMeters": 248 + }, + { + "name": "군산 영화의 거리", + "category": "관광지", + "searchQuery": "군산 영화의 거리", + "location": "전북특별자치도 군산시 구영6길 60 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/44/3522144_image2_1.jpg", + "distanceText": "318m", + "distanceMeters": 318 + }, + { + "name": "우체통거리", + "category": "관광지", + "searchQuery": "우체통거리", + "location": "전북특별자치도 군산시 우체통거리2길 19 (신창동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/3549620_image2_1.jpg", + "distanceText": "566m", + "distanceMeters": 566 + }, + { + "name": "군산근대미술관", + "category": "관광지", + "searchQuery": "군산근대미술관", + "location": "전북특별자치도 군산시 해망로 230", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/51/2657651_image2_1.jpg", + "distanceText": "708m", + "distanceMeters": 708 + }, + { + "name": "옛 군산세관", + "category": "관광지", + "searchQuery": "옛 군산세관", + "location": "전북특별자치도 군산시 해망로 244-7", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/19/3530419_image2_1.jpg", + "distanceText": "728m", + "distanceMeters": 728 + }, + { + "name": "근대건축관 (구)조선은행군산지점", + "category": "관광지", + "searchQuery": "근대건축관 (구)조선은행군산지점", + "location": "전북특별자치도 군산시 해망로 214", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/19/3351519_image2_1.jpg", + "distanceText": "788m", + "distanceMeters": 788 + }, + { + "name": "군산 시간여행마을", + "category": "관광지", + "searchQuery": "군산 시간여행마을", + "location": "전북특별자치도 군산시 내항1길 8", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/32/2642432_image2_1.jpg", + "distanceText": "1.1km", + "distanceMeters": 1097 + }, + { + "name": "월명호수", + "category": "관광지", + "searchQuery": "월명호수", + "location": "전북특별자치도 군산시 소룡동 1071-3", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/22/3055522_image2_1.jpg", + "distanceText": "1.6km", + "distanceMeters": 1633 + }, + { + "name": "은적사(군산)", + "category": "관광지", + "searchQuery": "은적사(군산)", + "location": "전북특별자치도 군산시 설림3길 49 (소룡동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/14/3055614_image2_1.jpg", + "distanceText": "2.3km", + "distanceMeters": 2271 + }, + { + "name": "서천문화예술창작공간", + "category": "관광지", + "searchQuery": "서천문화예술창작공간", + "location": "충청남도 서천군 장항읍 장산로 323", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/80/3381180_image2_1.JPG", + "distanceText": "2.6km", + "distanceMeters": 2596 + }, + { + "name": "경암동 철길마을", + "category": "관광지", + "searchQuery": "경암동 철길마을", + "location": "전북특별자치도 군산시 경촌4길 14", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/50/3080250_image2_1.JPG", + "distanceText": "2.7km", + "distanceMeters": 2734 + }, + { + "name": "장항항", + "category": "관광지", + "searchQuery": "장항항", + "location": "충청남도 서천군 장항읍 장산로 232", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/82/2786282_image2_1.jpg", + "distanceText": "3.1km", + "distanceMeters": 3146 + }, + { + "name": "금강하굿둑놀이동산", + "category": "관광지", + "searchQuery": "금강하굿둑놀이동산", + "location": "충청남도 서천군 마서면 장산로855번길 13", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/64/3567064_image2_1.jpg", + "distanceText": "4.5km", + "distanceMeters": 4502 + }, + { + "name": "금강2경 도보여행길", + "category": "관광지", + "searchQuery": "금강2경 도보여행길", + "location": "충청남도 서천군 마서면 장산로855번길 56-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/65/3560165_image2_1.jpg", + "distanceText": "4.6km", + "distanceMeters": 4612 + }, + { + "name": "금강하구둑 관광지", + "category": "관광지", + "searchQuery": "금강하구둑 관광지", + "location": "충청남도 서천군 마서면 장산로855번길 56-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/91/3438991_image2_1.png", + "distanceText": "4.6km", + "distanceMeters": 4612 + }, + { + "name": "장항송림자연휴양림", + "category": "관광지", + "searchQuery": "장항송림자연휴양림", + "location": "충청남도 서천군 장항읍 장항산단로34번길 122-16", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/72/3529172_image2_1.jpg", + "distanceText": "5.1km", + "distanceMeters": 5065 + }, + { + "name": "금강하구둑(군산)", + "category": "관광지", + "searchQuery": "금강하구둑(군산)", + "location": "전북특별자치도 군산시 성산면 철새로 120", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/85/3416685_image2_1.JPG", + "distanceText": "5.1km", + "distanceMeters": 5073 + }, + { + "name": "이영춘가옥", + "category": "관광지", + "searchQuery": "이영춘가옥", + "location": "전북특별자치도 군산시 동개정길 7", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/14/2658214_image2_1.jpg", + "distanceText": "5.1km", + "distanceMeters": 5095 + }, + { + "name": "금강하구 철새도래지 (서천)", + "category": "관광지", + "searchQuery": "금강하구 철새도래지 (서천)", + "location": "충청남도 서천군 마서면 장산로 916", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/31/2786231_image2_1.jpg", + "distanceText": "5.2km", + "distanceMeters": 5188 + } + ], + "restaurants": [ + { + "name": "도란", + "category": "맛집", + "searchQuery": "도란", + "location": "전북특별자치도 군산시 구영신창길 61 (월명동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/33/3423333_image2_1.jpg", + "distanceText": "135m", + "distanceMeters": 135 + }, + { + "name": "물밀소", + "category": "맛집", + "searchQuery": "물밀소", + "location": "전북특별자치도 군산시 구영7길 23", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/03/2794403_image2_1.JPG", + "distanceText": "297m", + "distanceMeters": 297 + }, + { + "name": "일흥옥", + "category": "맛집", + "searchQuery": "일흥옥", + "location": "전북특별자치도 군산시 구영7길 25 (월명동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/60/2866960_image2_1.jpg", + "distanceText": "300m", + "distanceMeters": 300 + }, + { + "name": "명월갈비", + "category": "맛집", + "searchQuery": "명월갈비", + "location": "전북특별자치도 군산시 구영7길 59 명월갈비", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/48/2924648_image2_1.jpg", + "distanceText": "351m", + "distanceMeters": 351 + }, + { + "name": "만남스넥", + "category": "맛집", + "searchQuery": "만남스넥", + "location": "전북특별자치도 군산시 대학로 67-3 만남스넥", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/62/2877162_image2_1.jpg", + "distanceText": "405m", + "distanceMeters": 405 + }, + { + "name": "동국사다온", + "category": "맛집", + "searchQuery": "동국사다온", + "location": "전북특별자치도 군산시 동국사길 16 (금광동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/38/3423338_image2_1.jpg", + "distanceText": "418m", + "distanceMeters": 418 + }, + { + "name": "국제반점", + "category": "맛집", + "searchQuery": "국제반점", + "location": "전북특별자치도 군산시 구영6길 100 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/46/2877146_image2_1.jpg", + "distanceText": "467m", + "distanceMeters": 467 + }, + { + "name": "영화원", + "category": "맛집", + "searchQuery": "영화원", + "location": "전북특별자치도 군산시 구영5길 112 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/76/2877176_image2_1.jpg", + "distanceText": "495m", + "distanceMeters": 495 + }, + { + "name": "틈(TEUM)", + "category": "맛집", + "searchQuery": "틈(TEUM)", + "location": "전북특별자치도 군산시 구영6길 125-1 (영화동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/50/2794350_image2_1.JPG", + "distanceText": "573m", + "distanceMeters": 573 + }, + { + "name": "미즈커피", + "category": "맛집", + "searchQuery": "미즈커피", + "location": "전북특별자치도 군산시 해망로 232 (장미동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/2774220_image2_1.jpg", + "distanceText": "696m", + "distanceMeters": 696 + }, + { + "name": "[백년가게]운정식당", + "category": "맛집", + "searchQuery": "[백년가게]운정식당", + "location": "전북특별자치도 군산시 중정길 8-2", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/43/2616343_image2_1.jpg", + "distanceText": "748m", + "distanceMeters": 748 + }, + { + "name": "빈해원", + "category": "맛집", + "searchQuery": "빈해원", + "location": "전북특별자치도 군산시 동령길 57", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/40/3575640_image2_1.jpg", + "distanceText": "749m", + "distanceMeters": 749 + }, + { + "name": "복성루", + "category": "맛집", + "searchQuery": "복성루", + "location": "전북특별자치도 군산시 월명로 382", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/09/3083009_image2_1.png", + "distanceText": "1.3km", + "distanceMeters": 1251 + }, + { + "name": "왕산반점", + "category": "맛집", + "searchQuery": "왕산반점", + "location": "전북특별자치도 군산시 대학로 209", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/73/2752473_image2_1.jpg", + "distanceText": "1.5km", + "distanceMeters": 1499 + }, + { + "name": "리투스카페", + "category": "맛집", + "searchQuery": "리투스카페", + "location": "전북특별자치도 군산시 해망로 118", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/52/2877152_image2_1.jpg", + "distanceText": "1.6km", + "distanceMeters": 1605 + }, + { + "name": "수송반점", + "category": "맛집", + "searchQuery": "수송반점", + "location": "전북특별자치도 군산시 동메3길 23 수송반점", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/88/2866888_image2_1.jpg", + "distanceText": "1.7km", + "distanceMeters": 1739 + }, + { + "name": "군산활어회센타", + "category": "맛집", + "searchQuery": "군산활어회센타", + "location": "전북특별자치도 군산시 문화로 41", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/20/2923820_image2_1.jpg", + "distanceText": "1.9km", + "distanceMeters": 1854 + }, + { + "name": "진성원", + "category": "맛집", + "searchQuery": "진성원", + "location": "전북특별자치도 군산시 구암3.1로 70-1 진성원", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/79/2866979_image2_1.jpg", + "distanceText": "1.9km", + "distanceMeters": 1934 + }, + { + "name": "서우식당", + "category": "맛집", + "searchQuery": "서우식당", + "location": "전북특별자치도 군산시 나운안2길 9-6 서우식당", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/68/2877168_image2_1.jpg", + "distanceText": "2.0km", + "distanceMeters": 1980 + }, + { + "name": "송가짬뽕", + "category": "맛집", + "searchQuery": "송가짬뽕", + "location": "전북특별자치도 군산시 동수송안길 55 (수송동)", + "imageUrl": "http://tong.visitkorea.or.kr/cms/resource/54/2924754_image2_1.jpg", + "distanceText": "2.2km", + "distanceMeters": 2150 + } + ], + "festivals": [ + { + "name": "군산시간여행축제", + "month": "10월", + "searchQuery": "군산시간여행축제", + "period": "2026.10.02 ~ 2026.10.05", + "location": "전북특별자치도 군산시 중앙로1가", + "distanceText": "522m", + "distanceMeters": 522, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/48/3561448_image2_1.jpg" + }, + { + "name": "군산짬뽕페스티벌", + "month": "10월", + "searchQuery": "군산짬뽕페스티벌", + "period": "2026.10.09 ~ 2026.10.11", + "location": "전북특별자치도 군산시 장미동 13-1", + "distanceText": "792m", + "distanceMeters": 792, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/49/4107749_image2_1.jpg" + }, + { + "name": "제5회 호러 홀로그램 페스티벌", + "month": "9월", + "searchQuery": "제5회 호러 홀로그램 페스티벌", + "period": "2026.09.10 ~ 2026.09.20", + "location": "전북특별자치도 익산시 성당면 함낭로 207", + "distanceText": "22.3km", + "distanceMeters": 22279, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/00/4103000_image2_1.jpg" + }, + { + "name": "NS푸드페스타", + "month": "10월", + "searchQuery": "NS푸드페스타", + "period": "2026.10.16 ~ 2026.10.17", + "location": "전북특별자치도 익산시 함열읍 익산대로78길 137", + "distanceText": "23.5km", + "distanceMeters": 23507, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/11/4096811_image2_1.jpg" + }, + { + "name": "김제지평선축제", + "month": "10월", + "searchQuery": "김제지평선축제", + "period": "2026.10.01 ~ 2026.10.05", + "location": "전북특별자치도 김제시 부량면 벽골제로 442", + "distanceText": "29.0km", + "distanceMeters": 29003, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/3516354_image2_1.jpg" + }, + { + "name": "부안붉은노을축제", + "month": "10월", + "searchQuery": "부안붉은노을축제", + "period": "2026.10.16 ~ 2026.10.18", + "location": "전북특별자치도 부안군 변산면 대항리", + "distanceText": "38.2km", + "distanceMeters": 38172, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/06/4107306_image2_1.jpg" + }, + { + "name": "제9회 전주국제단편영화제", + "month": "9월", + "searchQuery": "제9회 전주국제단편영화제", + "period": "2026.09.17 ~ 2026.09.21", + "location": "전북특별자치도 전주시 완산구 전주객사3길 72 (고사동)", + "distanceText": "43.4km", + "distanceMeters": 43392, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/4106254_image2_1.jpg" + }, + { + "name": "전주한옥마을 전통연희 퍼레이드-노상놀이야", + "month": "4월", + "searchQuery": "전주한옥마을 전통연희 퍼레이드-노상놀이야", + "period": "2026.04.18 ~ 2026.10.31", + "location": "전북특별자치도 전주시 완산구 태조로 44 (풍남동3가)", + "distanceText": "44.3km", + "distanceMeters": 44333, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/58/4067258_image2_1.JPG" + }, + { + "name": "완주 와일드&로컬푸드축제", + "month": "10월", + "searchQuery": "완주 와일드&로컬푸드축제", + "period": "2026.10.02 ~ 2026.10.04", + "location": "전북특별자치도 완주군 고산면 고산휴양림로 89", + "distanceText": "47.3km", + "distanceMeters": 47267, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/49/4062849_image2_1.JPG" + }, + { + "name": "완주 상관 워터풀파티", + "month": "9월", + "searchQuery": "완주 상관 워터풀파티", + "period": "2026.09.12 ~ 2026.09.13", + "location": "전북특별자치도 완주군 상관면 춘향로 4355", + "distanceText": "54.1km", + "distanceMeters": 54066, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/30/4103130_image2_1.png" + }, + { + "name": "정읍 구절초 꽃축제", + "month": "10월", + "searchQuery": "정읍 구절초 꽃축제", + "period": "2026.10.08 ~ 2026.10.18", + "location": "전북특별자치도 정읍시 산내면 청정로 926-89", + "distanceText": "55.7km", + "distanceMeters": 55733, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/56/4071256_image2_1.JPG" + }, + { + "name": "고창모양성제", + "month": "10월", + "searchQuery": "고창모양성제", + "period": "2026.10.15 ~ 2026.10.19", + "location": "전북특별자치도 고창군 고창읍 모양성로 11", + "distanceText": "61.5km", + "distanceMeters": 61521, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/30/3519730_image2_1.jpg" + }, + { + "name": "임실N치즈축제", + "month": "10월", + "searchQuery": "임실N치즈축제", + "period": "2026.10.08 ~ 2026.10.11", + "location": "전북특별자치도 임실군 성수면 도인2길 50", + "distanceText": "66.6km", + "distanceMeters": 66648, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/54/3377054_image2_1.png" + }, + { + "name": "트레저헌터 in 진안", + "month": "8월", + "searchQuery": "트레저헌터 in 진안", + "period": "2026.08.29 ~ 2026.09.19", + "location": "전북특별자치도 진안군 진안읍 외사양길 16-19", + "distanceText": "68.1km", + "distanceMeters": 68052, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/08/4096408_image2_1.jpeg" + }, + { + "name": "진안홍삼축제", + "month": "9월", + "searchQuery": "진안홍삼축제", + "period": "2026.09.18 ~ 2026.09.20", + "location": "전북특별자치도 진안군 진안읍 마이산로 160", + "distanceText": "68.2km", + "distanceMeters": 68236, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/75/4092075_image2_1.jpg" + }, + { + "name": "순창장류축제", + "month": "10월", + "searchQuery": "순창장류축제", + "period": "2026.10.15 ~ 2026.10.18", + "location": "전북특별자치도 순창군 순창읍 장류로 55", + "distanceText": "77.9km", + "distanceMeters": 77866, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/59/3535559_image2_1.JPG" + }, + { + "name": "장수한우랑사과랑축제", + "month": "9월", + "searchQuery": "장수한우랑사과랑축제", + "period": "2026.09.10 ~ 2026.09.13", + "location": "전북특별자치도 장수군 장수읍 한누리로 378", + "distanceText": "82.8km", + "distanceMeters": 82797, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/79/4095779_image2_1.jpg" + }, + { + "name": "무주반딧불축제", + "month": "9월", + "searchQuery": "무주반딧불축제", + "period": "2026.09.04 ~ 2026.09.12", + "location": "전북특별자치도 무주군 무주읍 한풍루로 326-17", + "distanceText": "86.1km", + "distanceMeters": 86065, + "imageUrl": "https://tong.visitkorea.or.kr/cms/resource/66/3520566_image2_1.JPG" + } + ], + "courses": [], + "weather": { + "temperature": 25.0, + "condition": "구름많음", + "observedAt": "2026-09-09T12:45" + }, + "story": { + "quiz": [ + { + "hint": "군산문화관광은 군산을 근대역사와 해양중심의 도시로 소개한다.", + "level": "초등", + "topic": "군산문화관광", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "왜 전북특별자치도 군산시는 근대역사와 바다를 함께 떠올리게 될까?", + "verified": "확인" + }, + { + "hint": "군산 시간여행 코스에는 근대역사박물관과 근대건축관, 초원사진관 등이 묶여 있다.", + "level": "중등", + "topic": "시간여행 코스", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "어떻게 전북특별자치도 군산시는 옛 건물과 이야기를 여행 자원으로 살리고 있을까?", + "verified": "확인" + }, + { + "hint": "군산근대역사박물관은 군산의 근대문화자원을 활용해 과거와 현재, 미래를 잇는다고 소개한다.", + "level": "초등", + "topic": "군산근대역사박물관", + "source": { + "url": "https://museum.gunsan.go.kr/", + "name": "군산근대역사박물관" + }, + "question": "왜 전북특별자치도 군산시는 박물관을 중심으로 도시의 과거를 보여 주려 할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광의 시간여행 코스에는 진포해양공원, 뜬다리 부두, 해망굴이 포함되어 있다.", + "level": "중등", + "topic": "해양·항만 유산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "어떻게 전북특별자치도 군산시는 바다와 항만의 흔적을 시민과 여행자에게 전할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 말랭이마을과 경암동 철길마을이 관광명소로 소개된다.", + "level": "초등", + "topic": "도심 마을 관광", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2080", + "name": "군산문화관광 관광명소" + }, + "question": "왜 전북특별자치도 군산시는 구도심의 골목과 마을을 함께 돌아보게 만들까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 일제강점기 군산역사관과 3.1운동 100주년 기념관이 소개된다.", + "level": "중등", + "topic": "기억·교육 공간", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2080", + "name": "군산문화관광 관광명소" + }, + "question": "어떻게 전북특별자치도 군산시는 일제강점기 기억을 교육과 여행으로 연결할까?", + "verified": "확인" + }, + { + "hint": "군산문화관광은 동국사 대웅전을 시간여행 코스에 포함하고 있다.", + "level": "초등", + "topic": "동국사", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "군산문화관광 시간여행 코스 소개" + }, + "question": "왜 전북특별자치도 군산시는 동국사를 중요한 배움의 장소로 볼 수 있을까?", + "verified": "확인" + }, + { + "hint": "군산시 문화관광에는 국가지정 문화유산, 도지정 문화유산, 국가등록 문화유산 등의 항목이 따로 보인다.", + "level": "어른", + "topic": "문화유산 분류", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "어떻게 전북특별자치도 군산시는 문화유산을 여러 종류로 나누어 알릴까?", + "verified": "확인" + }, + { + "hint": "군산문화관광에는 선유도 망주봉 일원과 선유도 고려유적이 따로 안내된다.", + "level": "중등", + "topic": "선유도", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "question": "왜 전북특별자치도 군산시는 선유도와 주변 섬 이야기를 함께 소개할까?", + "verified": "확인" + }, + { + "hint": "군산시향토유산으로 민살풀이 춤이 소개된다.", + "level": "어른", + "topic": "향토유산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2073", + "name": "군산문화관광 향토유산" + }, + "question": "어떻게 전북특별자치도 군산시는 지역의 무형유산을 다음 세대에 전하려 할까?", + "verified": "확인" + }, + { + "hint": "군산근대역사박물관은 동절기와 하절기 운영 시간이 다르다고 안내한다.", + "level": "중등", + "topic": "군산근대역사박물관 운영", + "source": { + "url": "https://museum.gunsan.go.kr/new/board/index.do;jsessionid=59B38B660C0A78ECA24DE10835D1110D?id=788&bbs_id=3&page=18&search=&searchDiv=&type=&act=view", + "name": "군산근대역사박물관 보도자료" + }, + "question": "왜 전북특별자치도 군산시는 박물관 운영 시간을 계절에 따라 달리할까?", + "verified": "확인" + }, + { + "hint": "군산시에는 33건의 지정 문화재가 있다는 설명이 보인다.", + "level": "어른", + "topic": "지정 문화재", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc/GC05700562", + "name": "군산 문화유산 설명" + }, + "question": "어떻게 전북특별자치도 군산시는 지정 문화재를 도시 이야기와 연결해 볼 수 있을까?", + "verified": "확인" + } + ], + "postcard": [ + { + "line": "군산은 근대역사와 해양중심의 도시다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/", + "name": "군산문화관광" + }, + "hashtags": [ + "#군산", + "#전북특별자치도" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산시 누리집엔 시간여행마을이 보인다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 누리집" + }, + "hashtags": [ + "#군산", + "#시간여행마을" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 근대역사박물관이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#근대역사" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 초원사진관이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#초원사진관" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 동국사 대웅전이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#동국사" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산 시간여행 코스에 해망굴이 있다", + "place": "군산", + "source": { + "url": "https://www.gunsan.go.kr/tour/m2355", + "name": "시간여행 코스 소개" + }, + "hashtags": [ + "#군산", + "#해망굴" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 내항 뜬다리 부두가 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#내항" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 내항 역사문화공간이 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#역사문화" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산에는 군산 어청도 등대가 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#어청도" + ], + "postmark": "군산", + "verified": "확인" + }, + { + "line": "군산시청 주소는 시청로 17이다", + "place": "군산시청", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 대표" + }, + "hashtags": [ + "#군산", + "#군산시청" + ], + "postmark": "군산시청", + "verified": "확인" + }, + { + "line": "군산시청은 조촌동에 있다", + "place": "조촌동", + "source": { + "url": "https://www.gunsan.go.kr/main", + "name": "군산시 대표" + }, + "hashtags": [ + "#군산", + "#조촌동" + ], + "postmark": "조촌", + "verified": "확인" + }, + { + "line": "군산에는 국가등록문화유산이 여럿 있다", + "place": "군산", + "source": { + "url": "https://www.heritage.go.kr/heri/cul/culSelectRegionList.do?s_kdcd=&s_ctcd=35&ccbaKdcd=&ccbaAsno=&ccbaCtcd=&ccbaCpno=&ccbaLcto=12&culPageNo=5&header=region&pageNo=1&returnUrl=/heri/cul/culSelectRegionList.do&assetnamel=&searchCondition=&searchCondition2=®ion=", + "name": "국가유산포털" + }, + "hashtags": [ + "#군산", + "#국가유산" + ], + "postmark": "군산", + "verified": "확인" + } + ], + "chronicle": [ + { + "year": 1380, + "title": "금강 일대에서 왜구 격퇴", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "고려 우왕 6년, 금강에서 왜구의 대규모 선박을 최무선의 화포로 격멸한 기록이 전해진다. 군산 해역이 외세 방어의 전초로 기억되는 계기다.", + "verified": "확인" + }, + { + "year": 1403, + "title": "조선 초기 옥구현 체제 정착", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "군산 지역은 조선 초기에 옥구현 중심의 행정 체계로 이어졌다. 현재 군산의 행정사 출발점으로 볼 수 있다.", + "verified": "확인" + }, + { + "year": 1899, + "title": "군산항 개항과 옥구부 설치", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "군산항이 개항되면서 옥구부가 설치되었다. 근대 군산의 성장과 항만도시화가 본격화된 전환점이다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1910, + "title": "군산부로 개편", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "개항장 체제의 변화 속에서 옥구부가 군산부로 바뀌었다. 도시의 행정 중심이 군산항 일대로 더 분명히 굳어졌다.", + "verified": "확인" + }, + { + "year": 1914, + "title": "행정구역 재편으로 옥구군·군산부 체계 형성", + "source": { + "url": "https://www.gunsan.go.kr/main/m348", + "name": "군산의 역사" + }, + "summary": "일제강점기 행정구역 개편으로 군산부와 옥구군 체계가 자리 잡았다. 이후 군산은 항만과 배후농촌이 맞물린 구조로 발전했다.", + "verified": "확인" + }, + { + "year": 1946, + "title": "해방 후 행정동 재편", + "source": { + "url": "https://ko.wikipedia.org/wiki/%EA%B5%B0%EC%82%B0%EC%8B%9C", + "name": "군산시" + }, + "summary": "왜식 동명 변경으로 군산의 도심 행정 구역이 새롭게 정비되었다. 해방 직후 도시 정체성을 다시 세운 과정이다.", + "verified": "확인" + }, + { + "year": 1949, + "title": "군산부에서 군산시로 개칭", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "지방자치법 시행에 따라 군산부가 군산시로 바뀌었다. 오늘의 군산시 행정 체계가 시작된 해로 볼 수 있다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1974, + "title": "군산외항 부두 건설 시작", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "1970년대 중반부터 군산외항 제1·2·3부두 건설이 이어졌다. 항만 기능 확장은 군산의 산업도시 성격을 강화했다.", + "verified": "확인" + }, + { + "year": 1988, + "title": "군산국가산업단지 조성", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "1988년부터 1994년까지 군산국가산업단지가 조성되었다. 제조업 기반이 커지며 도시 경제의 축이 바뀌었다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1990, + "title": "금강하구둑 준공", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "금강하구둑이 준공되며 전북과 충남을 잇는 대형 수리 시설이 완성되었다. 군산의 수변·교통 환경에 큰 변화를 준 사건이다.", + "verified": "확인" + }, + { + "year": 1995, + "title": "옥구군과 통합해 도농통합 군산시 출범", + "source": { + "url": "https://museum.gunsan.go.kr/content/sub04/04_01.jsp", + "name": "군산의 역사문화" + }, + "summary": "군산시와 옥구군이 통합되어 도농통합형 군산시가 탄생했다. 현재의 광역적 생활권과 행정 범위가 형성된 전환점이다.", + "turning": true, + "verified": "확인" + }, + { + "year": 1999, + "title": "군산개항 100주년", + "source": { + "url": "https://www.gunsan.go.kr/child/m1968", + "name": "군산의 역사" + }, + "summary": "군산은 개항 100주년을 맞아 근대 항만도시의 역사를 재조명했다. 개항의 의미가 도시의 역사 서사로 다시 강조된 시기다.", + "verified": "확인" + } + ], + "people": [ + { + "name": "채만식", + "role": "소설가", + "years": "1902–1950", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산에서 태어난 한국 근대문학의 대표 작가로, 일제강점기 군산을 배경으로 한 작품으로도 널리 알려져 있다.", + "verified": "확인", + "imageQuery": "채만식 초상" + }, + { + "name": "김수미", + "role": "배우", + "years": "1949–2024", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출생으로 소개되는 한국의 대표적 배우로, 오랜 기간 영화와 방송에서 활동했다.", + "verified": "확인", + "imageQuery": "김수미 초상" + }, + { + "name": "박명수", + "role": "방송인·코미디언·가수", + "years": "1970–", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출신으로 소개되는 방송인으로, 예능 프로그램을 통해 전국적 인지도를 얻었다.", + "verified": "확인", + "imageQuery": "박명수 초상" + }, + { + "name": "송새벽", + "role": "배우", + "years": "1979–", + "source": { + "url": "https://www.kupi.com/en-ae/explore/korea-republic-of/gunsan/history", + "name": "KUPI" + }, + "oneLine": "군산 출생으로 소개되는 배우로, 영화와 드라마에서 개성 있는 조연 연기로 이름을 알렸다.", + "verified": "확인", + "imageQuery": "송새벽 초상" + }, + { + "name": "고은", + "role": "시인", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc?search=5ca8d72b-38ad-4d29-88c6-31d3c7f95582", + "name": "디지털군산문화대전" + }, + "oneLine": "군산과 깊이 얽힌 대표 문인으로, 군산 문학 자료에서 군산을 대표하는 문학인 가운데 한 명으로 소개된다.", + "verified": "확인필요", + "imageQuery": "고은 시인 초상" + }, + { + "name": "이만흥", + "role": "영화인", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화사의 초기 전개와 관련해 언급되는 인물로, 지역 영화 문화의 개척자로 소개된다.", + "verified": "확인필요", + "imageQuery": "이만흥 군산 영화인" + }, + { + "name": "이강천", + "role": "영화감독", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화 문화의 여명기 인물로 함께 거론되며, 지역 영화사 서술에서 자주 등장한다.", + "verified": "확인필요", + "imageQuery": "이강천 영화감독" + }, + { + "name": "이집길", + "role": "배우", + "source": { + "url": "https://www.todaygunsan.co.kr/news/articleView.html?idxno=18896", + "name": "오늘군산" + }, + "oneLine": "군산 영화사 초기 인물로 소개되며, 지역 영화의 출발점과 연결되어 언급된다.", + "verified": "확인필요", + "imageQuery": "이집길 배우" + }, + { + "name": "정만채", + "role": "교육자·사회운동가", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc?search=5ca8d72b-38ad-4d29-88c6-31d3c7f95582", + "name": "디지털군산문화대전" + }, + "oneLine": "군산에서 동산학원을 설립하고 군산 시사회를 조직한 인물로, 지역 교육과 사회 활동에 흔적을 남겼다.", + "verified": "확인", + "imageQuery": "정만채 군산" + }, + { + "name": "김종량", + "role": "언론인", + "source": { + "url": "https://gunsan.grandculture.net/gunsan/toc/GC05701157", + "name": "디지털군산문화대전" + }, + "oneLine": "전라북도 군산 지역 출신 언론인으로 소개된다.", + "verified": "확인", + "imageQuery": "김종량 언론인 군산" + } + ], + "songs": [ + { + "year": 1963, + "story": "군산항을 정면으로 제목에 내건 초기 대중가요다. 항구의 이별 정서와 지역 지명이 함께 드러난다.", + "title": "잘 있거라 군산항", + "artist": "안다성", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "손석우", + "lyricist": "손석우", + "verified": "확인" + }, + { + "year": 1965, + "story": "군산항을 배경으로 한 이별 노래로 전해진다. 1960년대 군산항 노래들 가운데 하나로 묶여 소개된다.", + "title": "헤여진 군산항", + "artist": "박제연", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "손운선", + "lyricist": "송운선", + "verified": "확인" + }, + { + "year": 1966, + "story": "군산항을 중심 배경으로 삼은 1960년대 트로트 계열의 곡이다. 항구와 이별의 정서가 함께 읽힌다.", + "title": "님 떠난 군산항", + "artist": "이미자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "백영호", + "lyricist": "조형식", + "verified": "확인" + }, + { + "year": 1966, + "story": "군산과 맞닿은 금강을 배경으로 한 노래다. 지명과 강을 함께 품은 지역 서정가로 소개된다.", + "title": "금강 나그네", + "artist": "이미자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "박춘석", + "lyricist": "이병훈", + "verified": "확인" + }, + { + "year": 1967, + "story": "군산항구를 제목에 넣은 곡으로, 1960년대 군산 관련 가요 묶음에 포함된다. 항구 도시의 이별 정서를 다룬 노래로 볼 수 있다.", + "title": "잘 있거라 군산항구", + "artist": "최정자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "유금춘", + "lyricist": "최치수", + "verified": "확인" + }, + { + "year": 1969, + "story": "군산을 제목에 둔 1960년대 후반의 지역 가요다. 항구 도시 특유의 유흥가·이별 분위기를 떠올리게 하는 유형의 노래로 묶인다.", + "title": "군산 부르스", + "artist": "유주용", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "강도미", + "lyricist": "반야월", + "verified": "확인" + }, + { + "year": 1969, + "story": "군산항의 부두를 직접 배경으로 삼은 노래다. 부두와 이별이라는 키워드가 결합된 항구 노래다.", + "title": "이별의 군산항 부두", + "artist": "최정자", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "유금춘", + "lyricist": "최치수", + "verified": "확인" + }, + { + "year": 1984, + "story": "1980년대에 나온 군산항 제목의 곡이다. 항구 도시 군산의 밤과 이별 정서를 전면에 둔 노래로 소개된다.", + "title": "군산항 부르스", + "artist": "최하우", + "source": { + "url": "https://music.osulgil.com/entry/%EC%A0%84%EB%B6%81-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98", + "name": "음악의 오솔길" + }, + "composer": "김성근", + "lyricist": "최하우", + "verified": "확인" + } + ] + }, + "itineraries": [ + { + "name": "1박 2일", + "duration": "1박 2일", + "days": [ + { + "label": "첫째 날", + "stops": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "latitude": 35.986301, + "longitude": 126.7061578 + }, + { + "name": "도란", + "searchQuery": "도란", + "latitude": 35.9868704, + "longitude": 126.7075355 + }, + { + "name": "물밀소", + "searchQuery": "물밀소", + "latitude": 35.9851317, + "longitude": 126.7090576 + }, + { + "name": "군산시간여행축제", + "searchQuery": "군산시간여행축제", + "latitude": 35.987791, + "longitude": 126.711671 + } + ] + }, + { + "label": "둘째 날", + "stops": [ + { + "name": "초원사진관", + "searchQuery": "초원사진관", + "latitude": 35.987737, + "longitude": 126.7083826 + }, + { + "name": "명월갈비", + "searchQuery": "명월갈비", + "latitude": 35.9864982, + "longitude": 126.7100742 + }, + { + "name": "일흥옥", + "searchQuery": "일흥옥", + "latitude": 35.98524, + "longitude": 126.7091715 + }, + { + "name": "군산짬뽕페스티벌", + "searchQuery": "군산짬뽕페스티벌", + "latitude": 35.9896779, + "longitude": 126.7139462 + } + ] + } + ] + }, + { + "name": "2박 3일", + "duration": "2박 3일", + "days": [ + { + "label": "첫째 날", + "stops": [ + { + "name": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "searchQuery": "군산 신흥동 일본식가옥(히로쓰 가옥)", + "latitude": 35.986301, + "longitude": 126.7061578 + }, + { + "name": "도란", + "searchQuery": "도란", + "latitude": 35.9868704, + "longitude": 126.7075355 + }, + { + "name": "물밀소", + "searchQuery": "물밀소", + "latitude": 35.9851317, + "longitude": 126.7090576 + }, + { + "name": "군산시간여행축제", + "searchQuery": "군산시간여행축제", + "latitude": 35.987791, + "longitude": 126.711671 + } + ] + }, + { + "label": "둘째 날", + "stops": [ + { + "name": "초원사진관", + "searchQuery": "초원사진관", + "latitude": 35.987737, + "longitude": 126.7083826 + }, + { + "name": "명월갈비", + "searchQuery": "명월갈비", + "latitude": 35.9864982, + "longitude": 126.7100742 + }, + { + "name": "일흥옥", + "searchQuery": "일흥옥", + "latitude": 35.98524, + "longitude": 126.7091715 + }, + { + "name": "군산짬뽕페스티벌", + "searchQuery": "군산짬뽕페스티벌", + "latitude": 35.9896779, + "longitude": 126.7139462 + } + ] + }, + { + "label": "셋째 날", + "stops": [ + { + "name": "군산 영화의 거리", + "searchQuery": "군산 영화의 거리", + "latitude": 35.9866621, + "longitude": 126.7096909 + }, + { + "name": "만남스넥", + "searchQuery": "만남스넥", + "latitude": 35.9842568, + "longitude": 126.7097992 + }, + { + "name": "동국사다온", + "searchQuery": "동국사다온", + "latitude": 35.9828792, + "longitude": 126.707755 + }, + { + "name": "제5회 호러 홀로그램 페스티벌", + "searchQuery": "제5회 호러 홀로그램 페스티벌", + "latitude": 36.0693015, + "longitude": 126.9316832 + } + ] + } + ] + } + ], + "syncedAt": "2026-09-09T09:00:27.066565+00:00" + }, + "routes": [], + "narrative": { + "about": [ + "편안한 침구와 좋은 위치로 편안한 잠자리" + ], + "summary": "편안한 침구와 좋은 위치로 편안한 잠자리" + }, + "theme": { + "templateId": "stay-retro", + "colors": { + "primary": "#1b1a15", + "secondary": "#4c4739", + "bg": "#e4dac0", + "card": "#efe7d3", + "text": "#1b1a15", + "accent": "#bf2f1b" + }, + "fontStyle": "옛 간판체", + "sections": [ + { + "id": "hero", + "name": "히어로", + "enabled": true, + "locked": true + }, + { + "id": "intro", + "name": "소개", + "enabled": true, + "locked": false + }, + { + "id": "rooms", + "name": "객실 안내", + "enabled": true, + "locked": false + }, + { + "id": "info", + "name": "기본 정보", + "enabled": true, + "locked": true + }, + { + "id": "booking", + "name": "실시간 예약", + "enabled": true, + "locked": false + }, + { + "id": "video", + "name": "영상으로 보기", + "enabled": true, + "locked": false, + "data": "{\n \"kind\": \"video\",\n \"version\": 1,\n \"title\": \"영상으로 보기\",\n \"subtitle\": \"사진으로는 다 안 보이는 것\",\n \"items\": [\n {\n \"url\": \"https://youtube.com/shorts/c2ZdwhaB7S4\",\n \"caption\": \"머뭄의 하루\",\n \"verified\": \"확인\"\n },\n {\n \"url\": \"https://youtube.com/shorts/_bbo3dVgOXs\",\n \"caption\": \"마당과 창고형 카페 공간\",\n \"verified\": \"확인\"\n },\n {\n \"url\": \"https://youtube.com/shorts/qrCG4rOeJvs\",\n \"caption\": \"밤의 머뭄\",\n \"verified\": \"확인\"\n }\n ]\n}" + }, + { + "id": "photos", + "name": "사진 갤러리", + "enabled": true, + "locked": false, + "variantId": "photos.carousel" + }, + { + "id": "map", + "name": "오시는 길", + "enabled": true, + "locked": true + }, + { + "id": "local", + "name": "지역 정보", + "enabled": true, + "locked": false + }, + { + "id": "itinerary", + "name": "추천 일정", + "enabled": true, + "locked": false, + "data": "{\n \"kind\": \"itinerary\",\n \"version\": 1,\n \"title\": \"추천 일정\",\n \"subtitle\": \"며칠 묵느냐에 따라 다르게 돕니다\",\n \"items\": [\n {\n \"name\": \"탁류길 코스\",\n \"duration\": \"반나절\",\n \"audience\": \"걷기 좋아하는 손님\",\n \"why\": \"소설 『탁류』가 지나간 자리를 그대로 따라 걷습니다.\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"채만식문학관\",\n \"note\": \"금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.\",\n \"searchQuery\": \"군산 채만식문학관\",\n \"moveMinutes\": 12\n },\n {\n \"name\": \"군산 내항 부잔교\",\n \"note\": \"조수에 오르내리던 뜬다리. 이 코스의 중심입니다.\",\n \"searchQuery\": \"군산 내항 부잔교\",\n \"moveMinutes\": 9\n },\n {\n \"name\": \"째보선창\",\n \"note\": \"항구 노동의 기억이 남은 선창입니다.\",\n \"searchQuery\": \"군산 째보선창\",\n \"moveMinutes\": 7\n },\n {\n \"name\": \"초원사진관\",\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\",\n \"moveMinutes\": 11\n },\n {\n \"name\": \"이성당\",\n \"note\": \"코스의 끝. 단팥빵 하나로 반나절을 닫습니다.\",\n \"searchQuery\": \"군산 이성당\",\n \"moveMinutes\": 6\n }\n ],\n \"verified\": \"확인\",\n \"source\": {\n \"name\": \"군산 스탬프투어\",\n \"url\": \"https://www.gunsanstamp.kr/\"\n }\n },\n {\n \"name\": \"아리랑길 코스\",\n \"duration\": \"반나절\",\n \"audience\": \"걷기 좋아하는 손님\",\n \"why\": \"개항기 건물만 골라 짧게 도는 길입니다.\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"구 군산세관 본관\",\n \"note\": \"1908년에 지어진 벽돌 건물입니다.\",\n \"searchQuery\": \"구 군산세관 본관\",\n \"moveMinutes\": 8\n },\n {\n \"name\": \"군산근대미술관\",\n \"note\": \"옛 은행 금고가 그대로 남아 있습니다.\",\n \"searchQuery\": \"군산근대미술관\",\n \"moveMinutes\": 5\n },\n {\n \"name\": \"해망굴\",\n \"note\": \"굴 하나에 두 시대가 겹칩니다.\",\n \"searchQuery\": \"군산 해망굴\",\n \"moveMinutes\": 14\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산 스탬프투어\",\n \"url\": \"https://www.gunsanstamp.kr/\"\n }\n },\n {\n \"name\": \"개항장 골목 한 바퀴\",\n \"duration\": \"반나절\",\n \"audience\": \"처음 온 손님\",\n \"why\": \"해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.\",\n \"startTime\": \"09:30\",\n \"stops\": [\n {\n \"name\": \"군산근대역사박물관\",\n \"minutes\": 90,\n \"moveMinutes\": 12,\n \"note\": \"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\n \"searchQuery\": \"군산근대역사박물관\"\n },\n {\n \"name\": \"초원사진관\",\n \"minutes\": 30,\n \"moveMinutes\": 10,\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\"\n },\n {\n \"name\": \"이성당\",\n \"minutes\": 40,\n \"moveMinutes\": 8,\n \"note\": \"단팥빵 하나로 오후를 엽니다.\",\n \"searchQuery\": \"군산 이성당\"\n },\n {\n \"name\": \"경암동 철길마을\",\n \"minutes\": 60,\n \"moveMinutes\": 18,\n \"note\": \"해 질 무렵 빛이 가장 좋습니다.\",\n \"searchQuery\": \"군산 경암동 철길마을\"\n }\n ],\n \"verified\": \"확인\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n },\n {\n \"name\": \"비 오면 여기로\",\n \"duration\": \"반나절\",\n \"audience\": \"장마철에 온 손님\",\n \"why\": \"실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.\",\n \"season\": \"여름\",\n \"startTime\": \"10:30\",\n \"stops\": [\n {\n \"name\": \"군산근대건축관\",\n \"minutes\": 60,\n \"moveMinutes\": 10,\n \"note\": \"옛 은행 건물 안이 통째로 전시입니다.\",\n \"searchQuery\": \"군산근대건축관\"\n },\n {\n \"name\": \"한일옥\",\n \"minutes\": 60,\n \"moveMinutes\": 8,\n \"note\": \"무국 한 그릇으로 몸을 데웁니다.\",\n \"searchQuery\": \"군산 한일옥\"\n },\n {\n \"name\": \"째보선창 근처 카페\",\n \"minutes\": 90,\n \"moveMinutes\": 10,\n \"note\": \"비 그칠 때까지 앉아 있기 좋습니다.\",\n \"searchQuery\": \"군산 째보선창 카페\"\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n },\n {\n \"name\": \"섬까지 다녀오는 1박 2일\",\n \"duration\": \"1박 2일\",\n \"audience\": \"차를 가져온 손님\",\n \"why\": \"첫날은 도심에서 저녁을, 이튿날은 다리 건너 바다를 봅니다.\",\n \"days\": [\n {\n \"label\": \"첫째 날\",\n \"startTime\": \"17:00\",\n \"stops\": [\n {\n \"name\": \"체크인 · 스테이 머뭄\",\n \"minutes\": 40,\n \"note\": \"짐을 풀고 골목을 한 바퀴 돕니다.\"\n },\n {\n \"name\": \"저녁 · 빈해원\",\n \"minutes\": 70,\n \"note\": \"옛 중식당 건물 자체가 문화재입니다.\",\n \"searchQuery\": \"군산 빈해원\"\n },\n {\n \"name\": \"밤 산책 · 군산 내항\",\n \"minutes\": 50,\n \"note\": \"조명이 들어온 부잔교를 봅니다.\",\n \"searchQuery\": \"군산 내항\"\n },\n {\n \"name\": \"머뭄으로 · 스테이 머뭄\",\n \"minutes\": 60,\n \"note\": \"마당 평상에 앉으면 항구 소리가 들립니다.\"\n }\n ]\n },\n {\n \"label\": \"둘째 날\",\n \"startTime\": \"10:00\",\n \"stops\": [\n {\n \"name\": \"고군산군도 선유도\",\n \"minutes\": 150,\n \"moveMinutes\": 45,\n \"note\": \"다리로 건너갑니다. 차로 이동.\",\n \"searchQuery\": \"군산 선유도\"\n },\n {\n \"name\": \"장자도\",\n \"minutes\": 60,\n \"moveMinutes\": 12,\n \"note\": \"섬과 섬 사이를 걸어 넘습니다.\",\n \"searchQuery\": \"군산 장자도\"\n }\n ]\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"대한민국 구석구석\",\n \"url\": \"https://korean.visitkorea.or.kr/\"\n }\n },\n {\n \"name\": \"천천히 도는 2박 3일\",\n \"duration\": \"2박 3일\",\n \"audience\": \"오래 머무는 손님\",\n \"why\": \"하루에 한 가지씩만 봅니다. 서두르지 않는 사람에게 맞습니다.\",\n \"days\": [\n {\n \"label\": \"첫째 날\",\n \"startTime\": \"15:00\",\n \"stops\": [\n {\n \"name\": \"저녁 · 빈해원\",\n \"minutes\": 70,\n \"note\": \"옛 중식당 건물 자체가 문화재입니다.\",\n \"searchQuery\": \"군산 빈해원\"\n },\n {\n \"name\": \"밤 산책 · 군산 내항\",\n \"minutes\": 50,\n \"note\": \"조명이 들어온 부잔교를 봅니다.\",\n \"searchQuery\": \"군산 내항\"\n },\n {\n \"name\": \"머뭄으로 · 스테이 머뭄\",\n \"minutes\": 60,\n \"note\": \"마당 평상에 앉으면 항구 소리가 들립니다.\"\n }\n ]\n },\n {\n \"label\": \"둘째 날\",\n \"startTime\": \"09:30\",\n \"stops\": [\n {\n \"name\": \"군산근대역사박물관\",\n \"minutes\": 90,\n \"moveMinutes\": 12,\n \"note\": \"항구 도시가 어떻게 만들어졌는지 먼저 봅니다.\",\n \"searchQuery\": \"군산근대역사박물관\"\n },\n {\n \"name\": \"초원사진관\",\n \"minutes\": 30,\n \"moveMinutes\": 10,\n \"note\": \"영화가 남기고 간 자리입니다.\",\n \"searchQuery\": \"군산 초원사진관\"\n },\n {\n \"name\": \"이성당\",\n \"minutes\": 40,\n \"moveMinutes\": 8,\n \"note\": \"단팥빵 하나로 오후를 엽니다.\",\n \"searchQuery\": \"군산 이성당\"\n },\n {\n \"name\": \"경암동 철길마을\",\n \"minutes\": 60,\n \"moveMinutes\": 18,\n \"note\": \"해 질 무렵 빛이 가장 좋습니다.\",\n \"searchQuery\": \"군산 경암동 철길마을\"\n }\n ]\n },\n {\n \"label\": \"셋째 날\",\n \"startTime\": \"11:00\",\n \"stops\": [\n {\n \"name\": \"채만식문학관\",\n \"minutes\": 80,\n \"moveMinutes\": 20,\n \"note\": \"금강 하구 옆입니다. 차로 이동.\",\n \"searchQuery\": \"군산 채만식문학관\"\n },\n {\n \"name\": \"째보선창\",\n \"minutes\": 40,\n \"moveMinutes\": 15,\n \"note\": \"소설 속 노동의 자리입니다.\",\n \"searchQuery\": \"군산 째보선창\"\n }\n ]\n }\n ],\n \"verified\": \"확인필요\",\n \"source\": {\n \"name\": \"군산시 문화관광\",\n \"url\": \"https://www.gunsan.go.kr/tour/\"\n }\n }\n ]\n}" + }, + { + "id": "songs", + "name": "가요 다방", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"songs\", \"version\": 1, \"items\": [{\"title\": \"금강 나그네\", \"artist\": \"이미자\", \"lyricist\": \"이병훈\", \"composer\": \"박춘석\", \"year\": 1966, \"label\": \"지구레코드\", \"labelColor\": \"#d4551f\", \"story\": \"군산항을 모티브로 한 곡. 금강 하구와 항구의 이별 정서를 담았습니다.\", \"connection\": \"머뭄에서 걸어 10분, 내항 부잔교가 이 노래의 배경입니다.\", \"verified\": \"확인\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}, {\"title\": \"님 떠난 군산항\", \"artist\": \"이미자\", \"labelColor\": \"#1d3c74\", \"story\": \"군산항의 이별 정서를 담은 곡. 발표 연도와 작사·작곡은 아직 확인 중입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}, {\"title\": \"헤어진 군산항\", \"artist\": \"박제연\", \"labelColor\": \"#2a6053\", \"story\": \"1960년대 군산을 노래한 곡으로 전해집니다. 항구도시의 정서를 담았습니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"세계일보\", \"url\": \"https://www.segye.com/newsView/20160329000709\"}}], \"title\": \"가요 다방\", \"subtitle\": \"이 도시를 노래한 곡들\"}" + }, + { + "id": "daily", + "name": "오늘의 한 장", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"daily\", \"version\": 1, \"items\": [{\"monthDay\": \"09-01\", \"category\": \"장소\", \"title\": \"군산 내항 부잔교\", \"body\": \"조수 차가 큰 항구에서 배를 대기 위해 물 위에 띄운 다리입니다. 미곡 반출의 통로이기도 했습니다.\", \"season\": \"가을\", \"tags\": [\"#군산내항\", \"#부잔교\"], \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"monthDay\": \"09-02\", \"category\": \"축제\", \"title\": \"군산시간여행축제\", \"body\": \"근대문화도시의 정체성을 축제로 풀어냅니다. 밤에 걷는 골목이 낮과 다릅니다.\", \"season\": \"가을\", \"tags\": [\"#시간여행축제\"], \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"monthDay\": \"09-03\", \"category\": \"음식\", \"title\": \"이성당\", \"body\": \"오래된 빵집 하나가 도시의 이력이 된 자리입니다. 단팥빵 앞의 줄이 그 증거입니다.\", \"season\": \"가을\", \"tags\": [\"#이성당\"], \"verified\": \"확인\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}, {\"monthDay\": \"09-04\", \"category\": \"문학\", \"title\": \"채만식문학관\", \"body\": \"금강 하구 옆에 선 문학관입니다. 『탁류』의 무대를 창밖으로 바로 볼 수 있습니다.\", \"season\": \"가을\", \"tags\": [\"#채만식\", \"#탁류\"], \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"monthDay\": \"09-05\", \"category\": \"바다\", \"title\": \"고군산군도\", \"body\": \"다리로 이어진 예순 개 남짓의 섬입니다. 차로 건너 선유도까지 갈 수 있습니다.\", \"season\": \"가을\", \"tags\": [\"#고군산군도\", \"#선유도\"], \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"오늘의 한 장\", \"subtitle\": \"하루에 하나씩 뜯어 보는 군산\"}" + }, + { + "id": "people", + "name": "인물 열전", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"people\", \"version\": 1, \"items\": [{\"name\": \"채만식\", \"aka\": \"백릉\", \"years\": \"1902–1950\", \"role\": \"소설가\", \"oneLine\": \"군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.\", \"imageQuery\": \"채만식문학관 채만식 초상\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"name\": \"고은\", \"years\": \"1933–\", \"role\": \"시인\", \"oneLine\": \"군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"name\": \"박화요비\", \"role\": \"가수\", \"oneLine\": \"군산 출신으로 소개되는 가수입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"name\": \"진희경\", \"role\": \"배우\", \"oneLine\": \"군산 출신으로 알려진 배우입니다.\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}], \"title\": \"인물 열전\", \"subtitle\": \"이 도시가 배출한 이름들\"}" + }, + { + "id": "chronicle", + "name": "시간의 골목", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"chronicle\", \"version\": 1, \"items\": [{\"year\": 1380, \"title\": \"진포대첩\", \"summary\": \"최무선의 화포가 왜구 함대를 무너뜨린 해. 군산 해양사의 출발점으로 꼽힙니다.\", \"place\": \"진포시비공원\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1899, \"title\": \"군산 개항\", \"summary\": \"항구가 열리며 도시의 성격이 한 번에 바뀝니다.\", \"place\": \"군산 내항\", \"turning\": true, \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1908, \"title\": \"구 군산세관 본관 준공\", \"summary\": \"지금도 서 있는 벽돌 건물입니다. 개항기 행정의 자리였습니다.\", \"place\": \"구 군산세관 본관\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1926, \"title\": \"내항 부잔교 축조\", \"summary\": \"조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로였습니다.\", \"place\": \"군산 내항 부잔교\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 1937, \"title\": \"『탁류』 연재 시작\", \"summary\": \"채만식이 이 도시를 소설의 무대로 세웁니다.\", \"place\": \"채만식문학관\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"year\": 1990, \"title\": \"금강하굿둑 준공\", \"summary\": \"강과 바다의 경계가 사람 손으로 그어집니다.\", \"place\": \"금강하굿둑\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"year\": 2010, \"title\": \"새만금 방조제 개통\", \"summary\": \"간척으로 해안선이 다시 그려집니다.\", \"place\": \"새만금 방조제\", \"turning\": true, \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"시간의 골목\", \"subtitle\": \"이 도시를 만든 해들\"}" + }, + { + "id": "postcard", + "name": "오늘의 엽서", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"postcard\", \"version\": 1, \"items\": [{\"line\": \"부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.\", \"hashtags\": [\"#군산\", \"#내항\", \"#부잔교\"], \"place\": \"군산 내항\", \"postmark\": \"군산 內港\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"line\": \"짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.\", \"hashtags\": [\"#군산짬뽕\", \"#항구도시\"], \"place\": \"군산 중앙로\", \"postmark\": \"군산 中央\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"line\": \"단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.\", \"hashtags\": [\"#이성당\", \"#오래된가게\"], \"place\": \"이성당\", \"postmark\": \"군산 中央\", \"verified\": \"확인\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}, {\"line\": \"섬과 섬 사이를 다리로 건너면, 바다가 길이 된다.\", \"hashtags\": [\"#고군산군도\", \"#선유도\"], \"place\": \"고군산군도\", \"postmark\": \"고군산\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"오늘의 엽서\", \"subtitle\": \"그대로 붙여 쓰는 한 문장\"}" + }, + { + "id": "quiz", + "name": "뒤집어 보는 질문", + "enabled": true, + "locked": false, + "data": "{\"kind\": \"quiz\", \"version\": 1, \"items\": [{\"question\": \"부잔교는 왜 물 위에 뜨도록 만들었을까?\", \"hint\": \"서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.\", \"topic\": \"군산 내항\", \"level\": \"초등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"『탁류』의 “탁류”는 무엇을 가리키는 말일까?\", \"hint\": \"금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.\", \"topic\": \"채만식문학관\", \"level\": \"중등\", \"verified\": \"확인\", \"source\": {\"name\": \"군산시 채만식문학관\", \"url\": \"https://www.gunsan.go.kr/chae/\"}}, {\"question\": \"해망굴은 무엇을 위해 뚫린 굴일까?\", \"hint\": \"근대 교통로였고, 전쟁기에는 다른 쓰임도 있었습니다. 굴 하나에 두 시대가 겹칩니다.\", \"topic\": \"해망굴\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"군산시 문화관광\", \"url\": \"https://www.gunsan.go.kr/tour/\"}}, {\"question\": \"고군산군도의 ‘고(古)’ 는 왜 붙었을까?\", \"hint\": \"지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.\", \"topic\": \"고군산군도\", \"level\": \"어른\", \"verified\": \"확인필요\", \"source\": {\"name\": \"대한민국 구석구석\", \"url\": \"https://korean.visitkorea.or.kr/\"}}], \"title\": \"뒤집어 보는 질문\", \"subtitle\": \"뒤집으면 힌트가 나옵니다\"}" + }, + { + "id": "faq", + "name": "자주 묻는 질문", + "enabled": true, + "locked": false + }, + { + "id": "event", + "name": "소식", + "enabled": true, + "locked": false + }, + { + "id": "festival", + "name": "계절별 축제", + "enabled": true, + "locked": false + }, + { + "id": "story", + "name": "지역 이야기", + "enabled": true, + "locked": false + }, + { + "id": "weather", + "name": "날씨", + "enabled": true, + "locked": false + } + ], + "look": { + "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)", + "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)" + } + } +} \ No newline at end of file diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index f5cb41b..44b5510 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -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); } + /** * ───────────────────────────────────────────────────────────────────────── * 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터. diff --git a/solution/site/src/lib/format.ts b/solution/site/src/lib/format.ts index 2e479d1..3710084 100644 --- a/solution/site/src/lib/format.ts +++ b/solution/site/src/lib/format.ts @@ -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`; } /** diff --git a/solution/site/src/lib/ui/Carousel.tsx b/solution/site/src/lib/ui/Carousel.tsx index 2edeb54..171e3fe 100644 --- a/solution/site/src/lib/ui/Carousel.tsx +++ b/solution/site/src/lib/ui/Carousel.tsx @@ -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]); diff --git a/solution/site/src/lib/ui/use-rail-autoplay.ts b/solution/site/src/lib/ui/use-rail-autoplay.ts new file mode 100644 index 0000000..f602abd --- /dev/null +++ b/solution/site/src/lib/ui/use-rail-autoplay.ts @@ -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; + /** 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; +} diff --git a/solution/site/src/lib/use-live-weather.ts b/solution/site/src/lib/use-live-weather.ts index 2898352..6f9deb5 100644 --- a/solution/site/src/lib/use-live-weather.ts +++ b/solution/site/src/lib/use-live-weather.ts @@ -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; diff --git a/solution/site/src/sections/FestivalSection.tsx b/solution/site/src/sections/FestivalSection.tsx index fbd54ac..7bf5ec3 100644 --- a/solution/site/src/sections/FestivalSection.tsx +++ b/solution/site/src/sections/FestivalSection.tsx @@ -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() {

{season}

- + ); })} @@ -107,7 +107,7 @@ export function FestivalSection() {

계절 없이 열리는 행사

- + )} @@ -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 ( - + ); } diff --git a/solution/site/src/sections/GallerySection.tsx b/solution/site/src/sections/GallerySection.tsx index f86f669..4f369f2 100644 --- a/solution/site/src/sections/GallerySection.tsx +++ b/solution/site/src/sections/GallerySection.tsx @@ -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() {