[refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계

도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-09 17:08:02 +09:00
parent 7bbeb068a8
commit 64ce467f21
189 changed files with 13374 additions and 5023 deletions

View File

@ -142,7 +142,9 @@ docker compose logs -f solution-worker
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
@ -260,7 +262,9 @@ docker compose logs -f solution-worker
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
스키마는 `postgres-init/init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/`
(이미 만들어진 DB 보정)다. **둘 다 고친다** — init.sql 만 고치면 서버 DB 에 반영되지 않고,
마이그레이션만 쓰면 새로 세운 DB 에 그 변경이 없다. 적용: `scripts/migrate.py`
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`npm run dev:frontend` / `dev:admin` / `dev:site`
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`

View File

@ -9,9 +9,13 @@ import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
type Status = 1 | 2 | 3;
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
type ContentType = 2 | 3 | 4;
type LocalContent = {
id: string;
contentType: ContentType;
title: string;
region: string;
period: string;
@ -24,10 +28,15 @@ type LocalContent = {
};
const STATUS_LABEL: Record<Status, string> = {1: '검수 대기', 2: '발행됨', 3: '종료됨'};
const TYPE_LABEL: Record<ContentType, string> = {2: '축제', 3: '관광지', 4: '맛집'};
const TYPE_BADGE_VARIANT: Record<ContentType, 'accent' | 'default' | 'outline'> = {
2: 'accent', 3: 'default', 4: 'outline',
};
type ApiContent = {
local_content_id: string;
region_code: string;
content_type: ContentType;
external_id?: string;
title?: string;
body: Record<string, unknown>;
@ -43,14 +52,17 @@ function formatDate(value: unknown) {
}
function toContent(item: ApiContent): LocalContent {
// 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다.
const start = formatDate(item.body.eventstartdate);
const end = formatDate(item.body.eventenddate);
const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시');
return {
id: item.local_content_id,
contentType: item.content_type,
title: item.title || '제목 없음',
region: String(item.body.addr1 || item.region_code),
period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정',
source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`,
period,
source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`,
status: item.status,
selected: false,
displayStart: item.display_start_at,
@ -95,15 +107,24 @@ export function LocalContentPage() {
} catch { toast.error('발행하지 못했습니다.'); }
};
const sync = async () => {
const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim();
if (!regionCode) return;
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
if (!placeId) return;
setSyncing(true);
try {
const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({
url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode},
});
const res = await customFetch<{
result?: {success?: boolean}; msg?: string;
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
if (res.result?.success === false) throw new Error(res.msg);
toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`);
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
if (!res.changed) {
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
} else {
toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`);
}
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.');
@ -138,7 +159,7 @@ export function LocalContentPage() {
return (
<PageContainer
title="지역 콘텐츠"
description="공공데이터에서 지역 축제를 가져와 검수한 뒤 사장님에게 발행합니다."
description="한국관광공사 TourAPI 에서 지역 축제·관광지·맛집을 가져와 자동 발행합니다. 필요하면 여기서 수정하거나 발행을 종료할 수 있습니다."
actions={<Button onClick={sync} disabled={syncing}><Download className="size-4" />{syncing ? '수집 중…' : '공공데이터 수집'}</Button>}
>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
@ -174,6 +195,7 @@ export function LocalContentPage() {
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">{item.title}</h2>
<Badge variant={TYPE_BADGE_VARIANT[item.contentType]}>{TYPE_LABEL[item.contentType]}</Badge>
<Badge variant={item.status === 2 ? 'success' : 'warning'}>{STATUS_LABEL[item.status]}</Badge>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">

View File

@ -49,6 +49,9 @@ services:
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
ports:
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:

View File

@ -87,7 +87,7 @@
| 레이어 구조 | 원본 그대로 — `router` → `service` → `crud`, 람다 DB 실행, `Req_*`/`Res_*` 프로토콜, `RemoveNoneResponse` | "기존 컨벤션을 그대로 따른다" |
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
| 마이그레이션 | Alembic 안 씀. `init-data/init.sql`(새 DB 전체 DDL) **+** `postgres-init/migrations/NNNN_*.sql`(기존 DB 보정), 적용기 `scripts/migrate.py` | 2026-08-31 에 누적 ALTER 를 없애며 "운영 DB 가 생기는 순간 다시 필요해진다" 고 적어 뒀다. **2026-09-09 그 순간이 왔다** — init.sql 은 DB 를 처음 만들 때만 도는데 서버·로컬에 이미 데이터가 있어서, `local.place_contents` 테이블과 `places.external_category` 컬럼이 실제 DB 에만 빠져 있었다. TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다. Alembic 을 안 쓰는 이유는 그대로다 — ORM·init.sql 두 곳에 스키마가 있고 `test_schema_ddl.py` 가 대조하는 구조라, 세 번째 정의를 더하면 어긋날 자리가 하나 더 생긴다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |

View File

@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS place.places (
latitude NUMERIC(10,7) NULL, -- 위도
longitude NUMERIC(10,7) NULL, -- 경도
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드 — ★ 지역정보 캐시 키
external_category VARCHAR(200) NULL, -- 외부 장소 DB 분류 원문("음식점 > 한식 > 육류" / "펜션") — 주변 맛집 경쟁업소 제외 기준(폴백)
verified_at TIMESTAMPTZ NULL, -- ★ 동일 업소 검증 통과 시각. NULL = 수집·발행 금지
verified_by uuid NULL, -- 검증자(company.users.user_id)
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용
@ -211,6 +212,73 @@ CREATE TABLE IF NOT EXISTS local.local_contents (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id — local_contents(행정구역 캐시)와 다르다.
-- 빌드 때마다 TourAPI locationBasedList2 로 갱신. 응답에서 사라진 행은 소프트 삭제, hidden 은 유지.
CREATE TABLE IF NOT EXISTS local.place_contents (
place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 정보 식별자(PK)
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 5=여행코스
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간)
distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 축제·관광지·맛집 **실체**. 전국 공용 — 같은 장소를 업장마다 복제하지 않는다.
-- ★ place_contents 는 키가 (place_id, …) 라 업장마다 TourAPI 응답을 통째로 복제했다.
-- 실측(2026-09-09): 업장 한 곳에 144행. 열 곳이면 같은 축제가 열 벌이다.
-- 실체는 여기 한 행, 업장별로 다른 것(거리·숨김)만 place_spots 에 남긴다.
CREATE TABLE IF NOT EXISTS local.spots (
spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 장소 식별자(PK)
source SMALLINT NOT NULL, -- 출처(LocalSource): 2=TourAPI
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류)
latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 — 거리 계산이 읽는다
longitude NUMERIC(10,7) NULL,
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회)
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업장 ↔ 주변 장소. 업장별로 다른 것은 거리와 숨김뿐이다.
CREATE TABLE IF NOT EXISTS local.place_spots (
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
spot_id uuid NOT NULL, -- 장소(local.spots.spot_id)
distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, spot_id)
);
-- 지역 이야기(가요·인물·연표·엽서·퀴즈). ★ 업장이 아니라 **지역**의 것이다 —
-- 군산 이야기는 군산 숙소가 같이 쓴다. 검수 전에는 발행에 나가지 않는다(status).
CREATE TABLE IF NOT EXISTS local.region_stories (
region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 이야기 식별자(PK)
region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드
kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz'
data JSONB NOT NULL,
source_type SMALLINT NOT NULL DEFAULT 4, -- 출처(SourceType): 4=llm 1=운영자
status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS local.routes (
route_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 경로 식별자(PK)
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
@ -265,6 +333,24 @@ CREATE TABLE IF NOT EXISTS site.sites (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
-- sites.theme 은 색·서체·섹션 순서/on-off 만 갖고, 내용은 여기로 나온다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 콘텐츠가 39,645 B(94%)였다.
-- 크기가 아니라 쓰기 단위가 문제였다 — 영상 주소 하나를 고쳐도 42 KB 를 다시 썼다.
CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 콘텐츠 식별자(PK)
site_id uuid NOT NULL, -- 사이트(site.sites.site_id)
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' …
data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[])
source_type SMALLINT NOT NULL DEFAULT 1, -- 출처(SourceType): 1=owner 2=api 4=llm
shared_ref uuid NULL, -- ★ 공유 원본(local.region_stories 등)을 가리킬 때. 값을 복제하지 않는다
status SMALLINT NOT NULL DEFAULT 1, -- 검증상태(FactStatus): 3·4 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS site.site_versions (
site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 버전 식별자(PK)
site_id uuid NOT NULL, -- 사이트(site.sites.site_id)
@ -379,7 +465,15 @@ CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status
-- local
CREATE INDEX IF NOT EXISTS idx_local_contents_region ON local.local_contents (region_code);
CREATE INDEX IF NOT EXISTS idx_routes_place ON local.routes (place_id);
CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed ON local.place_contents (place_id, content_type, external_id) WHERE deleted = false;
CREATE INDEX IF NOT EXISTS idx_nearby_links_place ON local.nearby_links (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external ON local.spots (source, external_id) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type);
CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind ON local.region_stories (region_code, kind) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_site_contents_site ON site.site_contents (site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents (site_id, section_id) WHERE deleted = FALSE;
-- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_keyed ON local.local_contents (region_code, content_type, external_id)

View File

@ -0,0 +1,30 @@
-- 0001 · 업장 반경 주변정보 테이블 + 외부 분류 컬럼
--
-- init.sql 에는 둘 다 있는데 그 파일은 DB 를 처음 만들 때만 돈다. 이미 만들어진 DB
-- (로컬·서버)에는 없어서, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없었다 —
-- 축제·맛집이 0건이었고 화면에는 "그냥 안 나오는 것"으로만 보였다(실측 2026-09-09).
CREATE TABLE IF NOT EXISTS local.place_contents (
place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집 5=여행코스
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간)
distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준
has_image BOOLEAN NOT NULL DEFAULT FALSE,
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed
ON local.place_contents (place_id, content_type, external_id) WHERE deleted = FALSE;
-- 주변 맛집에서 경쟁 업소를 빼는 기준(폴백). 이 컬럼이 없으면 places 조회 자체가
-- UndefinedColumnError 로 죽어 빌드가 "사업장을 찾을 수 없다"로 끝난다.
ALTER TABLE place.places ADD COLUMN IF NOT EXISTS external_category VARCHAR(200) NULL;

View File

@ -0,0 +1,76 @@
-- 0002 · 주변 정보를 **실체와 관계로** 가른다
--
-- 지금 `local.place_contents` 는 키가 (place_id, content_type, external_id) 라서
-- 업장마다 TourAPI 응답을 통째로 복제한다. 축제 그 자체(실체)와 "우리 업장에서 850m"
-- (관계)가 한 행에 섞여 있어서 그렇다.
-- 실측(2026-09-09, 조이모텔): 한 곳에 144행. 성남 중원구에 모텔이 10곳 들어오면
-- 같은 축제·같은 맛집이 10벌이 된다 — TourAPI 가 준 값은 글자 하나까지 같고 거리만 다르다.
--
-- 실체는 `spots` 에 external_id 로 한 행만 두고, 업장별로 다른 것(거리·숨김)만
-- `place_spots` 에 남긴다. 원본을 한 번 갱신하면 그 장소를 참조하는 사이트 전부에 반영된다.
-- 실체 — 축제·관광지·맛집. 전국 공용.
CREATE TABLE IF NOT EXISTS local.spots (
spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source SMALLINT NOT NULL, -- LocalSource: 2=TourAPI
external_id VARCHAR(100) NOT NULL, -- TourAPI contentid
content_type SMALLINT NOT NULL, -- LocalContentType: 2=축제 3=관광지 4=맛집
title VARCHAR(300) NOT NULL,
body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류)
latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 둔다 — 거리 계산이 이걸 읽는다
longitude NUMERIC(10,7) NULL,
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회용)
has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ★ 같은 장소를 두 번 담지 않는 유일성. 이 인덱스가 곧 "공유"의 근거다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external
ON local.spots (source, external_id) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type);
-- 관계 — 이 업장에서 얼마나 먼가. 업장별로 다른 건 이것뿐이다.
CREATE TABLE IF NOT EXISTS local.place_spots (
place_id uuid NOT NULL, -- place.places.place_id
spot_id uuid NOT NULL, -- local.spots.spot_id
distance_m INTEGER NOT NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 사장님·운영자가 뺀 것. 재수집이 덮어쓰지 않는다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, spot_id)
);
CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE;
-- 이미 place_contents 에 쌓인 것을 옮긴다. 같은 external_id 는 한 행으로 접힌다.
-- ★ ON CONFLICT 대신 NOT EXISTS 로 쓴다 — 부분 유니크 인덱스(WHERE deleted=FALSE)는
-- ON CONFLICT 의 추론 대상이 되지만, 조건까지 적어야 해서 읽기 어렵다.
INSERT INTO local.spots (source, external_id, content_type, title, body,
latitude, longitude, has_image, display_end_at, collected_at)
SELECT DISTINCT ON (pc.external_id)
2, pc.external_id, pc.content_type, pc.title, pc.body,
NULLIF(pc.body->>'mapy', '')::NUMERIC,
NULLIF(pc.body->>'mapx', '')::NUMERIC,
pc.has_image, pc.display_end_at, pc.collected_at
FROM local.place_contents pc
WHERE pc.deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM local.spots s
WHERE s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE
)
ORDER BY pc.external_id, pc.collected_at DESC;
INSERT INTO local.place_spots (place_id, spot_id, distance_m, hidden)
SELECT pc.place_id, s.spot_id, pc.distance_m, pc.hidden
FROM local.place_contents pc
JOIN local.spots s ON s.source = 2 AND s.external_id = pc.external_id AND s.deleted = FALSE
WHERE pc.deleted = FALSE
ON CONFLICT (place_id, spot_id) DO NOTHING;
-- ★ place_contents 는 지우지 않는다. 읽는 쪽이 새 테이블로 다 옮겨간 뒤에 별도 번호로 뗀다 —
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.

View File

@ -0,0 +1,75 @@
-- 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(디자인).
CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL, -- site.sites.site_id
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' …
data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[] 계약)
source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 3=crawl 4=llm
-- ★ 공유 콘텐츠는 값을 복제하지 않고 **id 로 가리킨다**(축제·지역 이야기).
-- 가리키는 동안 data 는 비어 있을 수 있다 — 발행할 때 원본을 펼쳐 payload 에 싣는다.
shared_ref uuid NULL,
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축: 3=verified 4=corrected 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 한 사이트의 한 섹션은 한 행이다. 순서·on/off 는 theme 이 갖는다.
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, -- 카카오 행정구역 코드
kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz'
data JSONB NOT NULL,
source_type SMALLINT NOT NULL DEFAULT 4, -- 4=llm 1=운영자
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 검수 전에는 안 나간다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
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 에 남으므로 잃지 않는다.
INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id,
sec->>'id',
(sec->>'data')::JSONB,
1, -- 사장님이 넣은 것으로 본다
3, -- 이미 발행에 쓰이던 값이라 verified
ordinality - 1
FROM site.sites s
CROSS JOIN LATERAL jsonb_array_elements(s.theme->'sections') WITH ORDINALITY AS t(sec, ordinality)
WHERE s.deleted = FALSE
AND s.theme ? 'sections'
AND sec->>'data' IS NOT NULL
AND sec->>'data' <> ''
AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL
ON CONFLICT DO NOTHING;
-- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을
-- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다.

View File

@ -0,0 +1,102 @@
-- 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` 에 남긴다. 관계까지 합치면 복제가 돌아온다.
-- ── 1. 공용 테이블에 부족했던 것 ──────────────────────────────────────────
-- 좌표: 거리 계산과 일정 조립이 읽는다. 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.
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" 로 통째로 실패한다.)
UPDATE local.local_contents lc
SET deleted = TRUE, updated_at = now()
WHERE lc.deleted = FALSE
AND lc.external_id IS NOT NULL
AND lc.local_content_id NOT IN (
SELECT DISTINCT ON (source, external_id) local_content_id
FROM local.local_contents
WHERE deleted = FALSE AND external_id IS NOT NULL
ORDER BY source, external_id, collected_at DESC, created_at DESC
);
-- 새 유일성: 출처가 준 id 하나면 한 행. 지역과 무관하다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_external
ON local.local_contents (source, external_id)
WHERE deleted = FALSE AND external_id IS NOT NULL;
-- 지역 이야기: 한 지역에 종류당 한 벌.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind
ON local.local_contents (region_code, kind)
WHERE deleted = FALSE AND kind IS NOT NULL;
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,
s.latitude, s.longitude, s.display_end_at, s.collected_at,
2 -- 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정)
FROM local.spots s
WHERE s.deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM local.local_contents lc
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;
CREATE TABLE IF NOT EXISTS local.place_contents (
place_id uuid NOT NULL, -- 사업장(place.places.place_id)
local_content_id uuid NOT NULL, -- 공용 콘텐츠(local.local_contents)
distance_m INTEGER NULL, -- 업장 좌표 기준 거리. 정렬·도보 시간의 원값
hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (place_id, local_content_id)
);
CREATE INDEX IF NOT EXISTS idx_place_contents_place
ON local.place_contents (place_id) WHERE deleted = FALSE;
-- place_spots(0002) 의 관계를 옮긴다. spot → 같은 external_id 의 공용 콘텐츠로 잇는다.
INSERT INTO local.place_contents (place_id, local_content_id, distance_m, hidden)
SELECT ps.place_id, lc.local_content_id, ps.distance_m, ps.hidden
FROM local.place_spots ps
JOIN local.spots s ON s.spot_id = ps.spot_id AND s.deleted = FALSE
JOIN local.local_contents lc
ON lc.source = s.source AND lc.external_id = s.external_id AND lc.deleted = FALSE
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 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 —
-- 한 번에 갈아엎으면 되돌릴 자리가 없다.

View File

@ -0,0 +1,85 @@
-- 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 뿐이라 잃는 행이 없다.
-- ── 사람 · 큐 (아무에게도 안 속한다) ──────────────────────────────────────
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;
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 에서 판단한다. 한 파일에 두 가지 일을 넣지 않는다.
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;
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_ 다.
ALTER TABLE IF EXISTS public.ai_check_results RENAME TO place_ai_checks;
-- ── 빈 스키마 정리 ──────────────────────────────────────────────────────
-- ★ CASCADE 를 쓰지 않는다. 옮기다 빠뜨린 테이블이 있으면 여기서 실패해야 한다 —
-- CASCADE 면 그 테이블이 조용히 함께 지워진다.
DROP SCHEMA IF EXISTS company RESTRICT;
DROP SCHEMA IF EXISTS place RESTRICT;
DROP SCHEMA IF EXISTS fact RESTRICT;
DROP SCHEMA IF EXISTS local RESTRICT;
DROP SCHEMA IF EXISTS site RESTRICT;
DROP SCHEMA IF EXISTS job RESTRICT;

View File

@ -0,0 +1,27 @@
-- 0006 · 한 번도 쓰지 않은 테이블과 이관 잔재를 뗀다
--
-- 실측(2026-09-09) — 네 테이블이 **행 0건**이다. 만들어 두고 기능이 붙지 않았다:
-- place_names(별칭) · place_routes(가는 길) · nearby_links(주변) · place_ai_checks(AI 노출 점검)
-- 화면에 나오지 않는 테이블을 유지비만 내며 들고 있을 이유가 없다 — 필요해지는 날
-- 그때의 요구에 맞춰 만드는 편이 낫다(지금 모양이 그때 맞을 거라는 보장도 없다).
--
-- ★ nearby_links 는 area_contents 와 역할이 겹친다. 둘 다 "업장 주변의 장소"인데
-- 하나는 카카오 로컬, 하나는 TourAPI 였다. 겹치는 자리를 남겨 두면 다음 사람이
-- "어느 쪽이 진짜냐"를 다시 묻는다 — 오늘 spots/local_contents 로 이미 겪었다.
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건)로 전부 옮겨졌다.
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 는
-- "언제 무슨 일이 있었나"(동작·결과·반려사유·실행자)다.

View File

@ -0,0 +1,34 @@
# 마이그레이션 — 이미 만들어진 DB 를 따라오게 하는 파일
`init-data/init.sql` 은 **새 DB 를 세우는 전체 DDL** 이고 계속 최신을 유지한다.
여기 파일들은 **이미 데이터가 든 DB** 를 그 최신으로 끌어올린다. 둘 다 필요하다.
## 왜 생겼나 (2026-09-09)
`DECISIONS.md` 는 누적 ALTER 를 없애면서 이렇게 적어 뒀다 —
*"아직 git·서버 어디에도 안 올라가 보정할 기존 DB 가 없다 … **운영 DB 가 생기는 순간
다시 필요해진다**"*. 그 순간이 왔다.
실제로 터졌다: 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category`
컬럼이 없었다. `init.sql` 에는 둘 다 있었지만 그 파일은 **DB 를 처음 만들 때만** 돈다.
TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고,
화면에는 "그냥 안 나오는 것"으로 보였다 — 원인을 짚는 데 한참 걸렸다.
## 규칙
- 파일명 `NNNN_한글_요약.sql` — 번호는 이어 붙인다. 지운 번호를 재사용하지 않는다.
- **재실행 안전하게 쓴다**(`IF NOT EXISTS` · `ADD COLUMN IF NOT EXISTS`).
적용 기록이 있어도 사람이 손으로 한 번 더 돌릴 수 있다.
- 한 파일 = 한 가지 변경. 여러 테이블을 건드려도 목적이 하나면 한 파일이다.
- **`init.sql` 도 같이 고친다.** 새 DB 는 그 파일만 읽는다 — 여기만 고치면
새로 세운 DB 에 그 변경이 없다(`tests/test_schema_ddl.py` 가 ORM 과의 어긋남은 잡지만,
init.sql 과 이 폴더의 어긋남은 아무도 안 잡는다).
## 적용
```bash
cd solution/backend && .venv/bin/python scripts/migrate.py # 안 돌린 것만
cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run # 목록만
```
적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다.

View File

@ -19,6 +19,10 @@
{ "key": "breakfast", "label": "조식 제공", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "baby_amenities", "label": "유아용품 비치", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "pickup_service", "label": "픽업 서비스", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "facilities", "label": "부대시설", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "total_rooms", "label": "객실 수", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "실" },
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" },
{ "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
@ -29,6 +33,15 @@
{ "key": "bathroom_count", "label": "욕실 수", "type": "number", "scope": "unit", "required": false, "critical": false, "allow_llm": false, "unit": "개" },
{ "key": "has_kitchen", "label": "주방 여부", "type": "bool", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "has_aircon", "label": "에어컨", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_bathroom", "label": "욕실", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_tv", "label": "TV", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_internet", "label": "인터넷", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_refrigerator", "label": "냉장고", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_hairdryer", "label": "드라이기", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_toiletries", "label": "세면도구", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_table", "label": "테이블", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_sofa", "label": "소파", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "has_home_theater", "label": "홈시어터", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "view", "label": "전망", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "weekday_price", "label": "주중 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "weekend_price", "label": "주말 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },

View File

@ -12,6 +12,7 @@ from common.enums import (
UserRole,
PlaceStatus,
FactStatus,
SourceType,
MediaStatus,
SiteStatus,
BuildStatus,
@ -46,7 +47,6 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
__table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
@ -78,12 +78,11 @@ class places(MainTableMixin, MAIN_BASE):
__tablename__ = "places"
__table_args__ = (
Index("idx_places_region_code", "region_code", postgresql_where=text("deleted = false")),
{"schema": "place"},
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users)
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
@ -99,6 +98,10 @@ class places(MainTableMixin, MAIN_BASE):
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다.
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
external_category = Column(String(200), nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지
verified_by = Column(UUID(as_uuid=True), nullable=True)
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
@ -106,25 +109,14 @@ class places(MainTableMixin, MAIN_BASE):
content_updated_at = Column(DateTime(timezone=True), nullable=True)
class place_aliases(MainTableMixin, MAIN_BASE):
"""상호 별칭. 동명 업소 구분과 재검색에 쓴다(옛 상호, '본관/별관' 표기 등)."""
__tablename__ = "place_aliases"
__table_args__ = {"schema": "place"}
alias_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alias = Column(String(200), nullable=False)
source_type = Column(SmallInteger, nullable=False) # SourceType
class place_links(MainTableMixin, MAIN_BASE):
class place_channels(MainTableMixin, MAIN_BASE):
"""Perplexity 가 발견한 채널 URL.
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
__tablename__ = "place_links"
__tablename__ = "place_channels"
__table_args__ = (
Index(
"uq_place_links_place_url",
@ -133,7 +125,6 @@ class place_links(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("deleted = false"),
),
{"schema": "place"},
)
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -148,12 +139,11 @@ class place_links(MainTableMixin, MAIN_BASE):
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
class units(MainTableMixin, MAIN_BASE):
class place_units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "units"
__table_args__ = {"schema": "place"}
__tablename__ = "place_units"
unit_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -161,15 +151,14 @@ class units(MainTableMixin, MAIN_BASE):
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class media(MainTableMixin, MAIN_BASE):
class place_photos(MainTableMixin, MAIN_BASE):
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다.
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
__tablename__ = "media"
__table_args__ = {"schema": "place"}
__tablename__ = "place_photos"
media_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -190,7 +179,7 @@ class media(MainTableMixin, MAIN_BASE):
# ============================================================
# fact : 사실 / FAQ
# ============================================================
class facts(MainTableMixin, MAIN_BASE):
class place_facts(MainTableMixin, MAIN_BASE):
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
- key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다.
@ -200,7 +189,7 @@ class facts(MainTableMixin, MAIN_BASE):
활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다."""
__tablename__ = "facts"
__tablename__ = "place_facts"
__table_args__ = (
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
@ -234,7 +223,6 @@ class facts(MainTableMixin, MAIN_BASE):
"status",
postgresql_where=text("deleted = false AND status IN (3, 4)"),
),
{"schema": "fact"},
)
fact_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -246,17 +234,16 @@ class facts(MainTableMixin, MAIN_BASE):
source_type = Column(SmallInteger, nullable=False) # SourceType — owner | api | crawl | llm
source_url = Column(String(1000), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
verified_by = Column(UUID(as_uuid=True), nullable=True) # company.users.user_id
verified_by = Column(UUID(as_uuid=True), nullable=True) # users.user_id
verified_at = Column(DateTime(timezone=True), nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
expires_at = Column(DateTime(timezone=True), nullable=True) # 지나면 EXPIRED 전이 대상
class faqs(MainTableMixin, MAIN_BASE):
class place_faqs(MainTableMixin, MAIN_BASE):
"""FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다."""
__tablename__ = "faqs"
__table_args__ = {"schema": "fact"}
__tablename__ = "place_faqs"
faq_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -271,13 +258,13 @@ class faqs(MainTableMixin, MAIN_BASE):
# ============================================================
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================
class local_contents(MainTableMixin, MAIN_BASE):
class area_contents(MainTableMixin, MAIN_BASE):
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "local_contents"
__tablename__ = "area_contents"
__table_args__ = (
# external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다.
Index(
@ -296,7 +283,6 @@ class local_contents(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("deleted = false AND external_id IS NULL"),
),
{"schema": "local"},
)
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -313,49 +299,32 @@ class local_contents(MainTableMixin, MAIN_BASE):
display_end_at = Column(DateTime(timezone=True), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
class routes(MainTableMixin, MAIN_BASE):
"""가는 길. 검증 상태(FactStatus)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다."""
__tablename__ = "routes"
__table_args__ = {"schema": "local"}
route_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
origin_name = Column(String(200), nullable=False) # 출발지 (예: "서울역")
transport = Column(SmallInteger, nullable=False) # TransportType
duration_min = Column(Integer, nullable=True)
distance_m = Column(Integer, nullable=True)
description = Column(Text, nullable=True)
source_type = Column(SmallInteger, nullable=False) # SourceType
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class nearby_links(MainTableMixin, MAIN_BASE):
"""주변 맛집·시설. 카카오 카테고리 검색 결과를 사업장에 붙인 것.
카테고리 검색은 좌표 변환보다 4배 비싸므로 region_code 단위 캐시(local_contents)에서 파생시킨다."""
__tablename__ = "nearby_links"
__table_args__ = {"schema": "local"}
nearby_link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
region_code = Column(String(10), nullable=True) # 어느 지역 캐시에서 왔는지
name = Column(String(200), nullable=False)
category_name = Column(String(100), nullable=True) # 카카오 category_name
kakao_place_id = Column(String(32), nullable=True)
distance_m = Column(Integer, nullable=True)
url = Column(String(1000), nullable=True)
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL.
kind = Column(String(50), nullable=True)
class place_area_refs(MainTableMixin, MAIN_BASE):
"""업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.**
★ 예전에는 이 테이블이 값을 통째로 들고 있었다(place_contents). 키가 place_id 라서
업장마다 TourAPI 응답이 복제됐다 — 실측(2026-09-09) 조이모텔 한 곳에 144행이고,
성남 중원구에 모텔이 열 곳 들어오면 같은 축제가 열 벌이 된다.
실체는 `area_contents` 에 한 행, 여기에는 관계만 남긴다.
★ hidden 은 재수집이 덮어쓰지 않는다 — 운영자가 뺀 것을 다음 갱신이 되살리면
숨긴 의미가 없다."""
__tablename__ = "place_area_refs"
place_id = Column(UUID(as_uuid=True), primary_key=True) # places.place_id
local_content_id = Column(UUID(as_uuid=True), primary_key=True) # area_contents.local_content_id
distance_m = Column(Integer, nullable=True) # 정렬·도보 시간의 원값
hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False)
# ============================================================
# site : 사이트 / 버전 / 발행 로그 / AI 노출 점검
# ============================================================
class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
@ -364,7 +333,6 @@ class sites(MainTableMixin, MAIN_BASE):
__table_args__ = (
Index("uq_sites_place", "place_id", unique=True, postgresql_where=text("deleted = false")),
Index("uq_sites_domain", "domain", unique=True, postgresql_where=text("deleted = false AND domain IS NOT NULL")),
{"schema": "site"},
)
site_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -383,13 +351,46 @@ class sites(MainTableMixin, MAIN_BASE):
# NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
theme = Column(JSONB, nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site.site_versions.site_version_id
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
published_at = Column(DateTime(timezone=True), nullable=True)
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
thumbnail_url = Column(String(500), nullable=True)
class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
★ 왜 theme 에서 꺼냈나 (2026-09-09)
색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다.
실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다.
크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도
42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다
"누가 넣었나 · 확인됐나"를 물을 자리가 없었다.
★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다.
★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 —
발행할 때 펼쳐 payload 에 싣는다."""
__tablename__ = "site_sections"
__table_args__ = (
Index(
"uq_site_contents_section",
"site_id", "section_id",
unique=True,
postgresql_where=text("deleted = false"),
),
)
site_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
section_id = Column(String(50), nullable=False) # 'songs' 'itinerary' 'video' 'people' …
data = Column(JSONB, nullable=False) # shared 의 XxxItem[] 계약
source_type = Column(SmallInteger, nullable=False, server_default=text("1"), default=SourceType.OWNER.value)
shared_ref = Column(UUID(as_uuid=True), nullable=True) # 공유 원본을 가리킬 때
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class site_versions(MainTableMixin, MAIN_BASE):
"""빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다.
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다.
@ -399,7 +400,6 @@ class site_versions(MainTableMixin, MAIN_BASE):
__tablename__ = "site_versions"
__table_args__ = (
Index("uq_site_versions_no", "site_id", "version", unique=True, postgresql_where=text("deleted = false")),
{"schema": "site"},
)
site_version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -413,11 +413,10 @@ class site_versions(MainTableMixin, MAIN_BASE):
built_at = Column(DateTime(timezone=True), nullable=True)
class publish_logs(MainTableMixin, MAIN_BASE):
class site_publish_logs(MainTableMixin, MAIN_BASE):
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
__tablename__ = "publish_logs"
__table_args__ = {"schema": "site"}
__tablename__ = "site_publish_logs"
publish_log_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
@ -429,27 +428,7 @@ class publish_logs(MainTableMixin, MAIN_BASE):
actor_user_id = Column(UUID(as_uuid=True), nullable=True)
class ai_check_results(MainTableMixin, MAIN_BASE):
"""AI 검색 노출 점검. 이 서비스의 목표 지표 —
AI 가 이 가게를 **우리 사이트를 근거로** 설명하는가, 아니면 여전히 OTA 를 인용하는가."""
__tablename__ = "ai_check_results"
__table_args__ = {"schema": "site"}
ai_check_result_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
engine = Column(SmallInteger, nullable=False) # AiEngine
query = Column(String(500), nullable=False) # 던진 질의
answer = Column(Text, nullable=True)
cited_urls = Column(JSONB, nullable=True) # 인용된 URL 목록
is_own_site_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # ★ 핵심 지표
ota_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # OTA 가 대신 인용됐는지
checked_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
# ============================================================
# job : 작업 큐 (PostgreSQL 을 큐로 — LPS 의 job 큐 구조 이식)
# ============================================================
class jobs(MainTableMixin, MAIN_BASE):
"""작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다.
@ -477,7 +456,6 @@ class jobs(MainTableMixin, MAIN_BASE):
unique=True,
postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"),
),
{"schema": "job"},
)
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라

View File

@ -291,10 +291,12 @@ VISION_AUTO_APPROVE_CONFIDENCE = 0.7
class LocalContentType(CodeEnum):
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
WEATHER = 1 # 날씨 (Open-Meteo)
FESTIVAL = 2 # 축제 (TourAPI 행사정보, 주 1회)
ATTRACTION = 3 # 관광지 (TourAPI 지역기반, 월 1회)
RESTAURANT = 4 # 주변 맛집 (카카오 카테고리 검색)
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25
FESTIVAL = 2 # 축제/공연/행사 (15)
ATTRACTION = 3 # 관광지 (12)
RESTAURANT = 4 # 음식점 (39)
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
class LocalSource(CodeEnum):

View File

@ -0,0 +1,24 @@
"""좌표 거리 — 공용 한 벌.
★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터)
세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의
거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다.
국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정
(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다.
"""
import math
EARTH_RADIUS_M = 6_371_000.0
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a))
def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
return haversine_m(lat1, lng1, lat2, lng2) / 1000.0

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts
from common.database.model.models import place_facts
from common.enums import ErrorType, FactStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -21,17 +21,17 @@ _ACTIVE = _PUBLISHED + _CANDIDATE
def _unit_cond(unit_id):
"""unit_id 는 NULL 비교라 == 로 걸면 안 된다(사업장 단위 fact 를 못 찾는다)."""
return facts.unit_id.is_(None) if unit_id is None else facts.unit_id == unit_id
return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id
# fact CRUD. 항상 place_id 로 스코프한다.
class IFactCRUD(ABC):
@abstractmethod
async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType:
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
pass
@abstractmethod
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]:
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
@ -39,11 +39,11 @@ class IFactCRUD(ABC):
pass
@abstractmethod
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]:
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]:
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
pass
@abstractmethod
@ -68,18 +68,18 @@ class IFactCRUD(ABC):
class FactCRUD(IFactCRUD):
async def add_fact(self, cdb: AsyncSession, fact: facts) -> ErrorType:
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, fact)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, facts]:
async def get_fact(self, cdb: AsyncSession, place_id, fact_id) -> Tuple[ErrorType, place_facts]:
try:
query = (
select(facts)
.where(facts.fact_id == fact_id, facts.place_id == place_id, facts.deleted == False) # noqa: E712
select(place_facts)
.where(place_facts.fact_id == fact_id, place_facts.place_id == place_id, place_facts.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -101,19 +101,19 @@ class FactCRUD(IFactCRUD):
active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보)
"""
try:
conditions = [facts.place_id == place_id, facts.deleted == False] # noqa: E712
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
if unit_id is not None:
conditions.append(facts.unit_id == unit_id)
conditions.append(place_facts.unit_id == unit_id)
if publishable_only:
conditions.append(facts.status.in_(_PUBLISHED))
conditions.append(place_facts.status.in_(_PUBLISHED))
elif status is not None:
conditions.append(facts.status == status)
conditions.append(place_facts.status == status)
elif active_only:
conditions.append(facts.status.in_(_ACTIVE))
conditions.append(place_facts.status.in_(_ACTIVE))
# 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다.
query = select(facts).where(and_(*conditions)).order_by(
facts.key.asc(), facts.status.desc(), facts.collected_at.desc()
query = select(place_facts).where(and_(*conditions)).order_by(
place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc()
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
@ -121,17 +121,17 @@ class FactCRUD(IFactCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, facts]:
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
"""★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None).
유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다."""
try:
query = (
select(facts)
select(place_facts)
.where(and_(
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHED),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHED),
_unit_cond(unit_id),
))
.limit(1)
@ -144,20 +144,20 @@ class FactCRUD(IFactCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, facts]:
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
"""같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다."""
try:
query = (
select(facts)
select(place_facts)
.where(and_(
facts.place_id == place_id,
facts.key == key,
facts.source_type == source_type,
facts.deleted == False, # noqa: E712
facts.status.in_(_CANDIDATE),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.source_type == source_type,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_CANDIDATE),
_unit_cond(unit_id),
))
.order_by(facts.collected_at.desc())
.order_by(place_facts.collected_at.desc())
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -176,7 +176,7 @@ class FactCRUD(IFactCRUD):
values = {"collected_at": ts, "updated_at": ts}
if source_url:
values["source_url"] = source_url
query = update(facts).where(facts.fact_id == fact_id, facts.deleted == False).values(**values) # noqa: E712
query = update(place_facts).where(place_facts.fact_id == fact_id, place_facts.deleted == False).values(**values) # noqa: E712
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
@ -186,8 +186,8 @@ class FactCRUD(IFactCRUD):
"""기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다."""
try:
query = (
update(facts)
.where(facts.fact_id == fact_id, facts.status.in_(_CANDIDATE), facts.deleted == False) # noqa: E712
update(place_facts)
.where(place_facts.fact_id == fact_id, place_facts.status.in_(_CANDIDATE), place_facts.deleted == False) # noqa: E712
.values(value=value, source_url=source_url, status=status, collected_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -202,11 +202,11 @@ class FactCRUD(IFactCRUD):
허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다."""
try:
query = (
update(facts)
update(place_facts)
.where(
facts.fact_id == fact_id,
facts.status.in_(tuple(from_statuses)),
facts.deleted == False, # noqa: E712
place_facts.fact_id == fact_id,
place_facts.status.in_(tuple(from_statuses)),
place_facts.deleted == False, # noqa: E712
)
.values(status=to_status, updated_at=GTime.UTC(), **data)
)
@ -221,15 +221,15 @@ class FactCRUD(IFactCRUD):
지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다."""
try:
conditions = [
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHED),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHED),
_unit_cond(unit_id),
]
if except_fact_id is not None:
conditions.append(facts.fact_id != except_fact_id)
query = update(facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts)
conditions.append(place_facts.fact_id != except_fact_id)
query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.EXPIRED.value, updated_at=ts)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
@ -241,15 +241,15 @@ class FactCRUD(IFactCRUD):
후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다."""
try:
conditions = [
facts.place_id == place_id,
facts.key == key,
facts.deleted == False, # noqa: E712
facts.status.in_(_CANDIDATE),
place_facts.place_id == place_id,
place_facts.key == key,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_CANDIDATE),
_unit_cond(unit_id),
]
if except_fact_id is not None:
conditions.append(facts.fact_id != except_fact_id)
query = update(facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts)
conditions.append(place_facts.fact_id != except_fact_id)
query = update(place_facts).where(and_(*conditions)).values(status=FactStatus.REJECTED.value, updated_at=ts)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs
from common.database.model.models import place_faqs
from common.enums import ErrorType, FactStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -21,7 +21,7 @@ class IFaqCRUD(ABC):
pass
@abstractmethod
async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType:
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
pass
@abstractmethod
@ -33,26 +33,26 @@ class IFaqCRUD(ABC):
pass
@abstractmethod
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]:
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
pass
class FaqCRUD(IFaqCRUD):
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool = False) -> Tuple[ErrorType, list]:
try:
conds = [faqs.place_id == place_id, faqs.deleted == False] # noqa: E712
conds.append(faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE))
query = select(faqs).where(and_(*conds)).order_by(faqs.sort_order.asc(), faqs.created_at.asc())
conds = [place_faqs.place_id == place_id, place_faqs.deleted == False] # noqa: E712
conds.append(place_faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE))
query = select(place_faqs).where(and_(*conds)).order_by(place_faqs.sort_order.asc(), place_faqs.created_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, faqs]:
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
try:
query = select(faqs).where(
faqs.faq_id == faq_id, faqs.place_id == place_id, faqs.deleted == False # noqa: E712
query = select(place_faqs).where(
place_faqs.faq_id == faq_id, place_faqs.place_id == place_id, place_faqs.deleted == False # noqa: E712
).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
@ -64,7 +64,7 @@ class FaqCRUD(IFaqCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def add_faq(self, cdb: AsyncSession, faq: faqs) -> ErrorType:
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, faq)
except Exception as ex:
@ -78,11 +78,11 @@ class FaqCRUD(IFaqCRUD):
재생성이 사람의 판단을 덮어쓰면 fact 쪽 규칙과 어긋난다."""
try:
query = (
update(faqs)
update(place_faqs)
.where(
faqs.place_id == place_id,
faqs.deleted == False, # noqa: E712
faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)),
place_faqs.place_id == place_id,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)),
)
.values(status=FactStatus.EXPIRED.value, updated_at=ts)
)
@ -94,8 +94,8 @@ class FaqCRUD(IFaqCRUD):
async def transition(self, cdb: AsyncSession, faq_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
try:
query = (
update(faqs)
.where(faqs.faq_id == faq_id, faqs.status.in_(tuple(from_statuses)), faqs.deleted == False) # noqa: E712
update(place_faqs)
.where(place_faqs.faq_id == faq_id, place_faqs.status.in_(tuple(from_statuses)), place_faqs.deleted == False) # noqa: E712
.values(status=to_status, updated_at=GTime.UTC(), **data)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)

View File

@ -2,73 +2,97 @@ from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import local_contents
from common.database.model.models import area_contents
from common.enums import ErrorType, LocalContentType
from common.utils.gtime import GTime
class LocalContentCRUD:
async def list(self, db, status: int | None = None, region_code: str | None = None):
conds = [local_contents.deleted == False, local_contents.content_type == LocalContentType.FESTIVAL.value] # noqa: E712
"""축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은
관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨)."""
conds = [area_contents.deleted == False] # noqa: E712
if status is not None:
conds.append(local_contents.status == status)
conds.append(area_contents.status == status)
if region_code:
conds.append(local_contents.region_code == region_code)
conds.append(area_contents.region_code == region_code)
return await DB_SESSION_MNG.execute(
db, select(local_contents).where(and_(*conds)).order_by(local_contents.collected_at.desc())
db, select(area_contents).where(and_(*conds)).order_by(area_contents.collected_at.desc())
)
async def insert(self, db, row):
return await DB_SESSION_MNG.insert(db, row)
async def get_by_external_id(self, db, region_code: str, external_id: str):
err, rows = await DB_SESSION_MNG.execute(
db,
select(local_contents).where(
local_contents.region_code == region_code,
local_contents.content_type == LocalContentType.FESTIVAL.value,
local_contents.external_id == external_id,
local_contents.deleted == False, # noqa: E712
).limit(1),
)
return err, rows[0] if rows else None
async def publish(self, db, ids: list, user_id):
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(local_contents).where(
local_contents.local_content_id.in_(ids), local_contents.deleted == False # noqa: E712
update(area_contents).where(
area_contents.local_content_id.in_(ids), area_contents.deleted == False # noqa: E712
).values(status=2, published_at=GTime.UTC(), published_by=user_id, updated_at=GTime.UTC()),
)
async def update(self, db, content_id, data: dict):
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(local_contents).where(
local_contents.local_content_id == content_id, local_contents.deleted == False # noqa: E712
update(area_contents).where(
area_contents.local_content_id == content_id, area_contents.deleted == False # noqa: E712
).values(**data, updated_at=GTime.UTC()),
)
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 get_weather(self, db, region_code: str):
err, rows = await DB_SESSION_MNG.execute(
db,
select(local_contents).where(
local_contents.region_code == region_code,
local_contents.content_type == LocalContentType.WEATHER.value,
local_contents.external_id.is_(None),
local_contents.deleted == False, # noqa: E712
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.content_type == LocalContentType.WEATHER.value,
area_contents.external_id.is_(None),
area_contents.deleted == False, # noqa: E712
).limit(1),
)
return err, rows[0] if rows else None
async def upsert_weather(self, db, values: dict):
stmt = pg_insert(local_contents).values(**values)
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[local_contents.region_code, local_contents.content_type],
index_where=and_(local_contents.deleted == False, local_contents.external_id.is_(None)), # noqa: E712
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
set_={
"source": stmt.excluded.source,
"body": stmt.excluded.body,

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media
from common.database.model.models import place_photos
from common.enums import ErrorType, MediaStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -45,20 +45,20 @@ class MediaCRUD(IMediaCRUD):
★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지
않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다."""
try:
conditions = [media.place_id == place_id, media.deleted == False] # noqa: E712
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
if status is not None:
conditions.append(media.status == status)
conditions.append(place_photos.status == status)
if unlabeled_only:
conditions.append(
or_(media.alt_text.is_(None), func.btrim(media.alt_text) == "")
or_(place_photos.alt_text.is_(None), func.btrim(place_photos.alt_text) == "")
)
if unit_id is not None:
conditions.append(media.unit_id == unit_id)
conditions.append(place_photos.unit_id == unit_id)
if alt_required:
# 공백만 있는 alt 도 빌더에선 '없음'이다 — 같은 기준으로 거른다.
conditions.append(media.alt_text.is_not(None))
conditions.append(func.btrim(media.alt_text) != "")
query = select(media).where(and_(*conditions)).order_by(media.sort_order.asc(), media.created_at.asc())
conditions.append(place_photos.alt_text.is_not(None))
conditions.append(func.btrim(place_photos.alt_text) != "")
query = select(place_photos).where(and_(*conditions)).order_by(place_photos.sort_order.asc(), place_photos.created_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
@ -72,8 +72,8 @@ class MediaCRUD(IMediaCRUD):
라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다."""
try:
query = (
update(media)
.where(media.media_id == media_id, media.deleted == False) # noqa: E712
update(place_photos)
.where(place_photos.media_id == media_id, place_photos.deleted == False) # noqa: E712
.values(label=label, alt_text=alt_text, vision_confidence=confidence, status=status, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -85,8 +85,8 @@ class MediaCRUD(IMediaCRUD):
"""사람이 사진을 승인/반려한다."""
try:
query = (
update(media)
.where(media.media_id == media_id, media.place_id == place_id, media.deleted == False) # noqa: E712
update(place_photos)
.where(place_photos.media_id == media_id, place_photos.place_id == place_id, place_photos.deleted == False) # noqa: E712
.values(status=status, updated_at=ts)
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)

View File

@ -0,0 +1,133 @@
from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, place_area_refs
from common.utils.gtime import GTime
class PlaceContentCRUD:
"""업장 주변의 지역 콘텐츠.
★ 실체와 관계가 갈려 있다 (2026-09-09).
예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI
응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다.
지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고
`place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다.
그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다.
"""
async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다."""
conds = [
place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712
area_contents.deleted == False, # noqa: E712
]
if not include_hidden:
conds.append(place_area_refs.hidden == False) # noqa: E712
return await DB_SESSION_MNG.execute(
db,
select(
area_contents.local_content_id,
area_contents.content_type,
area_contents.external_id,
area_contents.title,
area_contents.body,
area_contents.latitude,
area_contents.longitude,
area_contents.display_end_at,
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
place_area_refs.distance_m.label("distance_m"),
place_area_refs.hidden.label("hidden"),
)
.join(area_contents, area_contents.local_content_id == place_area_refs.local_content_id)
.where(and_(*conds))
.order_by(
area_contents.content_type.asc(),
place_area_refs.distance_m.asc(),
),
)
async def upsert_content(self, db, values: dict):
"""공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다.
★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고
("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다.
"""
stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.source, area_contents.external_id],
index_where=and_(
area_contents.deleted == False, # noqa: E712
area_contents.external_id.isnot(None),
),
set_={
"title": stmt.excluded.title,
"body": stmt.excluded.body,
"latitude": stmt.excluded.latitude,
"longitude": stmt.excluded.longitude,
"region_code": stmt.excluded.region_code,
"display_end_at": stmt.excluded.display_end_at,
"collected_at": stmt.excluded.collected_at,
"updated_at": GTime.UTC(),
},
)
return await DB_SESSION_MNG.add(db, stmt)
async def find_content_id(self, db, source: int, external_id: str):
"""방금 upsert 한 공용 콘텐츠의 id."""
return await DB_SESSION_MNG.execute(
db,
select(area_contents.local_content_id).where(
area_contents.source == source,
area_contents.external_id == external_id,
area_contents.deleted == False, # noqa: E712
).limit(1),
)
async def upsert_ref(self, db, place_id, local_content_id, distance_m):
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다."""
stmt = pg_insert(place_area_refs).values(
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
)
stmt = stmt.on_conflict_do_update(
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
set_={"distance_m": stmt.excluded.distance_m, "deleted": False, "updated_at": GTime.UTC()},
)
return await DB_SESSION_MNG.add(db, stmt)
async def soft_delete_missing(self, db, place_id, keep_ids: set):
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 —
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
err, rows = await DB_SESSION_MNG.execute(
db,
select(place_area_refs.local_content_id).where(
place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712
),
)
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
gone = [r for r in (rows or []) if r not in keep_ids]
if not gone:
return err, 0
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(place_area_refs)
.where(place_area_refs.place_id == place_id, place_area_refs.local_content_id.in_(gone))
.values(deleted=True, updated_at=GTime.UTC()),
)
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다."""
return await DB_SESSION_MNG.add_with_rowcount(
db,
update(place_area_refs)
.where(
place_area_refs.place_id == place_id,
place_area_refs.local_content_id == local_content_id,
place_area_refs.deleted == False, # noqa: E712
)
.values(hidden=hidden, updated_at=GTime.UTC()),
)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, units
from common.database.model.models import place_channels, places, place_units
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -38,11 +38,11 @@ class IPlaceCRUD(ABC):
pass
@abstractmethod
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]:
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]:
pass
@abstractmethod
async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType:
async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType:
pass
@abstractmethod
@ -50,7 +50,7 @@ class IPlaceCRUD(ABC):
pass
@abstractmethod
async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType:
async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType:
pass
@abstractmethod
@ -156,9 +156,9 @@ class PlaceCRUD(IPlaceCRUD):
async def list_units(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, list]:
try:
query = (
select(units)
.where(units.place_id == place_id, units.deleted == False) # noqa: E712
.order_by(units.sort_order.asc(), units.created_at.asc())
select(place_units)
.where(place_units.place_id == place_id, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc(), place_units.created_at.asc())
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
@ -166,11 +166,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, units]:
async def get_unit(self, cdb: AsyncSession, place_id, unit_id) -> Tuple[ErrorType, place_units]:
try:
query = (
select(units)
.where(units.unit_id == unit_id, units.place_id == place_id, units.deleted == False) # noqa: E712
select(place_units)
.where(place_units.unit_id == unit_id, place_units.place_id == place_id, place_units.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -183,7 +183,7 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def add_unit(self, cdb: AsyncSession, unit: units) -> ErrorType:
async def add_unit(self, cdb: AsyncSession, unit: place_units) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, unit)
except Exception as ex:
@ -193,17 +193,17 @@ class PlaceCRUD(IPlaceCRUD):
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
"""채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만."""
try:
conditions = [place_links.place_id == place_id, place_links.deleted == False] # noqa: E712
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
if confirmed_only:
conditions.append(place_links.confirmed_at.is_not(None))
query = select(place_links).where(and_(*conditions)).order_by(place_links.discovered_at.asc())
conditions.append(place_channels.confirmed_at.is_not(None))
query = select(place_channels).where(and_(*conditions)).order_by(place_channels.discovered_at.asc())
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def add_link(self, cdb: AsyncSession, link: place_links) -> ErrorType:
async def add_link(self, cdb: AsyncSession, link: place_channels) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, link)
except Exception as ex:
@ -218,12 +218,12 @@ class PlaceCRUD(IPlaceCRUD):
경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.place_id == place_id,
place_links.url == url,
place_links.confirmed_at.is_(None),
place_links.deleted == False, # noqa: E712
place_channels.place_id == place_id,
place_channels.url == url,
place_channels.confirmed_at.is_(None),
place_channels.deleted == False, # noqa: E712
)
.values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts)
)
@ -246,11 +246,11 @@ class PlaceCRUD(IPlaceCRUD):
생성 시점에만 근거로 넘긴다(services/copy_service.py)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.place_id == place_id,
place_links.url == url,
place_links.deleted == False, # noqa: E712
place_channels.place_id == place_id,
place_channels.url == url,
place_channels.deleted == False, # noqa: E712
)
.values(raw=raw, updated_at=GTime.UTC())
)
@ -263,12 +263,12 @@ class PlaceCRUD(IPlaceCRUD):
"""미확정 링크만 확정한다(이미 확정된 건 rowcount 0 — 동시 처리 가드)."""
try:
query = (
update(place_links)
update(place_channels)
.where(
place_links.link_id == link_id,
place_links.place_id == place_id,
place_links.confirmed_at.is_(None),
place_links.deleted == False, # noqa: E712
place_channels.link_id == link_id,
place_channels.place_id == place_id,
place_channels.confirmed_at.is_(None),
place_channels.deleted == False, # noqa: E712
)
.values(confirmed_at=ts, confirmed_by=user_id, updated_at=ts)
)

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, publish_logs, site_versions, sites
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import BuildStatus, ErrorType, SiteStatus
from common.logger import LOG
from common.utils.gtime import GTime
@ -54,7 +54,7 @@ class ISiteCRUD(ABC):
pass
@abstractmethod
async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType:
async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType:
pass
@abstractmethod
@ -214,7 +214,7 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def add_log(self, cdb: AsyncSession, log: publish_logs) -> ErrorType:
async def add_log(self, cdb: AsyncSession, log: site_publish_logs) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, log)
except Exception as ex:
@ -224,9 +224,9 @@ class SiteCRUD(ISiteCRUD):
async def list_logs(self, cdb: AsyncSession, site_id, limit: int = 50) -> Tuple[ErrorType, list]:
try:
query = (
select(publish_logs)
.where(publish_logs.site_id == site_id, publish_logs.deleted == False) # noqa: E712
.order_by(publish_logs.created_at.desc())
select(site_publish_logs)
.where(site_publish_logs.site_id == site_id, site_publish_logs.deleted == False) # noqa: E712
.order_by(site_publish_logs.created_at.desc())
.limit(limit)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)

View File

@ -1,10 +1,15 @@
import uuid
from fastapi import APIRouter, Depends, Query
from common.enums import LocalContentStatus
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import RequireOwner, RemoveNoneResponse
from services.local_content_service import LocalContentService
from .protocol import ReqPublishLocalContent, ReqSyncFestivals, ReqUpdateLocalContent, ResLocalContentList, ResSyncFestivals, ResWeather
from .protocol import (
ReqHidePlaceContent, ReqPublishLocalContent, ReqUpdateLocalContent,
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather,
)
router = APIRouter(prefix="/v1/admin/local-content", tags=["LocalContent"])
weather_router = APIRouter(prefix="/v1/local", tags=["LocalContent"])
@ -20,6 +25,35 @@ async def get_weather(
return RemoveNoneResponse(await service.get_weather(region_code, latitude, longitude))
@weather_router.get("/guide", response_model=ResLocalGuide, summary="업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용")
async def get_guide(
place_id: uuid.UUID = Query(),
service: LocalContentService = Depends(),
):
"""날씨와 같은 공개 조회다 — 운영자가 숨기지 않은 공공데이터만 나가므로 인증을 요구하지 않는다."""
return RemoveNoneResponse(await service.get_guide(place_id))
@router.get("/place/{place_id}", response_model=ResPlaceContentList, summary="업장 주변정보 목록 (숨김 포함)")
async def list_place_contents(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.list_place_contents(place_id))
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다."""
return RemoveNoneResponse(await service.sync_place_by_id(place_id))
@router.post("/place-content/{place_content_id}/hide", response_model=ResPlaceContentList, summary="주변정보 숨김/해제")
async def hide_place_content(
place_content_id: uuid.UUID, req: ReqHidePlaceContent,
service: LocalContentService = Depends(), _user=Depends(RequireOwner),
):
"""숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다."""
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))
@router.get("", response_model=ResLocalContentList)
async def list_contents(
status: LocalContentStatus | None = Query(None), region_code: str | None = Query(None),
@ -28,11 +62,6 @@ async def list_contents(
return RemoveNoneResponse(await service.list(status.value if status else None, region_code))
@router.post("/sync-festivals", response_model=ResSyncFestivals)
async def sync_festivals(req: ReqSyncFestivals, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.sync_festivals(req))
@router.post("/publish", response_model=ResLocalContentList)
async def publish(req: ReqPublishLocalContent, service: LocalContentService = Depends(), user=Depends(RequireOwner)):
return RemoveNoneResponse(await service.publish(req.content_ids, user.user_id))

View File

@ -25,10 +25,29 @@ class LocalContentData(WebPacketProtocol):
display_end_at: datetime | None = None
class ReqSyncFestivals(WebPacketProtocol):
region_code: str = Field(min_length=1, max_length=10)
area_code: str | None = None
start_date: str | None = Field(default=None, pattern=r"^\d{8}$")
class PlaceContentData(WebPacketProtocol):
"""업장 반경 주변정보 1건(admin 목록용)."""
model_config = ConfigDict(from_attributes=True)
place_content_id: uuid.UUID
place_id: uuid.UUID
content_type: LocalContentType
external_id: str
title: str
body: dict[str, Any]
distance_m: int
has_image: bool
hidden: bool
display_end_at: datetime | None = None
collected_at: datetime | None = None
class ResPlaceContentList(Res_WebPacketProtocol):
contents: list[PlaceContentData] = []
class ReqHidePlaceContent(WebPacketProtocol):
hidden: bool
class ReqPublishLocalContent(WebPacketProtocol):
@ -46,9 +65,25 @@ class ResLocalContentList(Res_WebPacketProtocol):
contents: list[LocalContentData] = []
class ResSyncFestivals(Res_WebPacketProtocol):
collected: int = 0
skipped: int = 0
class ResSyncPlace(Res_WebPacketProtocol):
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지."""
festivals: int = 0
attractions: int = 0
restaurants: int = 0
courses: int = 0
changed: bool = False
class ResLocalGuide(Res_WebPacketProtocol):
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
attractions: list[dict[str, Any]] = []
restaurants: list[dict[str, Any]] = []
festivals: list[dict[str, Any]] = []
courses: list[dict[str, Any]] = []
synced_at: str | None = None
class WeatherData(WebPacketProtocol):

View File

@ -40,6 +40,8 @@ class Req_VerifyPlace(PlaceProtocol):
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
# 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다.
category_name: Optional[str] = None
class Req_VerifyPlaceByUrl(PlaceProtocol):

View File

@ -0,0 +1,111 @@
"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다.
cd solution/backend && .venv/bin/python scripts/migrate.py
cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run
★ 왜 필요한가 (2026-09-09)
`init-data/init.sql` 은 **DB 를 처음 만들 때만** 돈다(postgres 이미지의 초기화 훅).
그래서 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다.
실제로 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category`
컬럼이 없었고, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었다.
화면에는 "그냥 안 나오는 것"으로만 보여서 원인을 짚는 데 한참 걸렸다.
DECISIONS.md 가 예고한 그대로다 — "운영 DB 가 생기는 순간 다시 필요해진다".
★ Alembic 을 쓰지 않는다. 이 레포는 ORM 과 init.sql 두 곳에 스키마를 두고
`tests/test_schema_ddl.py` 로 대조하는 구조다. 거기에 세 번째 정의(Alembic 리비전)를
더하면 어긋날 자리가 하나 더 생긴다. 필요한 건 "안 돌린 SQL 을 순서대로 돌린다" 뿐이다.
★ 적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다.
파일은 재실행 안전하게 쓰므로(IF NOT EXISTS), 기록이 날아가도 다시 돌리면 그만이다.
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("APP_ENV", "local")
from sqlalchemy import text # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.enums import DBWRType # noqa: E402
from common.database.model.models import places # noqa: E402
# parents[3] = 레포 루트 (scripts → backend → solution → 루트).
# ★ test_schema_ddl.py 와 같은 계산이다 — 폴더를 옮기면 둘 다 고친다.
MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "postgres-init" / "migrations"
_LEDGER_DDL = """
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
def pending(applied: set[str]) -> list[Path]:
"""아직 안 돌린 파일. 파일명 순서가 곧 적용 순서다."""
files = sorted(p for p in MIGRATIONS_DIR.glob("*.sql"))
return [p for p in files if p.stem not in applied]
async def main(dry_run: bool) -> int:
if not MIGRATIONS_DIR.is_dir():
print(f"마이그레이션 폴더가 없습니다: {MIGRATIONS_DIR}")
return 1
db = await DB_SESSION_MNG.start_session(places.DBType(), DBWRType.DB_WRITE.value)
try:
await db.execute(text(_LEDGER_DDL))
await db.commit()
rows = await db.execute(text("SELECT version FROM public.schema_migrations"))
applied = {r[0] for r in rows}
todo = pending(applied)
if not todo:
print(f"적용할 마이그레이션이 없습니다 (적용됨 {len(applied)}건)")
return 0
print(f"적용 대상 {len(todo)}건:")
for path in todo:
print(f" {path.stem}")
if dry_run:
print("\n--dry-run — 아무것도 적용하지 않았습니다.")
return 0
for path in todo:
sql = path.read_text(encoding="utf-8")
print(f"\n▶ {path.stem}")
try:
# ★ 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다.
# 반쯤 적용된 파일이 기록에 남으면 다음 실행이 그것을 건너뛴다.
# ★ asyncpg 드라이버 커넥션으로 직접 보낸다. SQLAlchemy 의 text() 는 prepared
# statement 가 되는데, asyncpg 는 거기에 문장을 여러 개 못 넣는다
# ("cannot insert multiple commands into a prepared statement").
# 마이그레이션 파일은 본래 여러 문장이라 이 경로가 맞다.
raw = await (await db.connection()).get_raw_connection()
await raw.driver_connection.execute(sql)
await db.execute(
text("INSERT INTO public.schema_migrations (version) VALUES (:v)"),
{"v": path.stem},
)
await db.commit()
print(" 적용됨")
except Exception as ex:
await db.rollback()
print(f" 실패 — {ex}")
print(" ★ 여기서 멈춥니다. 뒤 파일은 돌리지 않습니다(순서가 뜻을 갖는다).")
return 1
print(f"\n완료 — {len(todo)}건 적용")
return 0
finally:
await DB_SESSION_MNG.end_session(places.DBType(), DBWRType.DB_WRITE.value)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="적용하지 않고 대상만 출력")
args = parser.parse_args()
raise SystemExit(asyncio.run(main(args.dry_run)))

View File

@ -14,7 +14,7 @@ import uuid
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, publish_logs, site_versions, sites
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
@ -30,6 +30,7 @@ from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
@ -69,13 +70,13 @@ async def _load_links(place_id: str) -> list:
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다.
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s,
select(place_links).where(
place_links.place_id == uuid.UUID(place_id),
place_links.deleted == False, # noqa: E712
select(place_channels).where(
place_channels.place_id == uuid.UUID(place_id),
place_channels.deleted == False, # noqa: E712
),
),
)
@ -84,7 +85,7 @@ async def _load_links(place_id: str) -> list:
async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None):
"""발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다."""
row = publish_logs(
row = site_publish_logs(
site_id=site_id,
site_version_id=version_id,
action=action.value,
@ -93,7 +94,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
detail=(gate.as_log() if gate is not None and not gate.passed else None),
actor_user_id=uuid.UUID(actor) if actor else None,
)
await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
async def run_build(job: dict) -> dict:
@ -114,6 +115,17 @@ async def run_build(job: dict) -> dict:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
site = await ensure_site(place_id)
# ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라
# 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고,
# place_contents 는 직전 값을 그대로 갖고 있다.
try:
synced = await LocalContentService().sync_place(place)
if not synced.result.success:
LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}")
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
snapshot = await build_snapshot(place)
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
@ -212,7 +224,7 @@ async def run_build(job: dict) -> dict:
# ---- 2차 게이트: 렌더 산출물 기준 ----
# ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches

View File

@ -5,8 +5,8 @@
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts as facts_model
from common.database.model.models import media, place_links, places, units
from common.database.model.models import place_facts as facts_model
from common.database.model.models import place_photos, place_channels, places, place_units
from common.enums import DBWRType, ErrorType, LinkChannel, MediaStatus, PlaceCategory, PlaceStatus, SourceType
from common.logger import LOG
from common.utils.gtime import GTime
@ -356,7 +356,7 @@ async def store_facts(user_info, place_id: str, sources: list, unit_map: dict) -
stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}}
for source in sources:
for cf in source.facts:
for cf in source.place_facts:
unit_id = unit_map.get(cf.unit_name) if cf.scope == "unit" else None
if cf.scope == "unit" and unit_id is None:
stat["rejected"] += 1

View File

@ -284,9 +284,11 @@ class TourApiAdapter:
def _lodging_facts(self, intro: dict) -> list[CollectedFact]:
"""숙박 detailIntro2 → 사업장 단위 fact.
★ 스키마에 없는 값은 만들지 않는다. roomcount·scalelodging·foodplace 는
lodging 스키마에 자리가 없어서 **일부러 버린다** — 억지로 다른 key 에 넣으면
'식음료장 있음' 이 '조식 제공' 으로 둔갑한다.
★ 스키마에 없는 값은 만들지 않는다. foodplace('식음료장 있음')는 lodging 스키마에
자리가 없어서 **일부러 버린다** — 억지로 breakfast 에 넣으면 '조식 제공' 으로 둔갑한다.
★ roomcount·scalelodging·accomcountlodging·subfacility 는 2026-09-07 에 자리를 만들었다
(total_rooms·building_scale·accommodation_capacity·facilities). 실측(오블로모프 3103191)에서
TourAPI 가 준 값의 절반이 자리가 없어 버려지고 있었다.
"""
return self._collect([
("check_in_time", self._plain(intro.get("checkintime"))[:40]),
@ -296,6 +298,11 @@ class TourApiAdapter:
("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))),
("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))),
("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))),
("facilities", self._plain(intro.get("subfacility"))[:500]),
("total_rooms", self._number(intro.get("roomcount"))),
("accommodation_capacity", self._number(intro.get("accomcountlodging"))),
# "약 23평" · "대지 면적 11,570㎡" 처럼 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다.
("building_scale", self._plain(intro.get("scalelodging"))[:200]),
])
def _restaurant_facts(self, intro: dict) -> list[CollectedFact]:
@ -362,6 +369,16 @@ class TourApiAdapter:
("peak_price", self._number(row.get("roompeakseasonminfee1")), name),
("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name),
("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name),
# 객실 편의시설 Y/N. ★ 빈 값은 '없음'이 아니라 '모름'이다 — _yn 이 None 을 주면 fact 를 만들지 않는다.
("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name),
("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name),
("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name),
("has_refrigerator", self._bool_str(self._yn(row.get("roomrefrigerator"))), name),
("has_hairdryer", self._bool_str(self._yn(row.get("roomhairdryer"))), name),
("has_toiletries", self._bool_str(self._yn(row.get("roomtoiletries"))), name),
("has_table", self._bool_str(self._yn(row.get("roomtable"))), name),
("has_sofa", self._bool_str(self._yn(row.get("roomsofa"))), name),
("has_home_theater", self._bool_str(self._yn(row.get("roomhometheater"))), name),
])
return facts

View File

@ -10,7 +10,7 @@ import uuid
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, faqs, place_links, places, units
from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
@ -64,7 +64,7 @@ async def run_copy(job: dict) -> dict:
# ★ 노출 가능한 fact 만 근거로 준다. 미검증 값으로 쓴 문장은 그 자체가 미검증이다.
pid = uuid.UUID(place_id)
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
)
@ -81,7 +81,7 @@ async def run_copy(job: dict) -> dict:
for r in fact_rows
if r.unit_id is None and (r.value or "").strip()
]
# ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_links.raw 에 박제된 글이다.
# ★ 수집 원문도 근거로 넘긴다 — fact 가 아니라 place_channels.raw 에 박제된 글이다.
#
# 왜 필요한가: 소개 원문(TourAPI overview·네이버 description)에만 있는 정보가 있다.
# '전면 통창 실내 온수풀', '판교역에서 3분' 같은 것들인데, 이게 근거에 없으면
@ -91,7 +91,7 @@ async def run_copy(job: dict) -> dict:
# 원문을 그 칸에 넣었더니 457자 원문이 발행본의 '숙소 소개' 를 차지했다(2026-08-31).
# 근거로만 쓰고 저장은 하지 않는다 — 원문은 화면에 나가지 않는다.
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_links(s, pid, True),
)
@ -110,7 +110,7 @@ async def run_copy(job: dict) -> dict:
# 요금표만 올라온 모텔은 사업장 fact 가 0건이고 객실 fact 만 있다 — 쓸 근거가 있는데도
# "근거 없음"으로 끝나 소개문·FAQ 가 영구히 생기지 않았다.
u_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, pid),
)
@ -196,15 +196,15 @@ async def run_copy(job: dict) -> dict:
# 확인 안 된 기존 생성 FAQ 는 내리고 새로 넣는다. 사람이 확인한 FAQ 는 건드리지 않는다.
await DB_SESSION_MNG.execute_lambda_claim(
faqs.DBType(),
place_faqs.DBType(),
lambda s: _faq_crud.expire_generated(s, pid, now),
)
for order, faq in enumerate(copy.faqs or []):
for order, faq in enumerate(copy.place_faqs or []):
if not faq.fact_keys:
# ★ 근거 없는 FAQ 는 저장하지 않는다.
stat["rejected"].append([faq.question, "근거 fact 없음"])
continue
row = faqs(
row = place_faqs(
place_id=pid,
question=faq.question,
answer=faq.answer,
@ -214,7 +214,7 @@ async def run_copy(job: dict) -> dict:
sort_order=order,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[faqs.DBType()],
[place_faqs.DBType()],
[lambda s, r=row: _faq_crud.add_faq(s, r)],
)
if run_err == ErrorType.SUCCESS:

View File

@ -43,9 +43,10 @@ _BASE_URL = "https://dapi.kakao.com"
_KEYWORD_URL = f"{_BASE_URL}/v2/local/search/keyword.json"
_COORD2REGION_URL = f"{_BASE_URL}/v2/local/geo/coord2regioncode.json"
_CATEGORY_URL = f"{_BASE_URL}/v2/local/search/category.json"
_ADDRESS_URL = f"{_BASE_URL}/v2/local/search/address.json"
# 초과 단가(원). 로그에 함께 남겨 어떤 호출이 비싼지 바로 보이게 한다.
_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5}
_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5, "address": 0.5}
# 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거.
_CALL_COUNTS: Counter = Counter()
@ -362,6 +363,28 @@ class KakaoLocalClient:
picked = next((d for d in docs if d.get("region_type") == "H"), docs[0])
return RegionCode.from_document(picked)
# ---- 2-1) 주소 → 좌표 ----
async def geocode_address(self, address: str) -> Optional[tuple[float, float]]:
"""도로명·지번 주소 → (위도, 경도). 결과가 없으면 None — 좌표를 지어내지 않는다.
★ 쓰는 곳: 좌표 없이 검증된 사업장의 주변 정보 수집(local_content_service.sync_place).
동일 업소 검증(카카오 후보·네이버 상세)은 좌표를 같이 주므로 보통은 비어 있지 않다 —
비는 건 옛 데이터나 좌표 없는 후보를 고른 경우다. 그때 주소로 한 번 더 찾는다.
싸다(초과 시 건당 0.5원). 결과는 places 에 박제하므로 사업장당 1회다.
"""
query = (address or "").strip()
if not query:
return None
data = await self._get(_ADDRESS_URL, {"query": query, "size": "1"}, "address")
docs = data.get("documents") or []
if not docs:
LOG.i(f"[kakao] 주소 → 좌표 결과 없음: {query[:60]}")
return None
lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # ★ y=위도 · x=경도
if lat is None or lon is None:
return None
return lat, lon
# ---- 3) 주변 맛집·시설 ----
async def search_category(
self,

View File

@ -1,17 +1,58 @@
"""공공데이터포털 전국문화축제표준데이터 클라이언트.
"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트.
설정 키 이름은 기존 배포 계약을 유지하기 위해 TOUR_API_KEY를 그대로 사용한다.
collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는
업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다.
결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다.
★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md)
처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지
'이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고,
같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고
거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건.
★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제)
2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) —
한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다.
여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다.
★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체)
locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18):
반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의
진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도)
단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다.
eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부르고,
이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다.
★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙)
firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다.
발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다.
"""
import hashlib
from datetime import date
from urllib.parse import unquote
from typing import Optional
from urllib.parse import unquote, urlencode
import httpx
from common.enums import LocalContentType
from common.utils.geo import haversine_m
from config.server_configs import external_api_config
_URL = "https://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api"
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
REQUEST_TIMEOUT = 25
PAGE_SIZE = 100
# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다.
MAX_PAGES = 4
# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 —
# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족).
CONTENT_TYPE_MAP = {
"39": LocalContentType.RESTAURANT.value,
"12": LocalContentType.ATTRACTION.value,
}
# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유).
_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
class TourApiNotConfigured(RuntimeError):
@ -22,52 +63,225 @@ class TourApiRequestFailed(RuntimeError):
pass
def _normalize(item: dict) -> dict:
"""표준데이터 필드명을 화면/저장소의 공통 축제 필드로 변환한다."""
identity = "|".join(str(item.get(key) or "") for key in ("fstvlNm", "fstvlStartDate", "fstvlEndDate", "insttCode"))
return {
"contentid": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32],
"title": item.get("fstvlNm"),
"eventstartdate": str(item.get("fstvlStartDate") or "").replace("-", ""),
"eventenddate": str(item.get("fstvlEndDate") or "").replace("-", ""),
"addr1": item.get("rdnmadr") or item.get("lnmadr") or item.get("eventPlace"),
"mapx": item.get("longitude"),
"mapy": item.get("latitude"),
"tel": item.get("phoneNumber"),
"homepage": item.get("homepageUrl"),
"organizer": item.get("suprtInsttNm"),
"overview": item.get("relateInfo") or item.get("opar"),
"raw": item,
}
# ── HTTP ───────────────────────────────────────────────────────────────
async def search_festivals(*, start_date: date, area_code: str | None = None, rows: int = 100) -> list[dict]:
def _service_key() -> str:
key = (external_api_config.tour_api_key or "").strip()
if not key:
raise TourApiNotConfigured("TOUR_API_KEY is not configured")
params = {
# 포털의 "Encoding" 키를 넣어도 httpx가 이중 인코딩하지 않도록 원문으로 되돌린다.
"serviceKey": unquote(key),
"type": "json",
"numOfRows": max(1, min(rows, 1000)),
"pageNo": 1,
}
return key
async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]:
"""오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다.
★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다
(collector/tour_api_adapter._call 과 같은 처리).
"""
query = urlencode(
{"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
"_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params},
safe="",
)
res = await client.get(f"{BASE_URL}/{op}?{query}")
if res.status_code != 200:
raise TourApiRequestFailed(f"{op} HTTP {res.status_code}")
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0)) as client:
response = await client.get(_URL, params=params)
response.raise_for_status()
payload = response.json()
response_data = payload.get("response", payload)
header = response_data.get("header", {})
if header.get("resultCode") not in (None, "00", "0000"):
raise TourApiRequestFailed(header.get("resultMsg") or "TourAPI returned an error")
items_node = response_data.get("body", {}).get("items", [])
items = items_node.get("item", []) if isinstance(items_node, dict) else items_node
if isinstance(items, dict):
items = [items]
normalized = [_normalize(item) for item in (items or [])]
# API가 날짜 필터를 제공하지 않으므로 종료된 축제는 애플리케이션에서 제외한다.
cutoff = start_date.strftime("%Y%m%d")
return [item for item in normalized if not item["eventenddate"] or item["eventenddate"] >= cutoff]
except (httpx.HTTPError, ValueError, TypeError) as ex:
raise TourApiRequestFailed(str(ex)) from ex
payload = res.json()
except ValueError:
# 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다.
raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
# 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다.
if "response" not in payload:
raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}")
header = payload["response"].get("header", {})
code = str(header.get("resultCode") or "")
if code not in ("0000", "00"):
raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}")
body = payload["response"].get("body", {}) or {}
items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None
if isinstance(items, dict):
items = [items]
total = int(body.get("totalCount") or 0)
return items or [], total
# ── 정규화 ──────────────────────────────────────────────────────────────
def _int(value) -> Optional[int]:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def _normalize(item: dict) -> Optional[dict]:
"""locationBasedList2 항목 1건 → place_contents.body. contentid·title·거리·종류 없으면 버린다."""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip())
distance = _int(item.get("dist"))
if not content_id or not title or kind is None or distance is None:
return None
body = {"contentid": content_id, "title": title, "content_type": kind, "distance_m": distance}
# lclsSystm1~3 = 분류체계 대/중/소. ★ 중분류(lclsSystm2)가 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준이다.
for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd",
"lclsSystm1", "lclsSystm2", "lclsSystm3"):
value = str(item.get(key) or "").strip()
if value:
body[key] = value
# 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다.
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
body["firstimage"] = image
body["license"] = license_code
thumb = str(item.get("firstimage2") or "").strip()
if thumb:
body["firstimage2"] = thumb
return body
# ── 공개 API ────────────────────────────────────────────────────────────
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
*, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정).
"""
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "locationBasedList2",
mapX=str(longitude), mapY=str(latitude), radius=str(radius_m),
contentTypeId=content_type_id, arrange="E", pageNo=str(page),
)
for item in items:
body = _normalize(item)
if body and body["contentid"] not in seen:
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
"""searchFestival2 항목 1건 → place_contents.body. locationBasedList2 와 달리 `dist` 를
안 주므로(호출측이 haversine 으로 잰 값을) 그대로 받는다. 기간은 여기 이미 있다."""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
if not content_id or not title:
return None
body = {"contentid": content_id, "title": title,
"content_type": LocalContentType.FESTIVAL.value, "distance_m": distance_m}
for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd",
"lclsSystm1", "lclsSystm2", "lclsSystm3", "eventstartdate", "eventenddate"):
value = str(item.get(key) or "").strip()
if value:
body[key] = value
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
body["firstimage"] = image
body["license"] = license_code
thumb = str(item.get("firstimage2") or "").strip()
if thumb:
body["firstimage2"] = thumb
return body
def _festival_not_ended(body: dict, today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다."""
end, start = body.get("eventenddate"), body.get("eventstartdate")
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
*, sido_code: str, today: date) -> list[dict]:
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다.
★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의
큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
(종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.)
★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring).
eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다.
"""
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "searchFestival2",
eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page),
)
for item in items:
lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다
if lnglat is None:
continue
lng, lat = lnglat
distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용
body = _normalize_festival(item, round(distance))
if not body or body["contentid"] in seen:
continue
if not _festival_not_ended(body, today):
continue
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _mapxy(item: dict) -> Optional[tuple[float, float]]:
"""(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다."""
try:
return float(item.get("mapx")), float(item.get("mapy"))
except (TypeError, ValueError):
return None
async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]:
"""콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None.
업장 자신이 TourAPI 에 등록돼 있을 때(place_links 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 —
주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다).
"""
items, _ = await _call(client, "detailCommon2", contentId=content_id)
if not items:
return None
code = str(items[0].get("lclsSystm2") or "").strip()
return code or None
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다."""
if not period:
return False
start, end = period
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, places, units
from common.database.model.models import place_facts, places, place_units
from common.enums import (
FACT_STATUS_TRANSITIONS,
PUBLISHABLE_FACT_STATUSES,
@ -121,7 +121,7 @@ class FactService:
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_facts(s, uuid.UUID(place_id), unit_id, None, publishable_only, True),
)
@ -195,7 +195,7 @@ class FactService:
unit_id = unit_map.get(fact.unit_name) if fact.unit_name else None
if fact.unit_name and unit_id is None:
row.reason = f"'{fact.unit_name}' 단위가 아직 없다 — 수집으로 객실·메뉴가 먼저 만들어져야 한다"
res.facts.append(row)
res.place_facts.append(row)
res.rejected += 1
continue
@ -212,7 +212,7 @@ class FactService:
else:
row.reason = write.result.desc or "저장 실패"
res.rejected += 1
res.facts.append(row)
res.place_facts.append(row)
LOG.i(f"[fact] 붙여넣기 추출 — 저장 {res.stored}건 · 반려 {res.rejected}건 (place={place_id})")
return res
@ -220,7 +220,7 @@ class FactService:
async def _unit_map(self, place_id: str) -> dict:
"""단위 이름 → unit_id. 붙여넣기 값이 어느 객실 것인지 잇는 데만 쓴다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.list_units(s, uuid.UUID(place_id)),
)
@ -264,7 +264,7 @@ class FactService:
pid = uuid.UUID(place_id)
pub_err, published = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_published_fact(s, pid, req.unit_id, req.key),
)
@ -278,7 +278,7 @@ class FactService:
# ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ──
if same_value:
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
facts.DBType(),
place_facts.DBType(),
lambda s: self.crud.refresh_collected(s, published.fact_id, req.source_type.value, req.source_url, now),
)
if run_err != ErrorType.SUCCESS:
@ -299,7 +299,7 @@ class FactService:
target_status = FactStatus.PENDING_OWNER if published is not None else FactStatus.UNVERIFIED
cand_err, candidate = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_candidate(s, pid, req.unit_id, req.key, req.source_type.value),
)
@ -310,7 +310,7 @@ class FactService:
# 같은 출처가 이미 올려둔 후보가 있으면 갱신한다(같은 후보가 계속 쌓이지 않게).
if candidate is not None:
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
facts.DBType(),
place_facts.DBType(),
lambda s: self.crud.update_candidate(
s, candidate.fact_id, req.value, req.source_url, target_status.value, now
),
@ -334,7 +334,7 @@ class FactService:
expires_at=req.expires_at,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[facts.DBType()],
[place_facts.DBType()],
[lambda s: self.crud.add_fact(s, fact)],
)
if run_err != ErrorType.SUCCESS:
@ -364,7 +364,7 @@ class FactService:
expires_at=req.expires_at,
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[facts.DBType()],
[place_facts.DBType()],
[
lambda s: self._expire_then_ok(s, pid, req, now),
lambda s: self.crud.add_fact(s, fact),
@ -387,7 +387,7 @@ class FactService:
async def _reload(self, res, pid, fact_id):
_e, row = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_fact(s, pid, fact_id),
)
@ -409,7 +409,7 @@ class FactService:
pid = uuid.UUID(place_id)
fid = uuid.UUID(fact_id)
get_err, fact = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_fact(s, pid, fid),
)
@ -445,7 +445,7 @@ class FactService:
if promoting:
funcs.append(lambda s: self._reject_others_ok(s, pid, fact, now, fid))
run_err = await DB_SESSION_MNG.execute_lambda_run([facts.DBType()], funcs)
run_err = await DB_SESSION_MNG.execute_lambda_run([place_facts.DBType()], funcs)
if run_err != ErrorType.SUCCESS:
res.result.SetResult(
ErrorType.FACT_INVALID_TRANSITION if run_err == ErrorType.DB_EMPTY_DATA else run_err

View File

@ -15,7 +15,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs, places
from common.database.model.models import place_faqs, places
from common.enums import (
CANDIDATE_FACT_STATUSES,
FACT_STATUS_TRANSITIONS,
@ -67,7 +67,7 @@ class FaqService:
async def _reload(self, res: Res_Faq, pid, faq_id) -> Res_Faq:
_e, row = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_faq(s, pid, faq_id),
)
@ -86,7 +86,7 @@ class FaqService:
pid = uuid.UUID(place_id)
list_err, rows = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_faqs(s, pid, publishable_only),
)
@ -123,7 +123,7 @@ class FaqService:
if sort_order is None:
# 순서를 안 주면 맨 뒤에 붙인다 — 기존 FAQ 사이에 끼어들어 순서를 흔들지 않게.
_e, rows = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_faqs(s, pid, False),
)
@ -137,7 +137,7 @@ class FaqService:
status=FactStatus.VERIFIED.value,
sort_order=sort_order,
)
run_err = await DB_SESSION_MNG.execute_lambda_run([faqs.DBType()], [lambda s: self.crud.add_faq(s, row)])
run_err = await DB_SESSION_MNG.execute_lambda_run([place_faqs.DBType()], [lambda s: self.crud.add_faq(s, row)])
if run_err != ErrorType.SUCCESS:
res.result.SetResult(run_err)
return res
@ -162,7 +162,7 @@ class FaqService:
pid = uuid.UUID(place_id)
fid = uuid.UUID(faq_id)
get_err, faq = await DB_SESSION_MNG.execute_lambda(
faqs.DBType(),
place_faqs.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_faq(s, pid, fid),
)
@ -192,7 +192,7 @@ class FaqService:
now = GTime.UTC()
run_err = await DB_SESSION_MNG.execute_lambda_run(
[faqs.DBType()],
[place_faqs.DBType()],
[lambda s: self._transition_ok(s, fid, current, target, data)],
)
if run_err != ErrorType.SUCCESS:

View File

@ -0,0 +1,147 @@
"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음.
빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로
일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면
다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다.
(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md)
★ 지어내지 않는 규칙은 여기도 적용된다.
재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다.
좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다.
하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다.
일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다.
"""
from typing import Optional
from common.utils.geo import haversine_km
# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다.
_SPOTS_PER_DAY = 2
_MEALS_PER_DAY = 2
# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다.
_MAX_RADIUS_KM = 30.0
_STOP_ATTRACTION = "attraction"
_STOP_RESTAURANT = "restaurant"
_STOP_FESTIVAL = "festival"
def _as_float(value) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def _candidates(rows: list[dict], stop_type: str,
base_lat: float, base_lng: float) -> list[dict]:
"""payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다.
행 모양은 스냅샷 local.contents 의 body(services/external/tour_api._normalize)다 —
mapx=경도, mapy=위도 (WGS84 문자열).
"""
out = []
for row in rows:
body = row.get("body") or {}
name = str(row.get("title") or body.get("title") or "").strip()
lat, lng = _as_float(body.get("mapy")), _as_float(body.get("mapx"))
if not name or lat is None or lng is None:
continue
dist = haversine_km(base_lat, base_lng, lat, lng)
if dist > _MAX_RADIUS_KM:
continue
out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng,
"distanceKm": round(dist, 1)})
out.sort(key=lambda c: c["distanceKm"])
return out
def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]:
"""일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다."""
remaining = list(stops)
ordered: list[dict] = []
lat, lng = base_lat, base_lng
while remaining:
nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"]))
remaining.remove(nxt)
ordered.append(nxt)
lat, lng = nxt["lat"], nxt["lng"]
return ordered
def _take(pool: list[dict], count: int) -> list[dict]:
taken, pool[:] = pool[:count], pool[count:]
return taken
# 일자 이름. 화면이 탭 라벨로 그대로 쓴다.
_DAY_LABEL = {1: "첫째 날", 2: "둘째 날", 3: "셋째 날"}
# 며칠짜리인가 → 사람이 읽는 말. 화면은 이 값으로 일정을 가른다(ItineraryItem.duration).
_DURATION = {2: "1박 2일", 3: "2박 3일"}
def _to_stop(cand: dict) -> dict:
"""후보 → 렌더러의 PlannerStop.
★ 머무는 시간·이동 시간은 **비운다.** 우리가 재지 않은 값이라, 넣으면 화면의 시각표가
지어낸 숫자 위에 세워진다. 화면은 없으면 자기 기본값으로 계산한다.
★ URL 을 만들지 않는다 — 지도 검색어(searchQuery)만 준다(LocalPlace 와 같은 규약).
"""
return {
"name": cand["name"],
"searchQuery": cand["name"],
"latitude": cand["lat"],
"longitude": cand["lng"],
}
def _plan_days(days: int, attractions: list[dict], restaurants: list[dict],
festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]:
"""일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다."""
spots = list(attractions)
meals = list(restaurants)
fests = list(festivals)
plan = []
for day in range(1, days + 1):
day_stops = []
# 축제는 하루 하나까지, 관광지 한 자리를 대신한다.
fest = _take(fests, 1)
day_stops += fest
day_stops += _take(spots, _SPOTS_PER_DAY - len(fest))
day_stops += _take(meals, _MEALS_PER_DAY)
if not day_stops:
break
plan.append({
"label": _DAY_LABEL.get(day, f"{day}일차"),
"stops": [_to_stop(s) for s in _order_by_route(day_stops, base_lat, base_lng)],
})
if not plan:
return None
# ★ 렌더러 계약(`shared` ItineraryItem)의 모양으로 낸다. 예전에는 {days, plan[{day, stops}]}
# 라는 우리끼리의 모양이었고, 화면(ItinerarySection)은 그걸 못 읽어 **일정이 통째로
# 안 나왔다** — 수집·계산은 다 됐는데 화면만 비어 있었다(실측 2026-09-09, 조이모텔).
return {"name": _DURATION[days], "duration": _DURATION[days], "days": plan}
def build_itineraries(base_lat: Optional[float], base_lng: Optional[float],
attractions: list[dict], restaurants: list[dict],
festivals: list[dict]) -> list[dict]:
"""업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열.
입력 행 모양은 스냅샷 local.contents 항목({title, body:{mapx, mapy, …}})이다.
"""
if base_lat is None or base_lng is None:
return []
spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng)
meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng)
fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng)
out = []
for days in (2, 3):
# 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다.
built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool),
base_lat, base_lng)
if built:
out.append(built)
return out

View File

@ -1,32 +1,142 @@
from datetime import date, datetime, timedelta, timezone
import re
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import local_contents
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource
from common.database.model.models import area_contents, place_area_refs, place_channels, places
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData
from crud.place_content_crud import PlaceContentCRUD
from crud.place_crud import PlaceCRUD
from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed
from services.external.naver import region_key
from router.v1.local.protocol import (
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData,
)
from services.external import tour_api
from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed, search_festivals
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed
from services.place_category import guess_food_class
# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId)
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다.
# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다).
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다.
_KST = timezone(timedelta(hours=9))
# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md
# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다.
# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다.
# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다.
# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido).
RESTAURANT_RADIUS_M = 5_000
ATTRACTION_RADIUS_M = 10_000
def _festival_display_end(body: dict) -> datetime | None:
"""eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC.
★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 —
스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다."""
raw = str(body.get("eventenddate") or "").strip()
if len(raw) != 8 or not raw.isdigit():
return None
try:
end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST)
except ValueError:
return None
return (end_day + timedelta(days=1)).astimezone(timezone.utc)
def _as_float(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
class LocalContentService:
def __init__(self):
self.crud = LocalContentCRUD()
self.place_crud = PlaceContentCRUD()
async def list(self, status=None, region_code=None):
res = ResLocalContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code)
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code)
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def sync_festivals(self, req):
res = ResSyncFestivals()
# ── 업장 반경 주변정보 ───────────────────────────────────────────────
async def sync_place(self, place) -> ResSyncPlace:
"""업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다.
종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08).
여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측).
빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다.
★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석).
★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다.
hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된
여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로).
★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다.
"""
res = ResSyncPlace()
place_id = getattr(place, "place_id", None)
if place_id is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
if lat is None or lng is None:
# ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가
# 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다.
found = await self._geocode_and_store(place)
if found is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)."
return res
lat, lng = found
try:
start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today()
items = await search_festivals(start_date=start, area_code=req.area_code)
async with tour_api.make_client() as client:
# 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다.
restaurants = await tour_api.fetch_nearby(
client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39")
# ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 —
# 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
# 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다.
own_class = await self._own_food_class(client, place)
if own_class:
before = len(restaurants)
restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class]
LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외")
attractions = await tour_api.fetch_nearby(
client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12")
# ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) —
# 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고,
# 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다.
sido_code = str(getattr(place, "region_code", None) or "")[:2] or None
if not sido_code:
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
derived = region_key(address)
sido_code = derived[:2] if derived else None
today = datetime.now(_KST).date()
if sido_code:
festivals = await tour_api.fetch_festivals_in_sido(
client, lat, lng, sido_code=sido_code, today=today)
else:
LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀")
festivals = []
except TourApiNotConfigured:
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
res.msg = "TOUR_API_KEY를 먼저 설정해주세요."
@ -36,39 +146,236 @@ class LocalContentService:
res.msg = str(ex)
return res
collected = skipped = 0
for item in items:
external_id = str(item.get("contentid") or "")
if not external_id:
skipped += 1
continue
_, existing = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value,
lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid),
kept: list[dict] = restaurants + attractions + festivals
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
existing = {(int(r.content_type), r.external_id): r for r in (rows or [])} # 조인 결과(area_contents + 거리)
now = datetime.now(timezone.utc)
# 공용 콘텐츠에 실어 둘 지역. 유일성의 근거는 external_id 이고 이건 조회 편의다 —
# 없으면 NULL 로 둔다(지어내지 않는다).
region_code = str(getattr(place, "region_code", None) or "").strip() or None
changed = 0
kept_ids: set = set()
for body in kept:
key = (body["content_type"], body["contentid"])
prev = existing.get(key)
# ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다.
# (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다.
content_values = {
"source": LocalSource.TOUR_API.value,
"external_id": body["contentid"],
"content_type": body["content_type"],
"title": body["title"],
"body": body,
"latitude": _as_float(body.get("mapy")),
"longitude": _as_float(body.get("mapx")),
"region_code": region_code,
# ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다.
# 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다.
"status": LocalContentStatus.PUBLISHED.value,
"display_end_at": (
_festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None
),
"collected_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()],
[lambda s, v=content_values: self.place_crud.upsert_content(s, v)],
)
if existing:
skipped += 1
if write_err != ErrorType.SUCCESS:
continue
row = local_contents(
region_code=req.region_code,
content_type=LocalContentType.FESTIVAL.value,
source=LocalSource.TOUR_API.value,
external_id=external_id,
title=item.get("title"),
body=item,
status=LocalContentStatus.REVIEW.value,
collected_at=datetime.now(timezone.utc),
err_i, rows_i = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s, e=body["contentid"]: self.place_crud.find_content_id(s, LocalSource.TOUR_API.value, e),
)
err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)])
collected += int(err == ErrorType.SUCCESS)
skipped += int(err != ErrorType.SUCCESS)
res.collected, res.skipped = collected, skipped
if err_i != ErrorType.SUCCESS or not rows_i:
continue
content_id = rows_i[0] # 단일 컬럼 SELECT 는 scalars() 로 펴져 값이 곧 행이다
kept_ids.add(content_id)
# 원문이 그대로면 관계만 확인하고 넘어간다 — collected_at 만 밀리면 '갱신된 척'이 된다.
await DB_SESSION_MNG.execute_lambda_run(
[place_area_refs.DBType()],
[lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)],
)
if prev is None or prev.body != body:
changed += 1
# 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다.
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids)
)
counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value,
LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)}
for b in kept:
counts[b["content_type"]] += 1
res.festivals = counts[LocalContentType.FESTIVAL.value]
res.attractions = counts[LocalContentType.ATTRACTION.value]
res.restaurants = counts[LocalContentType.RESTAURANT.value]
res.courses = counts[LocalContentType.COURSE.value]
res.changed = changed > 0 or bool(removed)
LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})")
return res
async def _own_food_class(self, client, place) -> str | None:
"""음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다).
우선순위 — 정확한 쪽부터:
1. 업장이 TourAPI 에 등록돼 있으면(place_channels 의 tour:// 링크) 그 콘텐츠의 lclsSystm2.
주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다.
2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑.
3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다).
실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다.
"""
category = getattr(place, "category", None)
if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value):
return None
err, links = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(), DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True),
)
for link in (links or []) if err == ErrorType.SUCCESS else []:
m = _TOUR_LINK.match(str(getattr(link, "url", "") or ""))
if not m:
continue
try:
code = await tour_api.fetch_content_class(client, m.group(2))
except TourApiRequestFailed as ex:
LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}")
code = None
if code:
return code
break
guessed = guess_food_class(getattr(place, "external_category", None))
if guessed:
return guessed
return _DEFAULT_FOOD_CLASS.get(category)
async def _geocode_and_store(self, place) -> tuple[float, float] | None:
"""주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None."""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return None
client = KakaoLocalClient()
if not client.enabled:
LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다")
return None
try:
found = await client.geocode_address(address)
except (KakaoNotConfigured, KakaoRequestFailed) as ex:
LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}")
return None
finally:
await client.aclose()
if found is None:
return None
lat, lng = found
company_id = getattr(place, "company_id", None)
if company_id is not None:
data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))}
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}")
else:
place.latitude, place.longitude = data["latitude"], data["longitude"]
LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}")
return lat, lng
async def _load_place(self, place_id):
"""회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712
),
)
return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace:
place = await self._load_place(place_id)
if place is None:
res = ResSyncPlace()
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
return await self.sync_place(place)
async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList:
res = ResPlaceContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True),
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList:
res = ResPlaceContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide:
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다.
"""
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local
from services.snapshot import _local_contents
res = ResLocalGuide()
place = await self._load_place(place_id)
if place is None:
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
# ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약).
# 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측).
# 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다.
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err == ErrorType.SUCCESS and not rows:
synced = await self.sync_place(place)
if not synced.result.success:
LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}")
snapshot_local = await _local_contents(place)
local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude))
res.attractions = local.get("attractions") or []
res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or []
res.synced_at = synced_at
return res
# ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ────────────────
async def publish(self, ids, user_id):
res = ResLocalContentList()
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id)
area_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id)
)
res.result.SetResult(err)
return res
@ -82,7 +389,7 @@ class LocalContentService:
res.msg = "노출 종료는 시작보다 뒤여야 합니다."
return res
err, count = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.update(s, content_id, data)
area_contents.DBType(), lambda s: self.crud.update(s, content_id, data)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
@ -90,7 +397,7 @@ class LocalContentService:
async def end(self, content_id):
res = ResLocalContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
local_contents.DBType(), lambda s: self.crud.end(s, content_id)
area_contents.DBType(), lambda s: self.crud.end(s, content_id)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
@ -99,7 +406,7 @@ class LocalContentService:
"""Cache-aside weather lookup; stale data survives upstream failures."""
res = ResWeather()
err, cached = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value,
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.get_weather(s, region_code),
)
if err != ErrorType.SUCCESS:
@ -139,7 +446,7 @@ class LocalContentService:
"updated_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[local_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)]
[area_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)]
)
if write_err != ErrorType.SUCCESS:
res.result.SetResult(write_err)

View File

@ -3,7 +3,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media, places
from common.database.model.models import place_photos, places
from common.enums import DBWRType, ErrorType, MediaStatus, SourceType
from common.models.gmodel import UserInfo
from crud.media_crud import IMediaCRUD, MediaCRUD
@ -66,7 +66,7 @@ class MediaService:
# publishable_only 의 승인 조건만 CRUD 에 넘기고, alt 조건은 목록 계산과 함께 아래에서 건다.
status = MediaStatus.APPROVED.value if publishable_only else None
list_err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_media(
s, uuid.UUID(place_id), status, False, unit_id, publishable_only
@ -81,7 +81,7 @@ class MediaService:
data = MediaData.model_validate(r)
data.publishable = _is_publishable(r)
items.append(data)
res.media = items
res.place_photos = items
res.publishable = sum(1 for x in items if x.publishable)
# 사람 확인 큐에 남은 수 — 관리 화면의 '검토할 것' 배지.
res.pending_review = sum(1 for r in rows if r.status == MediaStatus.PENDING_REVIEW.value)

View File

@ -68,3 +68,39 @@ def _match(text: str) -> Optional[PlaceCategory]:
if any(word in text for word in words):
return category
return None
# ── 음식 중분류(TourAPI lclsSystm2) 추정 ─────────────────────────────────
# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정).
# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전)
# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집.
#
# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 —
# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다.
# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 —
# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다.
_FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")),
("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")),
("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥",
"떡볶이", "간식", "토스트", "패스트푸드")),
("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크",
"프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")),
("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈",
"해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")),
]
def guess_food_class(category_name: Optional[str]) -> Optional[str]:
"""외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음).
예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 ·
카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02
"""
text = (category_name or "").replace(" ", "")
if not text:
return None
for code, words in _FOOD_CLASS_KEYWORDS:
if any(word in text for word in words):
return code
return None

View File

@ -4,7 +4,7 @@ from fastapi import Depends
from common.category_schema import CategorySchemaError, get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_links, places, units
from common.database.model.models import place_channels, places, place_units
from common.enums import DBWRType, ErrorType, JobStatus, JobType, LinkChannel, PlaceCategory, PlaceStatus, SourceType
from common.logger import LOG
from common.models.gmodel import PageParams, UserInfo
@ -243,6 +243,8 @@ class PlaceService:
phone=base.get("phone") or base.get("virtualPhone") or None,
latitude=Decimal(str(coord.get("y"))) if coord.get("y") else None,
longitude=Decimal(str(coord.get("x"))) if coord.get("x") else None,
# 네이버 상세의 분류("펜션"·"카페,디저트"). 주변 맛집 경쟁업소 제외의 폴백 근거(실측 2026-09-08: 있음).
category_name=str(base.get("category") or "").strip() or None,
)
verified = await self.verify_place(user_info, place_id, verify_req)
if not verified.result.success:
@ -268,7 +270,7 @@ class PlaceService:
title=f"{official} 네이버 플레이스", discovered_by=SourceType.OWNER),
)
await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: self.crud.confirm_link_by_url(
s, uuid.UUID(place_id), canonical, uuid.UUID(user_info.user_id), GTime.UTC()
),
@ -321,6 +323,9 @@ class PlaceService:
"verified_at": now,
"verified_by": uuid.UUID(user_info.user_id),
}
# ★ 값이 왔을 때만 덮는다 — 네이버 URL 재검증이 분류를 못 읽었다고 카카오가 준 값을 지우면 안 된다.
if (req.category_name or "").strip():
data["external_category"] = req.category_name.strip()[:200]
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data),
@ -345,7 +350,7 @@ class PlaceService:
discovered_at=now,
)
await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[place_channels.DBType()],
[lambda s: self.crud.add_link(s, link)],
)
return await self.get_place(user_info, place_id)
@ -358,7 +363,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
units.DBType(),
place_units.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_units(s, uuid.UUID(place_id)),
)
@ -380,7 +385,7 @@ class PlaceService:
unit = units(place_id=uuid.UUID(place_id), name=req.name.strip(), sort_order=req.sort_order)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[units.DBType()],
[place_units.DBType()],
[lambda s: self.crud.add_unit(s, unit)],
)
if run_err != ErrorType.SUCCESS:
@ -397,7 +402,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
list_err, rows = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_links(s, uuid.UUID(place_id), confirmed_only),
)
@ -430,7 +435,7 @@ class PlaceService:
discovered_at=GTime.UTC(),
)
run_err = await DB_SESSION_MNG.execute_lambda_run(
[place_links.DBType()],
[place_channels.DBType()],
[lambda s: self.crud.add_link(s, link)],
)
if run_err != ErrorType.SUCCESS:
@ -451,7 +456,7 @@ class PlaceService:
return res
run_err, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
place_channels.DBType(),
lambda s: self.crud.confirm_link(
s, uuid.UUID(place_id), uuid.UUID(link_id), uuid.UUID(user_info.user_id), GTime.UTC()
),
@ -492,7 +497,7 @@ class PlaceService:
# 채널 URL 발견(Perplexity)은 **잡의 첫 단계**다 — 링크가 하나도 없어도 수집을 시작할 수 있다.
# 여기서는 이미 확정된 링크 수만 세어 응답에 실어준다(진행 상황 표시용).
link_err, links = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(),
place_channels.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_links(s, uuid.UUID(place_id), True),
)
@ -554,7 +559,7 @@ class PlaceService:
force 로 재분석할 때 이 엔드포인트를 쓴다."""
from sqlalchemy import func, select
from common.database.model.models import media
from common.database.model.models import place_photos
from services.external import gemini
res = Res_StartVision()
@ -817,7 +822,7 @@ class PlaceService:
★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 —
근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다."""
from common.database.model.models import facts as facts_model
from common.database.model.models import place_facts as facts_model
from crud.fact_crud import FactCRUD
from services.external import gemini_text

View File

@ -31,6 +31,7 @@ from common.enums import (
SourceType,
)
from common.logger import LOG
from services.itinerary import build_itineraries
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
SCHEMA_VERSION = 1
@ -65,6 +66,26 @@ _CHANNEL_TITLE = {
LinkChannel.ETC.value: "기타 채널",
}
# 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿
# (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다.
# ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`)
# 이 키가 없으면 `<head>` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예
# 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는
# `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로,
# 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다.
# 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다.
_DEFAULT_LOOK = {
"fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
"fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
"radius": "0.75rem",
"borderWidth": "1px",
"shadow": "0 1px 2px rgb(0 0 0 / 0.06)",
"headingTracking": "-0.02em",
"headingWeight": "700",
"sectionSpace": "4rem",
}
# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다.
# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다:
# templateId ← sites.template_id (POST /v1/place/{id}/site/template)
@ -84,11 +105,22 @@ _DEFAULT_THEME = {
"fontStyle": "Modern Editorial",
"colors": {"primary": "#18181b", "secondary": "#52525b", "bg": "#ffffff",
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
# ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은
# 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
# ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
# 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
# ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
# (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
# 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
"sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "지역 정보", False), ("faq", "자주 묻는 질문", False),
("event", "소식", False),
("info", "기본 정보", True), ("booking", "예약 안내", False),
("video", "영상", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True),
("festival", "계절별 축제", False), ("local", "지역 정보", False),
("itinerary", "추천 일정", False), ("story", "지역 이야기", False),
("faq", "자주 묻는 질문", False), ("weather", "날씨", False),
],
},
PlaceCategory.CAFE.value: {
@ -313,9 +345,15 @@ def _theme(site, theme_spec: dict) -> dict:
# ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 —
# 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다.
# 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다.
# 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가
# 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다.
look = saved.get("look")
if isinstance(look, dict) and look:
out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
cleaned = (
{k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
if isinstance(look, dict)
else {}
)
out["look"] = {**_DEFAULT_LOOK, **cleaned}
return out
@ -477,32 +515,77 @@ def _festival(row: dict):
# 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다.
if homepage.startswith("http://") or homepage.startswith("https://"):
entry["officialUrl"] = homepage
# 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와
# 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정).
_put_distance(entry, body)
image = _text(body.get("firstimage"))
if image:
entry["imageUrl"] = image
return entry
def _local_place(row: dict, category: str):
"""LocalPlace(주변 명소·맛집).
★ title 컬럼만 믿는다. 이 두 종류(ATTRACTION·RESTAURANT)를 채우는 수집기가 아직 없어서
body 의 모양이 정해지지 않았다 — 있지도 않은 키를 가정해 파싱하면 수집기가 붙는 날
조용히 빈 값이 나간다. 지금은 테이블 스키마가 보장하는 것(title)만 쓰고,
설명·거리는 실제 수집기가 붙을 때 그 모양을 보고 채운다."""
body 는 TourAPI 수집기가 채운다(services/external/tour_api._normalize) —
주소(addr1)가 있으면 위치로 싣는다. 없는 값은 만들지 않는다."""
name = _text(row.get("title"))
if not name:
return None
return {"name": name, "category": category, "searchQuery": name}
body = row.get("body") or {}
entry = {"name": name, "category": category, "searchQuery": name}
location = _text(body.get("addr1"))
if location:
entry["location"] = location
# 거리는 수집 시점에 업장 좌표로 잰 값(place_contents.distance_m). 지역 캐시 항목엔 없다.
# ★ 숫자(distanceMeters)도 함께 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를 계산하려면
# "1.2km" 같은 문자열을 다시 파싱하는 것보다 원값이 안전하다.
_put_distance(entry, body)
image = _text(body.get("firstimage"))
if image:
entry["imageUrl"] = image
return entry
def _local(snapshot_local: dict) -> tuple[dict, str | None]:
def _put_distance(entry: dict, body: dict) -> None:
"""distance_m → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다."""
distance = _distance_text(body.get("distance_m"))
if not distance:
return
entry["distanceText"] = distance
entry["distanceMeters"] = int(body.get("distance_m"))
def _distance_text(meters) -> str:
"""850 → "850m", 1234 → "1.2km". 없으면 빈 문자열."""
try:
m = int(meters)
except (TypeError, ValueError):
return ""
if m < 0:
return ""
if m < 1000:
return f"{m}m"
# 반올림은 '5 는 올림'으로 — f"{1.45:.1f}" 는 부동소수 탓에 1.4 가 나온다.
return f"{(m + 50) // 100 / 10:.1f}km"
def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]:
"""스냅샷의 지역 정보 → LocalContents.
★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 —
fact·사진과 같은 분업이다.
★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다."""
payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.
★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 —
재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py).
렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다."""
contents = (snapshot_local or {}).get("contents") or []
local = {"attractions": [], "restaurants": [], "festivals": []}
# courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07).
local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []}
synced_at = None
# 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다.
raw = {"attractions": [], "restaurants": [], "festivals": []}
for row in contents:
if not isinstance(row, dict):
@ -523,16 +606,29 @@ def _local(snapshot_local: dict) -> tuple[dict, str | None]:
entry = _festival(row)
if entry:
local["festivals"].append(entry)
raw["festivals"].append(row)
elif content_type == LocalContentType.ATTRACTION.value:
entry = _local_place(row, "관광지")
if entry:
local["attractions"].append(entry)
raw["attractions"].append(row)
elif content_type == LocalContentType.RESTAURANT.value:
entry = _local_place(row, "맛집")
if entry:
local["restaurants"].append(entry)
raw["restaurants"].append(row)
elif content_type == LocalContentType.COURSE.value:
entry = _local_place(row, "여행코스")
if entry:
local["courses"].append(entry)
# 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다.
itineraries = build_itineraries(
base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"]
)
if itineraries:
local["itineraries"] = itineraries
return local, synced_at
@ -708,7 +804,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
# ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙).
# 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다.
# 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다.
local, local_synced_at = _local(snapshot.get("local") or {})
local, local_synced_at = _local(
snapshot.get("local") or {},
_as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")),
)
if local_synced_at:
local["syncedAt"] = local_synced_at

View File

@ -4,7 +4,7 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import ai_check_results, places, publish_logs, site_versions, sites
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
@ -585,7 +585,7 @@ class SiteService:
res.result.SetResult(ErrorType.SITE_NOT_FOUND)
return res
l_err, rows = await DB_SESSION_MNG.execute_lambda(
publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50)
site_publish_logs.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_logs(s, site.site_id, 50)
)
if l_err == ErrorType.SUCCESS:
res.logs = [PublishLogData.model_validate(r) for r in rows]
@ -623,12 +623,12 @@ class SiteService:
await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"status": target.value})
)
log = publish_logs(
log = site_publish_logs(
site_id=site.site_id,
site_version_id=site.current_version_id,
action=req.action.value,
result=PublishResult.SUCCESS.value,
actor_user_id=uuid.UUID(user_info.user_id),
)
await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)])
await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: self.crud.add_log(s, log)])
return await self.get_site(user_info, place_id)

View File

@ -22,13 +22,14 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, faqs, local_contents, media, units
from common.database.model.models import place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
LocalSource,
MediaStatus,
PlaceCategory,
)
@ -38,9 +39,9 @@ from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라
# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다.
# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다.
# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
@ -56,30 +57,30 @@ async def build_snapshot(place) -> dict:
schema = get_schema(category)
fact_rows = await _select(
select(facts).where(
facts.place_id == pid,
facts.deleted == False, # noqa: E712
facts.status.in_(_PUBLISHABLE),
select(place_facts).where(
place_facts.place_id == pid,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHABLE),
)
)
unit_rows = await _select(
select(units).where(units.place_id == pid, units.deleted == False) # noqa: E712
.order_by(units.sort_order.asc())
select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc())
)
faq_rows = await _select(
select(faqs).where(
faqs.place_id == pid,
faqs.deleted == False, # noqa: E712
faqs.status.in_(_PUBLISHABLE),
).order_by(faqs.sort_order.asc())
select(place_faqs).where(
place_faqs.place_id == pid,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_(_PUBLISHABLE),
).order_by(place_faqs.sort_order.asc())
)
# ★ 승인된 사진만. Vision 신뢰도가 낮아 확인 큐에 남은 사진은 사이트에 안 나간다.
media_rows = await _select(
select(media).where(
media.place_id == pid,
media.deleted == False, # noqa: E712
media.status == MediaStatus.APPROVED.value,
).order_by(media.sort_order.asc())
select(place_photos).where(
place_photos.place_id == pid,
place_photos.deleted == False, # noqa: E712
place_photos.status == MediaStatus.APPROVED.value,
).order_by(place_photos.sort_order.asc())
)
local_rows = await _local_contents(place)
@ -164,10 +165,14 @@ async def build_snapshot(place) -> dict:
async def _local_contents(place) -> dict:
"""사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]}
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
두 캐시를 합친다 —
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외
★ 노출 가능 = PUBLISHED + 노출 기간 안.
local_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
@ -192,48 +197,97 @@ async def _local_contents(place) -> dict:
region_code = region_key(
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
) or ""
if not region_code:
return {"region_code": None, "contents": []}
now = datetime.now(timezone.utc)
query = (
select(local_contents)
.where(
local_contents.region_code == region_code,
local_contents.deleted == False, # noqa: E712
local_contents.status == LocalContentStatus.PUBLISHED.value,
or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now),
or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now),
)
.order_by(local_contents.content_type.asc(), local_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
# 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
return {"region_code": region_code, "contents": []}
contents: list[dict] = []
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
seen: dict[int, int] = {}
contents = []
for row in rows or []:
# ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
if region_code:
query = (
select(area_contents)
.where(
area_contents.region_code == region_code,
area_contents.deleted == False, # noqa: E712
area_contents.status == LocalContentStatus.PUBLISHED.value,
or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now),
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen)
# ── 업장 반경 캐시(place_area_refs): 맛집·관광지·축제·여행코스 ──
# ★ 정렬은 **사진 있는 것 우선 → 가까운 순**(2026-09-07 결정). 종류별 상한 안에 들려면
# 사진 없는 가까운 곳보다 사진 있는 조금 먼 곳이 이긴다 — 화면이 카드라 사진이 없으면 자리가 빈다.
place_id = getattr(place, "place_id", None)
if place_id is not None:
# ★ 실체(area_contents)와 관계(place_area_refs)를 조인한다 — 2026-09-09 에 값과 관계를
# 갈랐다. 사진 유무는 body.firstimage 로 판단한다(같은 사실을 컬럼으로 또 두지 않는다).
nearby_q = (
select(
area_contents.content_type,
area_contents.external_id,
area_contents.title,
area_contents.body,
area_contents.display_end_at,
# ★ _local_rows 가 읽는 필드는 빠짐없이 넣는다 — 조인으로 바꾸면서
# source·collected_at 이 빠져 AttributeError 로 스냅샷이 통째로 죽었다.
area_contents.source,
area_contents.collected_at,
place_area_refs.distance_m,
)
.join(place_area_refs, place_area_refs.local_content_id == area_contents.local_content_id)
.where(
place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712
place_area_refs.hidden == False, # noqa: E712
area_contents.deleted == False, # noqa: E712
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
.order_by(
area_contents.content_type.asc(),
# 사진 있는 것이 먼저 — 화면이 카드라 사진이 없으면 자리가 빈다.
(area_contents.body["firstimage"].astext != "").desc().nullslast(),
place_area_refs.distance_m.asc(),
)
)
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, nearby_q)
)
if err != ErrorType.SUCCESS:
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen, source=LocalSource.TOUR_API.value)
return {"region_code": region_code or None, "contents": contents}
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
"""행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다.
seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다."""
out = []
for row in rows:
content_type = int(row.content_type)
# 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다.
taken = seen.get(content_type, 0)
if taken >= _LOCAL_MAX_PER_TYPE:
continue
seen[content_type] = taken + 1
body = row.body if isinstance(row.body, dict) else {}
contents.append({
out.append({
"content_type": content_type,
"source": row.source,
"source": source if source is not None else row.source,
"title": row.title,
"body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP},
"collected_at": _iso(row.collected_at),
})
return {"region_code": region_code, "contents": contents}
return out
def _iso(value) -> str | None:
@ -250,7 +304,7 @@ def _iso(value) -> str | None:
async def _select(query) -> list:
err, rows = await DB_SESSION_MNG.execute_lambda(
facts.DBType(),
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query),
)

View File

@ -10,7 +10,7 @@
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import media, places
from common.database.model.models import place_photos, places
from common.enums import DBWRType, ErrorType, MediaStatus, PlaceCategory
from common.logger import LOG
from common.utils.gtime import GTime
@ -47,7 +47,7 @@ async def run_vision(job: dict) -> dict:
# force 가 아니면 아직 분석 안 된 사진만 — 같은 사진을 다시 태우면 요금만 나간다.
list_err, rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: _media_crud.list_media(s, uuid.UUID(place_id), unlabeled_only=not force),
)
@ -58,7 +58,7 @@ async def run_vision(job: dict) -> dict:
# 객실·메뉴 이름을 힌트로 준다 — 라벨이 units 와 같은 어휘로 나오면 매칭이 쉬워진다.
unit_err, unit_rows = await DB_SESSION_MNG.execute_lambda(
media.DBType(),
place_photos.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.list_units(s, uuid.UUID(place_id)),
)
@ -101,7 +101,7 @@ async def run_vision(job: dict) -> dict:
approved = not result.needs_review
status = MediaStatus.APPROVED.value if approved else MediaStatus.PENDING_REVIEW.value
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
media.DBType(),
place_photos.DBType(),
lambda s, r=result, st=status: _media_crud.apply_vision(
s, by_key[r.origin_url].media_id, r.label, r.alt_text, r.confidence, st, now
),

View File

@ -112,7 +112,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client
"""검증: 사장님이 문구를 고쳐 승인(CORRECTED)한 뒤 COPY 잡이 재생성을 돌린다.
기대결과: 고친 문구가 그대로 남는다 — ★ 자동 생성이 사람의 판단을 덮어쓰지 않는다."""
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import faqs
from common.database.model.models import place_faqs
from common.utils.gtime import GTime
from crud.faq_crud import FaqCRUD
@ -129,7 +129,7 @@ async def test_corrected_faq_is_locked_against_regeneration(auth_headers, client
# 재생성이 미확인 FAQ 를 내리는 단계(COPY 잡이 실제로 부르는 그 함수).
await DB_SESSION_MNG.execute_lambda_claim(
faqs.DBType(),
place_faqs.DBType(),
lambda s: FaqCRUD().expire_generated(s, uuid.UUID(pid), GTime.UTC()),
)
after = await _list(client, h, pid, publishable_only=True)

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /admin/

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://web4ai.o2osolution.ai/</loc>
<lastmod>2026-08-12T00:00:00+09:00</lastmod>
<priority>1.0</priority>
</url>
</urlset>

View File

@ -38,6 +38,15 @@ const LOOK = {
headingTracking: '0em',
headingWeight: '400',
sectionSpace: '4rem',
/**
* 갱지 결 — 가로 3px · 세로 4px 간격의 아주 옅은 줄 두 겹.
*
* ★ 이 칸이 비어 있어서 '옛 항구'를 골라도 면이 매끈했다. 타입(`TemplateLook.texture`)에도
* 있고 발행본이 심는 코드(`seo/head.ts`)도 있는데 **주는 쪽만 없었다** — 색과 서체는
* 레트로인데 종이가 아니라, 인쇄물이 아니라 '갈색 웹페이지'로 보였다.
*/
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)',
},
} as const;
@ -92,10 +101,11 @@ function templatesFor(
toneLabel: '레트로 감성',
description: retro.description,
colors: {
// 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2.
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
card: '#efe7d3',
text: '#1b1a15',
// 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다.
accent: '#bf2f1b',
@ -103,7 +113,16 @@ function templatesFor(
fontStyle: '옛 간판체',
look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
/**
* 이 템플릿이 데려오는 아이템.
*
* ★ 예전 값('course' · 'schedule')은 **렌더러에 없는 타입**이었다. 시안이 그 둘을
* itinerary · event 로 갈아치웠는데 여기만 남아서, '옛 항구'를 골라도 아이템이
* 하나도 안 붙었다(실측 2026-09-09).
* ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다 — '지역 이야기'(story) 탭이 데이터가
* 있는 것만 골라 그린다. 따로 붙이면 탭 밖에 한 번 더 선다.
*/
defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'],
},
];
}
@ -121,17 +140,23 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'instagram', name: '인스타그램', checked: true },
],
sections: [
// ★ 백엔드 기본 표(`site_payload._DEFAULT_THEME`)와 id·순서·이름·잠금이 1:1 이어야 한다.
// 어긋나면 에디터에서 본 섹션이 발행본에서 통째로 사라진다.
{ id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '상단 메인 비주얼과 대표 문구' },
{ id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스테이의 철학과 공간 스토리' },
{ id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
{ id: 'event', type: 'event', name: '소식', isLocked: false, isEnabled: true, description: '지금 하는 행사 · 공지' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' },
{ id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' },
{ id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' },
{ id: 'video', type: 'video', name: '영상', isLocked: false, isEnabled: true, description: '유튜브 주소 하나면 됩니다' },
{ id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' },
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
{ id: 'festival', type: 'festival', name: '계절별 축제', isLocked: false, isEnabled: true, description: '주변에서 열리는 축제 — 계절로 묶어 보여줍니다' },
{ id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
{ id: 'itinerary', type: 'itinerary', name: '추천 일정', isLocked: false, isEnabled: true, description: '숙소에서 출발하는 하루 코스' },
{ id: 'story', type: 'story', name: '지역 이야기', isLocked: false, isEnabled: true, description: '가요·인물·연표·엽서·퀴즈를 탭으로' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
],
templates: templatesFor('stay', '#2563eb', {
name: '옛 항구',

View File

@ -84,6 +84,9 @@ export function CanvasView() {
'--tpl-heading-weight': look.headingWeight,
'--tpl-section-space': look.sectionSpace,
'--tpl-border-width': look.borderWidth,
// 종이 결. 색·서체만으로는 '인쇄물'이 안 된다 — 갱지 면이 있어야 레트로가 레트로로 읽힌다.
// 없는 템플릿에서는 undefined 라 변수 자체가 안 걸리고, `.paper` 가 폴백(none)으로 떨어진다.
'--tpl-texture': look.texture,
// ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진
// rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다.

View File

@ -0,0 +1,275 @@
/**
* 붙여넣기 아이템의 **직접 입력** 폼.
*
* ★ 왜 만들었나
* 입구가 JSON 붙여넣기 하나뿐이었다. 한 글자만 고치려 해도 사장님이 중괄호와 쉼표를
* 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다. ChatGPT 를 안 쓰는
* 사장님은 아예 채울 수가 없었다.
*
* ★ 폼과 JSON 은 한 값의 두 얼굴이다
* 진실은 `section.data` **문자열 하나**뿐이고 이 폼은 그걸 비춘다. 그래서
* JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이 다시 쓰인다 —
* 어느 쪽이 최신인지 물을 일이 없다. 폼 상태를 따로 들고 있으면 그 순간
* "화면은 새 값, 저장은 옛 값"이 생긴다.
*
* ★ 깨진 JSON 은 폼으로 못 편다. 그때는 폼을 감추고 오류만 남긴다 —
* 반쯤 읽힌 값으로 폼을 그리면 사장님이 쓴 걸 덮어쓴다.
*/
import {Plus, Trash2} from 'lucide-react';
import {parseSectionData} from '@o2o/shared';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {cn} from '@/lib/utils';
import type {ItemField, SectionDataSpec} from './canvas/dataSpec';
type Row = Record<string, unknown>;
/** 'source.name' 처럼 점이 있는 키를 읽는다. */
function readAt(row: Row, key: string): unknown {
return key.split('.').reduce<unknown>((node, part) => {
if (node === null || typeof node !== 'object') return undefined;
return (node as Row)[part];
}, row);
}
/** 값을 쓴다. 빈 값이면 **키를 지운다** — 빈 문자열을 남기면 "확인 안 된 값"이 발행본에 나간다. */
function writeAt(row: Row, key: string, value: unknown): Row {
const [head, ...rest] = key.split('.');
const next = {...row};
if (rest.length === 0) {
if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
delete next[head];
} else {
next[head] = value;
}
return next;
}
const child = (typeof next[head] === 'object' && next[head] !== null ? next[head] : {}) as Row;
const written = writeAt(child, rest.join('.'), value);
if (Object.keys(written).length === 0) delete next[head];
else next[head] = written;
return next;
}
function toInput(field: ItemField, value: unknown): string {
if (value === undefined || value === null) return '';
if (field.type === 'tags') return Array.isArray(value) ? value.join(', ') : String(value);
if (field.key === 'turning') return value === true ? '예' : value === false ? '아니오' : '';
return String(value);
}
function fromInput(field: ItemField, text: string): unknown {
const trimmed = text.trim();
if (trimmed === '') return undefined;
if (field.type === 'tags') return trimmed.split(',').map((t) => t.trim()).filter(Boolean);
if (field.type === 'number') {
const n = Number(trimmed.replace(/[^0-9.-]/g, ''));
return Number.isFinite(n) ? n : undefined;
}
if (field.key === 'turning') return trimmed === '예' ? true : trimmed === '아니오' ? false : undefined;
return text;
}
function Field({
field,
row,
onChange,
}: {
field: ItemField;
row: Row;
onChange: (key: string, value: unknown) => void;
}) {
const value = toInput(field, readAt(row, field.key));
const set = (text: string) => onChange(field.key, fromInput(field, text));
return (
<label className={cn('block space-y-1', field.half ? 'sm:col-span-1' : 'col-span-2')}>
<span className="text-[10px] font-semibold text-muted-foreground">{field.label}</span>
{field.options ? (
<select
value={value}
onChange={(event) => set(event.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-[11px] outline-none focus:border-ring"
>
<option value="">—</option>
{field.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : field.type === 'area' ? (
<textarea
value={value}
onChange={(event) => set(event.target.value)}
rows={2}
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-[11px] leading-relaxed outline-none focus:border-ring"
/>
) : field.type === 'color' ? (
<span className="flex items-center gap-1.5">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value) ? value : '#888888'}
onChange={(event) => set(event.target.value)}
className="h-8 w-9 shrink-0 cursor-pointer rounded border border-input bg-background"
aria-label={field.label}
/>
<Input value={value} onChange={(event) => set(event.target.value)} className="h-8 text-[11px]" />
</span>
) : (
<Input
value={value}
inputMode={field.type === 'number' ? 'numeric' : undefined}
onChange={(event) => set(event.target.value)}
className="h-8 text-[11px]"
/>
)}
{field.hint && <span className="block text-[10px] text-muted-foreground">{field.hint}</span>}
</label>
);
}
export function ItemFormEditor({
spec,
raw,
onChange,
}: {
spec: SectionDataSpec;
raw: string;
onChange: (next: string) => void;
}) {
const parsed = parseSectionData<Row>(spec.kind, raw);
// 깨진 JSON 위에 폼을 그리지 않는다 — 반쯤 읽힌 값으로 덮어쓰면 사장님이 쓴 걸 잃는다.
if (parsed.error) return null;
const envelope = (() => {
try {
const value = JSON.parse(raw || '{}') as Record<string, unknown>;
return typeof value === 'object' && value !== null ? value : {};
} catch {
return {};
}
})();
const items = Array.isArray(envelope.items) ? (envelope.items as Row[]) : [];
/** 항목 배열을 다시 JSON 문자열로. 봉투(kind·title·subtitle)는 그대로 둔다. */
const commit = (next: Row[]) =>
onChange(JSON.stringify({...envelope, kind: spec.kind, items: next}, null, 2));
const patch = (index: number, key: string, value: unknown) =>
commit(items.map((row, i) => (i === index ? writeAt(row, key, value) : row)));
const patchChild = (index: number, childIndex: number, key: string, value: unknown) => {
const childKey = spec.child!.key;
commit(
items.map((row, i) => {
if (i !== index) return row;
const list = Array.isArray(row[childKey]) ? ([...(row[childKey] as Row[])]) : [];
list[childIndex] = writeAt(list[childIndex] ?? {}, key, value);
return {...row, [childKey]: list};
}),
);
};
return (
<div className="space-y-2">
{items.map((row, index) => (
<div key={index} className="space-y-2 rounded-md border border-border bg-background p-2.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-muted-foreground">
{spec.label} {index + 1}
</span>
<button
type="button"
onClick={() => commit(items.filter((_, i) => i !== index))}
className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-destructive"
aria-label={`${index + 1}번째 지우기`}
>
<Trash2 className="size-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{spec.fields.map((field) => (
<Field
key={field.key}
field={field}
row={row}
onChange={(key, value) => patch(index, key, value)}
/>
))}
</div>
{spec.child && (
<div className="space-y-1.5 border-t border-border pt-2">
<span className="text-[10px] font-bold text-muted-foreground">
{spec.child.label}
</span>
{(Array.isArray(row[spec.child.key]) ? (row[spec.child.key] as Row[]) : []).map(
(child, childIndex) => (
<div key={childIndex} className="rounded border border-border/70 bg-muted/30 p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{childIndex + 1}</span>
<button
type="button"
onClick={() =>
patch(
index,
spec.child!.key,
(row[spec.child!.key] as Row[]).filter((_, i) => i !== childIndex),
)
}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive"
aria-label={`${spec.child!.label} ${childIndex + 1} 지우기`}
>
<Trash2 className="size-3" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{spec.child!.fields.map((field) => (
<Field
key={field.key}
field={field}
row={child}
onChange={(key, value) => patchChild(index, childIndex, key, value)}
/>
))}
</div>
</div>
),
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 w-full gap-1 text-[10px]"
onClick={() =>
patch(index, spec.child!.key, [
...(Array.isArray(row[spec.child!.key]) ? (row[spec.child!.key] as Row[]) : []),
{},
])
}
>
<Plus className="size-3" />
{spec.child.label} 추가
</Button>
</div>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full gap-1 text-[11px]"
onClick={() => commit([...items, {}])}
>
<Plus className="size-3.5" />
{spec.label} 추가
</Button>
</div>
);
}

View File

@ -25,6 +25,7 @@ import {cn} from '@/lib/utils';
import {useBuilderStore, useUnverifiedFields, type RightTab} from '@/stores/builder';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {FaqPanel} from './FaqPanel';
import {ItemFormEditor} from './ItemFormEditor';
import {SectionDesignPanel} from './SectionDesignPanel';
import {resolveVariant} from './canvas/registry';
import {
@ -186,6 +187,13 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
const [copied, setCopied] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
/**
* 직접 입력 ↔ JSON.
*
* ★ 둘은 같은 값(`section.data`)의 두 얼굴이라 어느 쪽으로 고쳐도 다른 쪽이 따라온다.
* 기본은 **직접 입력**이다 — JSON 을 먼저 보여 주면 대부분의 사장님이 거기서 멈춘다.
*/
const [mode, setMode] = useState<'form' | 'json'>('form');
const section = sections.find((item) => item.id === sectionId);
if (!spec || !section) return null;
@ -225,10 +233,27 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-2.5">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3.5 text-primary" />
<span className="text-xs font-bold">{spec.label} 내용 (JSON)</span>
<span className="text-xs font-bold">{spec.label} 내용</span>
<span className="ml-auto flex rounded-md border border-border p-0.5">
{(['form', 'json'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setMode(id)}
aria-pressed={mode === id}
className={cn(
'rounded px-2 py-0.5 text-[10px] font-semibold transition-colors',
mode === id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground',
)}
>
{id === 'form' ? '직접 입력' : 'JSON'}
</button>
))}
</span>
</div>
<p className="text-[10px] leading-relaxed text-muted-foreground">
프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요.
칸을 채워도 되고, 프롬프트를 ChatGPT·Claude 에 붙여넣어 받은 JSON 을 넣어도 됩니다.
어느 쪽으로 고쳐도 다른 쪽이 따라 바뀝니다.
</p>
<div className="flex flex-wrap gap-1.5">
@ -283,14 +308,23 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
</div>
)}
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
{/* 깨진 JSON 위에는 폼을 그리지 않는다(ItemFormEditor 가 null 을 돌려준다) — 그때는 JSON 으로 고쳐야 한다. */}
{mode === 'form' && !parsed.error ? (
<ItemFormEditor
spec={spec}
raw={raw}
onChange={(next) => updateSectionData(sectionId, next)}
/>
) : (
<textarea
value={raw}
onChange={(event) => updateSectionData(sectionId, event.target.value)}
placeholder={`{ "kind": "${spec.kind}", "items": [ ... ] }`}
rows={10}
spellCheck={false}
className="w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 font-mono text-[11px] leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
/>
)}
{parsed.error ? (
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-destructive">

View File

@ -4,27 +4,128 @@
* 사장님은 코드 이름이 아니라 모양으로 고른다. 그래서 카드마다 와이어프레임을 붙이고,
* "언제 이걸 고르면 좋은지"를 한 줄로 적는다.
*/
import {Check, MousePointerClick, Palette, RotateCcw, Shapes} from 'lucide-react';
import {
Check,
ChevronDown,
LayoutTemplate,
MousePointerClick,
Palette,
RotateCcw,
Shapes,
} from 'lucide-react';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {COLOR_PALETTE_PRESETS} from './colorPalettes';
import {TemplatePreview} from './TemplatePreview';
import {resolveVariant, variantsFor} from './canvas/registry';
import {VariantThumb} from './canvas/thumbs';
function ColorPalettePicker() {
/**
* 접히는 묶음.
*
* ★ [디자인] 탭은 290px 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 +
* 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의
* 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다.
* ★ `<details>` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다.
*/
function Group({
title,
icon: Icon,
count,
open,
children,
}: {
title: string;
icon: typeof Palette;
count?: string;
open?: boolean;
children: React.ReactNode;
}) {
return (
<details open={open} className="group border-b border-border pb-3">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-xs font-bold marker:content-none [&::-webkit-details-marker]:hidden">
<Icon className="size-3.5" />
<span>{title}</span>
{count && <span className="ml-auto font-mono text-[10px] text-muted-foreground">{count}</span>}
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="mt-2 space-y-2">{children}</div>
</details>
);
}
/**
* 템플릿 고르기.
*
* ★ 이 자리가 없었다. 템플릿은 온보딩 4단계에서 한 번 고르면 끝이었고, 에디터의 [디자인] 탭에는
* 팔레트와 섹션 배리에이션만 있었다 — 사장님은 **디자인을 바꾸러 들어와서 디자인을 못 바꿨다.**
* 스토어의 `selectTemplate` 과 서버 저장(`queueSiteTemplateSave`)은 처음부터 있었고 UI 만 없었다.
* ★ 미리보기는 위저드와 **같은 컴포넌트**다(TemplatePreview). 두 벌로 그리면 고를 때 본 것과
* 에디터에서 본 것이 갈린다.
*/
function TemplatePicker({open}: {open?: boolean}) {
const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const templates = INDUSTRY_CONFIGS[industry].templates;
/**
* ★ 저장된 templateId 가 지금 목록에 **없을 수 있다.** 실제로 있었다 —
* `stay-warm-wood` 처럼 예전 이름이 sites.template_id 에 남아 있으면
* `resolveTemplate` 은 말없이 첫 템플릿으로 떨어지는데, 이 목록에서는 아무것도
* 선택돼 보이지 않아 "고를 수 없는 화면"이 된다. 떨어지는 자리를 여기서도 같게 본다.
*/
const activeId = templates.some((t) => t.id === templateId) ? templateId : templates[0].id;
return (
<Group title="템플릿" icon={LayoutTemplate} count={`${templates.length}종`} open={open}>
<div className="space-y-2">
{templates.map((template) => {
const isActive = activeId === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => {
selectTemplate(template.id);
// 고른 순간 서버에도 남긴다 — 저장 안 하면 새로고침 한 번에 되돌아간다.
queueSiteTemplateSave(placeId, template.id);
}}
aria-pressed={isActive}
className={cn(
'block w-full overflow-hidden rounded-lg border p-2 text-left transition-all',
isActive
? 'border-primary ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50',
)}
>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold">{template.name}</span>
<span className="truncate text-[10px] text-muted-foreground">
{template.toneLabel} · {template.fontStyle}
</span>
{isActive && <Check className="ml-auto size-3 shrink-0 text-primary" />}
</span>
{/* 미리보기는 그 템플릿의 서체·모서리·그림자로 실제로 그린다 — 색 동그라미로는 뭘 고르는지 모른다. */}
<TemplatePreview template={template} />
</button>
);
})}
</div>
</Group>
);
}
function ColorPalettePicker({open}: {open?: boolean}) {
const industry = useBuilderStore((s) => s.industry);
const selectedId = useBuilderStore((s) => s.colorPaletteId);
const selectColorPalette = useBuilderStore((s) => s.selectColorPalette);
const palettes = COLOR_PALETTE_PRESETS.filter((palette) => palette.industry === industry);
return (
<section className="space-y-2 border-b border-border pb-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-bold">
<Palette className="size-3.5" /> 컬러 시스템
</span>
<span className="font-mono text-[10px] text-muted-foreground">{palettes.length}종</span>
</div>
<Group title="컬러 시스템" icon={Palette} count={`${palettes.length}종`} open={open}>
<div className="grid grid-cols-2 gap-2">
{palettes.map((palette) => {
const isActive = selectedId === palette.id;
@ -61,7 +162,7 @@ function ColorPalettePicker() {
템플릿 기본 색상으로 되돌리기
</button>
)}
</section>
</Group>
);
}
@ -79,7 +180,8 @@ export function SectionDesignPanel() {
if (!section) {
return (
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
<ColorPalettePicker />
<TemplatePicker open />
<ColorPalettePicker open />
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground">
<MousePointerClick className="size-4" />
@ -96,6 +198,8 @@ export function SectionDesignPanel() {
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
{/* 큰 것부터 좁혀 간다 — 템플릿(전체) → 팔레트(색) → 이 섹션의 레이아웃. */}
<TemplatePicker />
<ColorPalettePicker />
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-bold">

View File

@ -0,0 +1,73 @@
import type {TemplateItem} from '@o2o/shared';
/**
* 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
*
* ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
* 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
*/
export function TemplatePreview({template}: {template: TemplateItem}) {
const {look, colors} = template;
return (
<div
className="my-3 overflow-hidden p-3.5"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderRadius: look.radius,
border: `${look.borderWidth} solid ${colors.secondary}22`,
}}
>
<p
className="text-[9px] uppercase"
style={{color: colors.accent, letterSpacing: '0.22em', fontFamily: look.fontBody}}
>
Section
</p>
<p
className="mt-1.5 text-base leading-tight"
style={{
fontFamily: look.fontHeading,
fontWeight: look.headingWeight,
letterSpacing: look.headingTracking,
}}
>
오래 머무는 자리
</p>
<p
className="mt-1.5 text-[10px] leading-relaxed"
style={{fontFamily: look.fontBody, color: colors.secondary}}
>
제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
</p>
<div
className="mt-3 p-2"
style={{
backgroundColor: colors.card,
borderRadius: `calc(${look.radius} * 0.67)`,
border: `${look.borderWidth} solid ${colors.secondary}22`,
boxShadow: look.shadow,
}}
>
<span className="text-[9px]" style={{fontFamily: look.fontBody, color: colors.secondary}}>
카드 · 모서리 {look.radius === '0px' ? '각짐' : '둥금'} · 그림자{' '}
{look.shadow === 'none' ? '없음' : '있음'}
</span>
</div>
<span
className="mt-2.5 inline-block px-2.5 py-1 text-[9px] font-semibold"
style={{
backgroundColor: colors.accent,
color: colors.bg,
borderRadius: `calc(${look.radius} * 0.5)`,
fontFamily: look.fontBody,
}}
>
예약 문의
</span>
</div>
);
}

View File

@ -18,8 +18,46 @@ export interface SectionDataSpec {
task: string;
/** 그 아이템에만 걸리는 금지·형식 규칙. */
rules: string;
/**
* 직접 입력 폼의 칸.
*
* ★ 왜 필요한가: JSON 붙여넣기가 유일한 입구였다. 한 글자만 고치고 싶어도 사장님이
* 중괄호와 쉼표를 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다.
* ChatGPT 를 안 쓰는 사장님은 아예 못 채운다.
* ★ 폼과 JSON 은 **한 값의 두 얼굴**이다 — 진실은 `section.data` 문자열 하나뿐이고
* 폼은 그걸 비춘다. 그래서 JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이
* 다시 쓰인다. 어느 쪽이 최신인지 물을 일이 없다.
*/
fields: ItemField[];
/**
* 항목 안의 목록(정거장 · 시간대 · 들르는 곳).
*
* ★ 이게 없으면 코스·스케줄은 폼으로 못 채운다 — 그 아이템의 알맹이가 전부 이 배열에 있다.
*/
child?: {key: string; label: string; fields: ItemField[]};
}
export interface ItemField {
/** 항목 객체의 키. `@o2o/shared` 의 아이템 타입에 있는 이름 그대로다. */
key: string;
label: string;
/** text = 한 줄 · area = 여러 줄 · number = 숫자 · tags = 쉼표로 끊는 배열 · color = 색 */
type?: 'text' | 'area' | 'number' | 'tags' | 'color';
/** 칸 아래 회색 한 줄. 무엇을 적는 칸인지 애매할 때만 적는다. */
hint?: string;
/** 고르는 값이면 목록. 계절처럼 오탈자가 곧 버그가 되는 칸에 쓴다. */
options?: string[];
/** 두 칸씩 나란히 놓는다 — 짧은 값(연도·시각·분)이 한 줄을 다 먹지 않게. */
half?: boolean;
}
/** 모든 아이템이 공유하는 끝 두 칸 — 출처와 확신. */
const SOURCE_FIELDS: ItemField[] = [
{key: 'verified', label: '확신', type: 'text', options: ['확인', '확인필요'], half: true},
{key: 'source.name', label: '출처 이름', type: 'text', half: true},
{key: 'source.url', label: '출처 주소', type: 'text', hint: '실제로 열리는 공식·기관·언론 페이지'},
];
// ★ 테마 전체 상한이 64KB 다(site_service._THEME_MAX_BYTES). 세 아이템이 각자 다 채우면 거절당하고,
// 거절은 발행 직전에야 드러난다. 그래서 한 섹션당 여기서 먼저 끊는다.
export const SECTION_DATA_MAX_CHARS = 12000;
@ -142,6 +180,18 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.`,
fields: [
{key: 'title', label: '곡 제목'},
{key: 'artist', label: '가수', half: true},
{key: 'year', label: '발표 연도', type: 'number', half: true},
{key: 'lyricist', label: '작사', half: true},
{key: 'composer', label: '작곡', half: true},
{key: 'label', label: '음반사', half: true},
{key: 'labelColor', label: '라벨 색', type: 'color', half: true},
{key: 'story', label: '곡 이야기', type: 'area', hint: '가사는 옮기지 않습니다 — 배경만'},
{key: 'connection', label: '우리 가게와의 연결', type: 'area'},
...SOURCE_FIELDS,
],
},
daily: {
@ -203,102 +253,17 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.
· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.
· 같은 장소를 계절만 바꿔 반복하지 않는다.`,
fields: [
{key: 'monthDay', label: '날짜', hint: 'MM-DD. 연도는 넣지 않습니다', half: true},
{key: 'category', label: '분류', half: true},
{key: 'title', label: '제목'},
{key: 'body', label: '내용', type: 'area', hint: '두 문장 이내'},
{key: 'season', label: '계절', options: ['봄', '여름', '가을', '겨울'], half: true},
{key: 'tags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다', half: true},
...SOURCE_FIELDS,
],
},
course: {
kind: 'course',
label: '반나절 산책',
sample: JSON.stringify(
{
kind: 'course',
version: 1,
title: '반나절 산책',
items: [
{
name: '탁류길 코스',
duration: '3시간',
startsFrom: '스테이 머뭄',
stops: [
{order: 1, name: '채만식문학관', minutes: 12, note: '금강 하구 옆. 소설의 배경을 먼저 눈에 담습니다.', searchQuery: '군산 채만식문학관'},
{order: 2, name: '군산 내항 부잔교', minutes: 9, note: '조수에 오르내리던 뜬다리. 이 코스의 중심입니다.', searchQuery: '군산 내항 부잔교'},
{order: 3, name: '째보선창', minutes: 7, note: '항구 노동의 기억이 남은 선창입니다.', searchQuery: '군산 째보선창'},
{order: 4, name: '초원사진관', minutes: 11, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{order: 5, name: '이성당', minutes: 6, note: '코스의 끝. 단팥빵 하나로 반나절을 닫습니다.', searchQuery: '군산 이성당'},
],
verified: '확인',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에서 출발하는 반나절 산책 코스를 2~3개 만든다. 코스마다 정거장 4~6곳.
걸어서 이동할 수 있는 순서로 배열하고, 앞 정거장에서 다음까지 걸리는 분을 minutes 에 적는다.
[스키마]
{ "kind":"course", "version":1, "title":"반나절 산책", "items":[
{ "name":"코스 이름", "duration":"3시간", "startsFrom":"[업소]",
"stops":[ {"order":1,"name":"장소","minutes":12,
"note":"한 문장","searchQuery":"검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 정거장에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
},
schedule: {
kind: 'schedule',
label: '여행 스케줄',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
[스키마]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
people: {
kind: 'people',
label: '인물 열전',
@ -354,6 +319,15 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`,
fields: [
{key: 'name', label: '이름'},
{key: 'aka', label: '호 · 예명', half: true},
{key: 'years', label: '생몰년', half: true},
{key: 'role', label: '역할'},
{key: 'oneLine', label: '한 줄 소개', type: 'area'},
{key: 'imageQuery', label: '사진 검색어', hint: '사진 주소는 받지 않습니다 — 검색어만'},
...SOURCE_FIELDS,
],
},
chronicle: {
@ -420,72 +394,14 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`,
},
literature: {
kind: 'literature',
label: '문학 서가',
sample: JSON.stringify(
{
kind: 'literature',
version: 1,
title: '문학 서가',
subtitle: '이 도시를 쓴 책들',
items: [
{
workTitle: '탁류',
author: '채만식',
year: '1937–1938 연재',
genre: '장편소설',
spineColor: '#7d3320',
background: '일제강점기 군산을 무대로 한 여자의 삶과 도시의 경제를 겹쳐 놓았습니다.',
whyHere: '소설 속 거리를 따라 걸으면 지금의 내항이 겹쳐 보입니다.',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '레디메이드 인생',
author: '채만식',
year: '1934',
genre: '단편소설',
spineColor: '#2a4a6b',
background: '배운 사람이 일자리를 얻지 못하는 식민지 도시의 앞뒤를 풍자로 잘라 보입니다.',
whyHere: '같은 작가의 문학관이 금강 하구 옆에 서 있습니다.',
verified: '확인필요',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '아리랑',
author: '조정래',
year: '대하소설',
genre: '장편소설',
spineColor: '#3f5c33',
background: '이 지역이 겪은 시간을 인물들의 이동으로 따라갑니다.',
whyHere: '스탬프투어 아리랑길이 소설과 같은 자리를 지납니다.',
verified: '확인필요',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]을 배경으로 하거나 [지역]에서 쓰인 문학 작품을 8편까지 찾는다.
소설·시·수필을 고루 섞고, 작품이 그 지역과 어떻게 닿는지 한 문장으로 적는다.
[스키마]
{ "kind":"literature", "version":1, "title":"문학 서가", "items":[
{ "workTitle":"작품명", "author":"작가", "year":"1937", "genre":"장편소설",
"spineColor":"#7d3320", "background":"작품 배경 두 문장 이내",
"whyHere":"[지역]과 잇는 한 문장",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 작품 원문(구절·시행·문장)을 옮기지 않는다. 줄거리와 배경만 쓴다.
· 저작권이 살아 있는 작품일수록 짧게 쓴다.
· spineColor 는 책등 색이다. 작품 분위기에 맞는 어두운 색 하나를 hex 로 고른다.`,
fields: [
{key: 'year', label: '연도', type: 'number', half: true},
{key: 'place', label: '지금 이 자리', half: true},
{key: 'title', label: '무슨 일'},
{key: 'summary', label: '한 줄 설명', type: 'area'},
{key: 'turning', label: '전환점', options: ['예', '아니오'], hint: '붉은 점으로 표시됩니다'},
...SOURCE_FIELDS,
],
},
postcard: {
@ -542,6 +458,13 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`,
fields: [
{key: 'line', label: '엽서 문장', type: 'area'},
{key: 'place', label: '장소', half: true},
{key: 'postmark', label: '소인 지명', half: true},
{key: 'hashtags', label: '해시태그', type: 'tags', hint: '쉼표로 끊습니다 · 3개까지'},
...SOURCE_FIELDS,
],
},
quiz: {
@ -598,95 +521,154 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.
· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.
· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`,
fields: [
{key: 'question', label: '질문', type: 'area'},
{key: 'hint', label: '힌트', type: 'area', hint: '정답은 두지 않습니다'},
{key: 'topic', label: '주제', half: true},
{key: 'level', label: '난이도', half: true},
...SOURCE_FIELDS,
],
},
planner: {
kind: 'planner',
label: '계절별 추천 하루',
video: {
kind: 'video',
label: '영상',
sample: JSON.stringify(
{
kind: 'planner',
kind: 'video',
version: 1,
title: '계절별 추천 하루',
subtitle: '계절을 고르면 시각까지 짜 드립니다',
title: '영상으로 보기',
subtitle: '사진으로 다 안 보이는 것',
items: [
{
name: '개항장 골목 한 바퀴',
season: '가을',
rank: 1,
startTime: '09:30',
audience: '처음 온 손님',
why: '해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 15, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관'},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당'},
{name: '경암동 철길마을', minutes: 60, moveMinutes: 20, note: '해 질 무렵 빛이 가장 좋습니다.', searchQuery: '군산 경암동 철길마을'},
],
url: 'https://youtube.com/shorts/c2ZdwhaB7S4',
caption: '마당에서 본 저녁 무렵',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬으로 건너가는 날',
season: '가을',
rank: 2,
startTime: '10:00',
audience: '차를 가져온 손님',
why: '바람이 차기 전 마지막으로 바다가 열리는 계절입니다.',
stops: [
{name: '고군산군도 선유도', minutes: 150, moveMinutes: 50, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도'},
{name: '장자도', minutes: 60, moveMinutes: 15, note: '섬과 섬 사이를 걸어 넘습니다.', searchQuery: '군산 장자도'},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
{
name: '비 오면 여기로',
season: '여름',
rank: 1,
startTime: '10:30',
audience: '혼자 온 손님',
why: '장마철에 실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.',
stops: [
{name: '군산근대건축관', minutes: 60, moveMinutes: 12, note: '옛 은행 건물 안이 통째로 전시입니다.', searchQuery: '군산근대건축관'},
{name: '한일옥', minutes: 60, moveMinutes: 8, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{name: '째보선창 근처 카페', minutes: 90, moveMinutes: 10, note: '비 그칠 때까지 앉아 있기 좋습니다.', searchQuery: '군산 째보선창 카페'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵는 손님을 위한 [지역] 하루 코스를 **계절마다 1~3위**로 추천한다.
계절 넷(봄·여름·가을·겨울)을 모두 채우고, 계절마다 성격이 다른 코스 세 개를 1·2·3위로 매긴다.
코스마다 정거장 3~5곳을 **도는 순서대로** 적는다.
task: `
[해야 할 일]
[업소]의 유튜브 영상 주소를 1~3개 고른다.
★ 시각은 적지 않는다. 출발 시각(startTime)과 각 정거장의 머무는 시간(minutes)·이동 시간(moveMinutes)만
적으면 화면이 시각을 계산해 채운다.
[스키마]
{ "kind":"planner", "version":1, "title":"계절별 추천 하루", "items":[
{ "name":"코스 이름", "season":"봄|여름|가을|겨울", "rank":1,
"startTime":"09:30", "audience":"누구에게 맞는 하루",
"why":"왜 이 계절에 이 코스인가 (한 문장)",
"stops":[ {"name":"장소","minutes":90,"moveMinutes":15,
"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
· 이미 올라가 있는 영상의 **주소만** 옮긴다. 영상을 만들라는 것이 아니다.
· 손님이 사진으로는 알 수 없는 것을 보여주는 영상이 좋다 — 공간의 크기, 빛, 소리, 가는 길.`,
rules: `
[이 아이템만의 규칙]
· rank 는 계절마다 1·2·3 만 쓴다. 4위는 만들지 않는다 — 아무도 안 고른다.
· 같은 계절의 세 코스가 서로 달라야 한다. 정거장이 겹치면 순위가 아니라 중복이다.
· moveMinutes 는 앞 정거장에서 오는 시간이다. 첫 정거장은 [업소]에서 나서는 시간이다.
· 걸어서 못 가는 곳은 note 에 "차로 이동" 이라고 적는다.
· 영업시간·휴무일·요금은 넣지 않는다. 바뀌면 짜 준 일정이 손님을 헛걸음시킨다.
· 장소에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.`,
· url 은 유튜브만 받는다. watch · youtu.be · shorts 어느 형식이든 된다.
· 다른 사이트 영상 주소를 넣으면 재생되지 않고 링크로만 남는다.
· caption 은 한 줄이다. 영상 제목을 그대로 옮기지 않는다 — 왜 볼 만한지를 적는다.
· 남의 영상을 넣지 않는다. 업소가 올렸거나 업소를 찍은 영상만.`,
fields: [
{key: 'url', label: '유튜브 주소', hint: 'watch · youtu.be · shorts 다 됩니다'},
{key: 'caption', label: '한 줄 설명', hint: '제목 말고 왜 볼 만한지'},
...SOURCE_FIELDS,
],
},
itinerary: {
kind: 'itinerary',
label: '추천 일정',
sample: JSON.stringify(
{
kind: 'itinerary',
version: 1,
title: '추천 일정',
subtitle: '며칠 묵느냐에 따라',
items: [
{
name: '개항장 골목 한 바퀴',
duration: '반나절',
audience: '처음 온 손님',
why: '걷는 거리가 가장 짧고, 군산이 어떤 도시였는지 먼저 보게 됩니다.',
startTime: '09:30',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 12, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관', latitude: 35.9908197098, longitude: 126.7121231556},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관', latitude: 35.9877369536, longitude: 126.7083826065},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당', latitude: 35.9870443303, longitude: 126.7111681428},
],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬까지 다녀오는 1박 2일',
duration: '1박 2일',
audience: '차를 가져온 손님',
why: '첫날은 도심, 이튿날은 바다. 하루씩 성격이 다릅니다.',
days: [
{
label: '첫째 날',
startTime: '14:00',
stops: [
{name: '경암동 철길마을', minutes: 40, moveMinutes: 15, searchQuery: '군산 경암동 철길마을', latitude: 35.9813520474, longitude: 126.7362330582},
{name: '째보선창', minutes: 40, moveMinutes: 12, note: '해 지는 시간에 맞추면 좋습니다.', searchQuery: '군산 째보선창', latitude: 35.9875345, longitude: 126.7199875},
],
},
{
label: '둘째 날',
startTime: '09:30',
stops: [
{name: '고군산군도 선유도', minutes: 180, moveMinutes: 45, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도', latitude: 35.8102049733, longitude: 126.4124052922},
{name: '장자도', minutes: 60, moveMinutes: 12, searchQuery: '군산 장자도', latitude: 35.8101, longitude: 126.3973},
],
},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `
[해야 할 일]
[업소]에서 출발하는 추천 일정을 3~5개 만든다. **반나절 · 1박 2일 · 2박 3일** 을 섞는다.
· 하루짜리는 stops 만 쓴다. 여러 날이면 days 로 날짜를 나눈다.
· 정거장은 하루에 3~5곳. 여섯 곳부터는 아무도 그대로 못 돈다.
· minutes 는 거기서 머무는 시간, moveMinutes 는 앞 칸에서 오는 데 걸리는 시간이다.
· 첫 정거장의 moveMinutes 는 **업소에서 나서는 시간**이다.`,
rules: `
[이 아이템만의 규칙]
· 순위를 매기지 않는다. rank 같은 칸은 없다 — 어느 일정이 1위인지는 정할 일이 아니다.
· duration 은 "반나절" · "1박 2일" · "2박 3일" 중 하나로 적는다. 목록이 이 값으로 갈린다.
· season 은 **꼭 필요할 때만** 적는다. 적으면 그 계절에만 손님 화면에 나간다
(벚꽃·해수욕처럼 계절을 타는 일정에만).
· 링크를 만들지 않는다. searchQuery 에 지도 검색어만 적는다.
· latitude · longitude 는 **아는 곳만** 적는다. 지도에 번호 핀이 찍히는 값이다 —
지어내면 엉뚱한 동네에 핀이 찍히고, 비워 두면 그 칸은 시간표에만 선다(그게 낫다).
· 밤 9시를 넘기는 칸은 화면에서 빠진다 — 시각을 계산해 보고 넣는다.`,
fields: [
{key: 'name', label: '일정 이름'},
{key: 'duration', label: '기간', options: ['반나절', '1박 2일', '2박 3일'], half: true},
{key: 'startTime', label: '출발 시각', hint: 'HH:MM · 하루짜리만', half: true},
{key: 'audience', label: '누구에게 맞는 일정', half: true},
{
key: 'season',
label: '계절',
half: true,
options: ['봄', '여름', '가을', '겨울'],
hint: '적으면 그 계절에만 나갑니다',
},
{key: 'why', label: '왜 이 일정인가', type: 'area'},
...SOURCE_FIELDS,
],
child: {
key: 'stops',
label: '정거장',
fields: [
{key: 'name', label: '장소'},
{key: 'minutes', label: '머무는 시간(분)', type: 'number', half: true},
{key: 'moveMinutes', label: '오는 데 걸리는 시간(분)', type: 'number', half: true},
{key: 'searchQuery', label: '지도 검색어'},
{key: 'note', label: '한 줄', type: 'area'},
],
},
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -0,0 +1,205 @@
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowUpRight, ChevronLeft, ChevronRight} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import type {GuideCard} from '../variants/local/types';
import {walkMinutes} from '../variants/local/walking';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
/**
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
*
* ★ 사진이 없으면 회색 판에 이름을 크게 쓴다(스크린샷의 '군산복집' 카드). 자리를 비우거나
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={naverSearch(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="group flex w-[240px] shrink-0 snap-start flex-col overflow-hidden rounded-lg border border-stone-200/80 bg-white shadow-2xs transition-shadow hover:shadow-md sm:w-[260px]"
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-stone-200/70">
{card.imageUrl ? (
<img
src={card.imageUrl}
alt={card.name}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="flex size-full items-center justify-center px-4 text-center">
<span className="serif-title text-base font-bold text-stone-700">{card.name}</span>
</div>
)}
{minutes !== undefined && (
<span className="absolute bottom-2.5 left-2.5 inline-flex items-center gap-1.5 rounded bg-stone-900/80 px-2 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
<span>도보 약 {minutes}분</span>
{card.distanceText && <span className="font-mono font-normal text-white/75">{card.distanceText}</span>}
</span>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3.5">
{leading}
<h4 className="text-sm font-bold text-stone-900" style={{color: colors.text}}>
{card.name}
</h4>
{card.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-stone-500">{card.description}</p>
)}
<span className="mt-auto flex items-center gap-0.5 pt-1 text-[11px] font-medium text-stone-400 group-hover:text-stone-900">
검색으로 열기
<ArrowUpRight className="size-3" />
</span>
</div>
</a>
);
}
/**
* 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "1 / N".
*
* ★ 페이지 = 한 화면에 온전히 들어가는 카드 수. 미리보기 해상도(PC/태블릿/모바일)가 바뀌면
* ResizeObserver 가 다시 센다 — 고정 4장으로 두면 모바일에서 점이 카드 수와 어긋난다.
* ★ 화살표는 컨테이너 폭만큼 넘긴다(한 페이지). 한 장씩 넘기면 24장에 화살표 23번이다.
*/
export function PlaceCarousel({
cards,
colors,
renderLeading,
}: {
cards: GuideCard[];
colors: TemplateItem['colors'];
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
renderLeading?: (card: GuideCard) => ReactNode;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [perPage, setPerPage] = useState(1);
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(cards.length / perPage));
// 카드 한 장 폭(gap 포함)으로 한 화면에 몇 장 들어가는지 잰다.
const measure = useCallback(() => {
const track = trackRef.current;
const first = track?.firstElementChild as HTMLElement | null;
if (!track || !first) return;
const gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0;
const step = first.offsetWidth + gap;
const fit = Math.max(1, Math.floor((track.clientWidth + gap) / step));
setPerPage(fit);
setPage(Math.min(Math.round(track.scrollLeft / (step * fit)), Math.max(0, Math.ceil(cards.length / fit) - 1)));
}, [cards.length]);
useEffect(() => {
measure();
const track = trackRef.current;
if (!track) return;
const ro = new ResizeObserver(measure);
ro.observe(track);
return () => ro.disconnect();
}, [measure]);
// 필터가 바뀌어 카드가 줄면 첫 페이지로.
useEffect(() => {
trackRef.current?.scrollTo({left: 0});
setPage(0);
}, [cards]);
const scrollToPage = (next: number) => {
const track = trackRef.current;
if (!track) return;
const clamped = Math.max(0, Math.min(pages - 1, next));
track.scrollTo({left: clamped * track.clientWidth, behavior: 'smooth'});
setPage(clamped);
};
const onScroll = () => {
const track = trackRef.current;
if (!track || track.clientWidth === 0) return;
setPage(Math.max(0, Math.min(pages - 1, Math.round(track.scrollLeft / track.clientWidth))));
};
const arrow =
'flex size-9 cursor-pointer items-center justify-center rounded-full border transition-colors disabled:cursor-default disabled:opacity-30';
return (
<div className="space-y-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
aria-label="이전"
disabled={page === 0}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page - 1);
}}
className={cn(arrow, 'border-stone-300/70 bg-white/70 text-stone-500 hover:bg-white')}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
aria-label="다음"
disabled={page >= pages - 1}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page + 1);
}}
style={{borderColor: colors.text, color: colors.text}}
className={cn(arrow, 'bg-white hover:bg-stone-50')}
>
<ChevronRight className="size-4" />
</button>
</div>
<div
ref={trackRef}
onScroll={onScroll}
className="scrollbar-none flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1"
>
{cards.map((card) => (
<GuideCardView
key={`${card.name}-${card.distanceMeters ?? ''}`}
card={card}
colors={colors}
leading={renderLeading?.(card)}
/>
))}
</div>
{pages > 1 && (
<div className="flex items-center justify-center gap-3">
<div className="flex items-center gap-1.5">
{Array.from({length: pages}, (_, i) => (
<button
key={i}
type="button"
aria-label={`${i + 1}페이지`}
onClick={(e) => {
e.stopPropagation();
scrollToPage(i);
}}
className={cn(
'h-1.5 cursor-pointer rounded-full transition-all',
i === page ? 'w-5' : 'w-1.5 bg-stone-300 hover:bg-stone-400',
)}
style={i === page ? {backgroundColor: colors.text} : undefined}
/>
))}
</div>
<span className="font-mono text-[11px] text-stone-400">
{page + 1} / {pages}
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,234 @@
/**
* 캔버스의 가로 슬라이더.
*
* ★ 왜 라이브러리인가 (2026-09-02)
* 예전에는 `overflow-x: auto` 상자 + 화살표 버튼 두 개였다. 화살표는 `scrollBy` 를 불러
* 한 화면의 80%씩 밀었는데, **카드를 잡아끄는 방법이 없었다** — 휠 마우스 사용자는
* 화살표를 찾지 못하면 옆에 더 있다는 걸 몰랐고, 트랙패드에서만 자연스러웠다.
* embla-carousel 은 의존성 0, 코어 ~5KB(gzip)이고 발행본(solution/site)이 쓰는 것과 같다 —
* 미리보기와 발행본의 조작감이 갈리면 미리보기가 아니다.
*
* ★ 캔버스 전용 사정: 카드 안에 링크·버튼이 있다. 드래그를 시작한 뒤의 클릭은 삼킨다
* (embla 의 `pointerUp` 뒤 click 은 막지 않으면 카드가 눌린 것으로 처리된다).
*/
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import {ChevronLeft, ChevronRight} from 'lucide-react';
import {ITEM_BORDER, ITEM_CARD} from '../variants/items/common';
export function Rail({
label,
children,
tone = 'light',
gap = 1,
className,
nav = 'above',
viewportClassName,
trackClassName = 'items-start',
}: {
/** 스크린리더가 읽을 이름. 화살표의 aria-label 도 여기서 만든다. */
label: string;
children: ReactNode;
tone?: 'light' | 'dark';
/** 슬라이드 사이 간격(rem). */
gap?: number;
className?: string;
/**
* 화살표를 어디에 둘지. 슬라이더가 액자(필름·책장) 안에 들어가는 아이템은
* 'below' 로 액자 밖에 세운다 — 액자 안에 넣으면 질감 위에 버튼이 떠서 물건처럼 안 보인다.
*/
nav?: 'above' | 'below' | 'overlay' | 'none';
/** 창(viewport)에 붙일 클래스. 액자 안쪽 여백처럼 슬라이더 자체가 가져야 하는 값. */
viewportClassName?: string;
/** 트랙에 붙일 클래스. 세로 정렬(items-end 같은)이 아이템마다 다르다. */
trackClassName?: string;
}) {
const [emblaRef, embla] = useEmblaCarousel({align: 'start', containScroll: 'trimSnaps'});
const [snaps, setSnaps] = useState<number[]>([]);
const [selected, setSelected] = useState(0);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const viewport = useRef<HTMLDivElement | null>(null);
const dragged = useRef(false);
useEffect(() => {
if (!embla) return;
const sync = () => {
setSelected(embla.selectedScrollSnap());
setCanPrev(embla.canScrollPrev());
setCanNext(embla.canScrollNext());
};
const measure = () => {
setSnaps(embla.scrollSnapList());
sync();
};
measure();
embla.on('select', sync);
embla.on('reInit', measure);
viewport.current?.setAttribute('data-slider', 'on');
return () => {
embla.off('select', sync);
embla.off('reInit', measure);
};
}, [embla]);
// 끌고 나서 손을 떼면 그 자리의 카드가 클릭된 것으로 처리된다 — 한 번만 삼킨다.
useEffect(() => {
if (!embla) return;
const down = () => {
dragged.current = false;
viewport.current?.setAttribute('data-dragging', 'true');
};
const move = () => {
dragged.current = true;
};
const up = () => viewport.current?.removeAttribute('data-dragging');
embla.on('pointerDown', down);
embla.on('scroll', move);
embla.on('pointerUp', up);
return () => {
embla.off('pointerDown', down);
embla.off('scroll', move);
embla.off('pointerUp', up);
};
}, [embla]);
const prev = useCallback(() => embla?.scrollPrev(), [embla]);
const next = useCallback(() => embla?.scrollNext(), [embla]);
// 한 화면에 다 들어가면 조작부를 그리지 않는다 — 눌러도 안 움직이는 버튼은 고장으로 읽힌다.
const movable = snaps.length > 1;
// 액자(필름·책장) 안에 든 슬라이더는 화살표를 창 위에 겹친다 — 액자 밖에 두면
// 어느 줄을 미는 버튼인지 알 수 없고, 액자 안에 한 줄 더 두면 질감이 끊긴다.
const overlay = movable && nav === 'overlay' && (
<>
<EdgeButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<EdgeButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</>
);
const controls = movable && (nav === 'above' || nav === 'below') && (
<div className={`flex items-center justify-end gap-1.5 ${nav === 'above' ? 'mb-2' : 'mt-2'}`}>
<span className="mr-auto text-[11px] tabular-nums opacity-50">
{selected + 1} / {snaps.length}
</span>
<NavButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<NavButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</div>
);
return (
<div className={`${nav === 'overlay' ? 'relative' : ''} ${className ?? ''}`}>
{nav === 'above' && controls}
<div
ref={(node) => {
viewport.current = node;
emblaRef(node);
}}
className={`w4-scroll ${viewportClassName ?? ''}`}
role="group"
aria-roledescription="캐러셀"
aria-label={label}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
prev();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
next();
}
}}
onClickCapture={(event) => {
if (!dragged.current) return;
event.preventDefault();
event.stopPropagation();
dragged.current = false;
}}
>
<div className={`flex ${trackClassName}`} style={{gap: `${gap}rem`}}>
{children}
</div>
</div>
{nav === 'below' && controls}
{overlay}
</div>
);
}
function EdgeButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full backdrop-blur transition-opacity hover:opacity-80 disabled:opacity-0 ${
dir === 'prev' ? 'left-1.5' : 'right-1.5'
}`}
style={{
backgroundColor: dark
? 'color-mix(in oklab, currentColor 22%, transparent)'
: 'color-mix(in oklab, currentColor 12%, transparent)',
}}
>
<Icon className="size-4" />
</button>
);
}
function NavButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70 disabled:opacity-25"
style={{
borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER,
backgroundColor: dark ? 'transparent' : ITEM_CARD,
}}
>
<Icon className="size-4" />
</button>
);
}

View File

@ -0,0 +1,58 @@
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import {
WALK_FILTERS,
matchesWalkFilter,
type WalkFilterKey,
} from '../variants/local/walking';
/**
* 도보 시간 필터 탭 — "전체 24 · 걸어서 5분 이내 8 · 10분 이내 19 · 10분 이상 5".
*
* ★ 구간은 누적이다(walking.ts 주석). 그래서 숫자가 서로 더해져 전체가 되지 않는다 —
* "5분 이내" ⊂ "10분 이내". 배타 구간으로 바꾸면 라벨("이내")과 숫자가 어긋난다.
* ★ 상태는 부르는 쪽(카테고리 섹션)이 든다. 섹션마다 필터가 따로 움직여야 한다.
*/
export function WalkFilterTabs({
distances,
value,
onChange,
colors,
}: {
/** 항목별 거리(m). 모르는 항목은 undefined — '전체'에만 센다. */
distances: (number | undefined)[];
value: WalkFilterKey;
onChange: (key: WalkFilterKey) => void;
colors: TemplateItem['colors'];
}) {
return (
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="도보 시간 필터">
{WALK_FILTERS.map(({key, label}) => {
const count = distances.filter((m) => matchesWalkFilter(key, m)).length;
const active = key === value;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={active}
onClick={(e) => {
e.stopPropagation();
onChange(key);
}}
style={active ? {backgroundColor: colors.text, borderColor: colors.text} : undefined}
className={cn(
'flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors',
active
? 'text-white'
: 'border-stone-300/80 bg-white/70 text-stone-700 hover:bg-white',
)}
>
<span>{label}</span>
<span className={cn('font-mono text-[11px]', active ? 'text-white/80' : 'text-stone-400')}>{count}</span>
</button>
);
})}
</div>
);
}

View File

@ -17,3 +17,7 @@ export {AddressCard} from './AddressCard';
export {FeatureCard} from './FeatureCard';
export {Pill} from './Pill';
export {CtaLink, type CtaStyle} from './CtaLink';
export {Rail} from './Rail';
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
export {PlaceCarousel} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -7,10 +7,8 @@
import type {IndustryType, SectionItem} from '@o2o/shared';
import type {SectionVariant} from './types';
import {HeroEditorial} from './variants/hero/HeroEditorial';
import {HeroCover} from './variants/hero/HeroCover';
import {HeroSplit} from './variants/hero/HeroSplit';
import {HeroFullBleed} from './variants/hero/HeroFullBleed';
import {HeroTypeOnly} from './variants/hero/HeroTypeOnly';
import {IntroStory} from './variants/intro/IntroStory';
import {IntroCentered} from './variants/intro/IntroCentered';
@ -28,9 +26,9 @@ import {PhotosWithVideos} from './variants/photos/PhotosWithVideos';
import {MapDetailed} from './variants/map/MapDetailed';
import {MapCompact} from './variants/map/MapCompact';
import {LocalFull} from './variants/local/LocalFull';
import {LocalTabs} from './variants/local/LocalTabs';
import {LocalCompact} from './variants/local/LocalCompact';
import {ItineraryTickets} from './variants/itinerary/ItineraryTickets';
import {VideoFrame} from './variants/video/VideoFrame';
import {LocalGuide} from './variants/local/LocalGuide';
import {WeatherSection} from './variants/weather/WeatherSection';
import {FaqAccordion} from './variants/faq/FaqAccordion';
@ -41,8 +39,6 @@ import {RoomsCarousel} from './variants/rooms/RoomsCarousel';
import {RoomsGrid} from './variants/rooms/RoomsGrid';
import {RoomsList} from './variants/rooms/RoomsList';
import {RulesList} from './variants/rules/RulesList';
import {RulesCards} from './variants/rules/RulesCards';
import {BookingCard} from './variants/booking/BookingCard';
import {BookingBanner} from './variants/booking/BookingBanner';
@ -66,23 +62,27 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts).
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
import {ChronicleRail} from './variants/chronicle/ChronicleRail';
import {LiteratureShelf} from './variants/literature/LiteratureShelf';
import {PostcardStack} from './variants/postcard/PostcardStack';
import {QuizFlip} from './variants/quiz/QuizFlip';
import {PlannerPodium} from './variants/planner/PlannerPodium';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
/*
* 히어로.
*
* ★ 발행본이 실제로 해석하는 것과 **1:1 이어야 한다**(`site/src/sections/HeroSection.tsx`).
* 예전에는 에디토리얼·풀스크린·타이포 셋을 더 보여줬는데 발행본에 짝이 없어서,
* 무엇을 골라도 발행본은 기본(표지)으로 나왔다 — 고르는 의미가 없는 선택지였다.
* 여기 늘릴 때는 발행본 쪽 분기도 같이 늘린다.
*/
hero: [
{
id: 'hero.editorial',
name: '에디토리얼',
description: '상호 바 + 큰 사진 + 가운데 문구. 분위기부터 보여준다.',
id: 'hero.cover',
name: '표지',
description: '사진 한 장 위, 상호는 좌하단. 사진의 피사체를 글자가 가리지 않는다.',
thumb: 'fullbleed',
Component: HeroEditorial,
Component: HeroCover,
isDefault: true,
},
{
@ -92,20 +92,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
thumb: 'split',
Component: HeroSplit,
},
{
id: 'hero.full-bleed',
name: '풀스크린 사진',
description: '사진을 화면 끝까지. 사진 한 장의 힘이 강할 때.',
thumb: 'fullbleed',
Component: HeroFullBleed,
},
{
id: 'hero.type-only',
name: '타이포 중심',
description: '사진 없이 여백과 글자만. 쓸 사진이 없거나 조용한 인상을 원할 때.',
thumb: 'centered',
Component: HeroTypeOnly,
},
],
intro: [
@ -208,29 +194,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
// ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는
// resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다.
local: [
{
id: 'local.full',
name: '전체',
description: '날씨 + 맛집 + 명소 + 축제를 전부 세로로.',
id: 'local.guide',
name: '가이드',
description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.',
thumb: 'stack',
Component: LocalFull,
Component: LocalGuide,
isDefault: true,
},
{
id: 'local.tabs',
name: '탭 전환',
description: '맛집/명소/축제를 탭으로. 화면 길이를 1/3 로 줄인다.',
thumb: 'accordion',
Component: LocalTabs,
},
{
id: 'local.compact',
name: '요약',
description: '날씨 한 줄과 추천 6곳만. 주인공이 아닐 때.',
thumb: 'compact',
Component: LocalCompact,
},
],
weather: [
@ -294,23 +268,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
rules: [
{
id: 'rules.list',
name: '목록',
description: '한 덩어리로 짧게. 가장 눈에 덜 띈다.',
thumb: 'compact',
Component: RulesList,
isDefault: true,
},
{
id: 'rules.cards',
name: '번호 카드',
description: '규정 하나에 칸 하나. 클레임이 잦은 곳에.',
thumb: 'cards',
Component: RulesCards,
},
],
booking: [
{
@ -431,27 +388,7 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
course: [
{
id: 'course.tickets',
name: '승차권',
description: '정거장 하나가 표 한 장. 마지막 표에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: CourseTickets,
isDefault: true,
},
],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
people: [
{
@ -475,16 +412,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
literature: [
{
id: 'literature.shelf',
name: '책등 서가',
description: '책등이 가로로 흐르고 고른 책만 세로쓰기로 펼쳐진다.',
thumb: 'carousel',
Component: LiteratureShelf,
isDefault: true,
},
],
postcard: [
{
@ -508,16 +435,6 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
planner: [
{
id: 'planner.podium',
name: '시즌 랭킹',
description: '계절을 고르면 1·2·3위 하루가 뜬다. 시각은 계산해서 채운다.',
thumb: 'timeline',
Component: PlannerPodium,
isDefault: true,
},
],
exhibition: [
{
@ -536,6 +453,28 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
Component: ExhibitionNotice,
},
],
video: [
{
id: 'video.frame',
name: '영상 한 편',
description: '표지를 먼저 보여주고 누르면 재생한다. 세로 영상(쇼츠)은 세로 틀로 나간다.',
thumb: 'fullbleed',
Component: VideoFrame,
isDefault: true,
},
],
itinerary: [
{
id: 'itinerary.tickets',
name: '승차권',
description: '절취선 뚫린 표가 줄줄이. 표 한 장이 정거장 하나이고 마지막에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: ItineraryTickets,
isDefault: true,
},
],
};
/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */

View File

@ -6,11 +6,10 @@
* ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
*/
import {useMemo} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ChronicleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -73,7 +71,6 @@ function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
export function ChronicleRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
const items = useMemo(
@ -108,21 +105,16 @@ export function ChronicleRail(props: SectionRenderProps) {
<PasteHint label="시간의 골목" />
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
{items.length > 3 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="연표" />
)}
</div>
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
<Rail label="연표" gap={0}>
{items.map((item, index) => (
<Milestone
key={`${item.year ?? 'x'}-${item.title}-${index}`}
@ -130,7 +122,7 @@ export function ChronicleRail(props: SectionRenderProps) {
isLast={index === items.length - 1}
/>
))}
</div>
</Rail>
</div>
)}
</SectionBody>

View File

@ -1,173 +0,0 @@
/**
* 반나절 산책 — 승차권 캐러셀.
*
* 표 한 장이 정거장 하나다. 절취선·펀치 구멍·완주 도장은 스탬프투어라는 실제 형식을 그대로 옮긴 것.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_SURFACE,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
function Ticket({
stop,
courseName,
index,
isLast,
}: {
stop: CourseStop;
courseName: string;
index: number;
isLast: boolean;
}) {
const no = String(stop.order ?? index + 1).padStart(2, '0');
return (
<div
className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-75"
style={{borderColor: ITEM_INK}}
>
<span className="truncate">{courseName}</span>
<span className="shrink-0">NO.{no}</span>
</div>
<p className="px-4 pt-4 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 32, color: ITEM_ACCENT}}>
{no}
</p>
<h4 className="px-4 pt-1.5 text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{stop.name}
</h4>
{stop.note && (
<p className="px-4 pt-2 text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{stop.note}
</p>
)}
<div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
</div>
{/* 펀치 구멍 — 표를 표로 만드는 자리 */}
<i
className="absolute -left-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/>
<i
className="absolute -right-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/>
{isLast && (
<span
className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75"
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
완주
<br />
도장
</span>
)}
</div>
);
}
function CourseRow({course}: {course: CourseItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const stops = course.stops ?? [];
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{course.name}
</h3>
<span className="text-[11px] opacity-60">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{stops.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={course.name} />
)}
</div>
{stops.length === 0 ? (
<p className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60" style={{borderColor: ITEM_BORDER}}>
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{stops.map((stop, index) => (
<Ticket
key={`${stop.name}-${index}`}
stop={stop}
courseName={course.name}
index={index}
isLast={index === stops.length - 1}
/>
))}
</div>
)}
<SourceLine source={course.source} verified={course.verified} />
</div>
);
}
export function CourseTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<CourseItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="반나절 산책" />
) : (
<div className="space-y-8">
{parsed.items.map((course, index) => (
<CourseRow key={`${course.name}-${index}`} course={course} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,84 @@
/**
* 히어로 · 표지 — 사진 한 장 위, 글은 좌하단.
*
* ★ 왜 가운데가 아닌가
* 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은
* 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고
* 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다.
*
* ★ 버튼을 세우지 않는다
* 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고,
* 여기서는 다음 섹션으로 눈을 내려보내기만 한다.
*
* ★ 색을 직접 쓰지 않는다
* `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다.
* 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다.
*/
import {ChevronDown, MapPin} from 'lucide-react';
import {SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {primaryPhoto} from '../common';
/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */
const SCRIM =
'linear-gradient(to top,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' +
' transparent 66%)';
export function HeroCover(props: SectionRenderProps) {
const {section, isSelected, onSelect, storeName, location, photos} = props;
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const cover = primaryPhoto(photos);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="relative flex w-full select-none items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{cover?.url && (
<div className="absolute inset-0 z-0">
<img src={cover.url} alt={storeName} className="size-full object-cover object-center" />
<div className="absolute inset-0" style={{background: SCRIM}} />
</div>
)}
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{location && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden />
{location}
</p>
)}
{/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
<h1
className="tpl-title leading-[1.15]"
style={{fontSize: 'var(--fs-display)'}}
>
{storeName}
</h1>
{/*
* ★ 여기에 `section.description` 을 쓰지 않는다.
* 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
* 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
* 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
*/}
<p className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] opacity-80">
객실 보기
<ChevronDown className="size-4" aria-hidden />
</p>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,66 +0,0 @@
/**
* 히어로 · 에디토리얼 — 상단 브랜드 바 + 큰 사진 + 가운데 세리프 카피.
* 감성 숙소/공방처럼 "분위기부터 보여주고 싶은" 곳의 기본값.
*/
import {Calendar} from 'lucide-react';
import {SectionFrame} from '../../primitives';
import {CtaLink} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, primaryPhoto} from '../common';
export function HeroEditorial(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props;
const cover = primaryPhoto(photos);
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const coverUrl = cover?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="flex w-full select-none flex-col"
style={{backgroundColor: template.colors.bg}}
>
<div className="mx-auto flex w-full max-w-[1120px] items-center justify-between border-b border-stone-200/60 bg-white/90 px-4 py-4 backdrop-blur-xs sm:px-6">
<span
className="serif-title text-base font-bold uppercase tracking-widest text-stone-800 sm:text-lg"
style={{color: template.colors.text}}
>
{storeName}
</span>
</div>
<div className="relative flex h-[50vh] w-full items-center justify-center overflow-hidden bg-stone-900 sm:h-[65vh]">
{coverUrl && (
<img
src={coverUrl}
alt={storeName}
className="absolute inset-0 size-full object-cover opacity-75"
/>
)}
<div className="absolute inset-0 bg-black/45" />
<div className="relative z-10 mx-auto w-full max-w-xl space-y-4 px-6 text-center text-white">
<h1 className="serif-title text-2xl font-bold leading-tight tracking-tight drop-shadow-xs sm:text-4xl md:text-5xl">
{storeName}
</h1>
<p className="text-xs font-light tracking-wide text-stone-200 sm:text-sm md:text-base">
{section.description || location}
</p>
<div className="flex items-center justify-center gap-2 pt-2">
<CtaLink
href={bookingHref(industryId, storeName)}
variant="pill"
colors={template.colors}
>
<Calendar className="size-3.5" />
<span>예약 안내</span>
</CtaLink>
</div>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,54 +0,0 @@
/**
* 히어로 · 풀블리드 — 화면을 꽉 채운 사진 위에 왼쪽 아래로 붙는 큰 타이포.
* 사진 한 장의 힘이 강한 곳(뷰 좋은 스테이, 플레이팅이 강한 다이닝)에 맞는다.
*/
import {Calendar, MapPin} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, primaryPhoto} from '../common';
export function HeroFullBleed(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props;
const cover = primaryPhoto(photos);
// 사진이 없으면 <img> 를 아예 렌더하지 않고 어두운 배경만 남긴다.
const coverUrl = cover?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div className="relative h-[62vh] min-h-[380px] w-full select-none overflow-hidden bg-stone-900 sm:h-[78vh]">
{coverUrl && (
<img
src={coverUrl}
alt={storeName}
className="absolute inset-0 size-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/10" />
<div className="absolute inset-x-0 bottom-0 px-6 pb-10 sm:px-10 sm:pb-14">
<div className="mx-auto max-w-[1120px] space-y-3 text-white">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-[11px] font-medium backdrop-blur-xs">
<MapPin className="size-3" />
{section.description || location}
</span>
<h1 className="serif-title max-w-2xl text-3xl font-bold leading-[1.15] tracking-tight drop-shadow-sm sm:text-5xl md:text-6xl">
{storeName}
</h1>
<div className="flex flex-wrap items-center gap-2 pt-2">
<CtaLink
href={bookingHref(industryId, storeName)}
variant="brand"
colors={template.colors}
>
<Calendar className="size-3.5" />
<span>지금 예약하기</span>
</CtaLink>
</div>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,47 +0,0 @@
/**
* 히어로 · 타이포 중심 — 사진 없이 여백과 글자만.
* 쓸 만한 사진이 아직 없거나, 조용한 인상을 주고 싶을 때 고른다.
*/
import {ArrowUpRight} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref, industryConfig} from '../common';
export function HeroTypeOnly(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, location, template} = props;
const config = industryConfig(industryId);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="px-6 py-20 sm:px-10 sm:py-28"
style={{backgroundColor: template.colors.bg}}
>
<div className="mx-auto max-w-2xl space-y-6 text-center">
<span
className="text-[11px] font-semibold uppercase tracking-[0.35em]"
style={{color: template.colors.accent}}
>
{section.description || `${config.name} · ${location}`}
</span>
<h1
className="serif-title text-3xl font-bold leading-[1.2] tracking-tight sm:text-5xl"
style={{color: template.colors.text}}
>
{storeName}
</h1>
<div className="mx-auto h-px w-12" style={{backgroundColor: template.colors.primary}} />
<div className="flex justify-center pt-2">
<CtaLink href={bookingHref(industryId, storeName)} variant="outline">
<span>{storeName} 둘러보기</span>
<ArrowUpRight className="size-3.5" />
</CtaLink>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -8,7 +8,6 @@
* ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다.
* 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다.
*/
import {useCallback, useRef} from 'react';
import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react';
import {cn} from '@/lib/utils';
import type {DataSource, DataVerified} from '@o2o/shared';
@ -102,17 +101,12 @@ export function SourceLine({
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(null);
const scrollBy = useCallback((dir: -1 | 1) => {
const box = ref.current;
if (!box) return;
box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'});
}, []);
return {ref, scrollBy};
}
/**
* 앞뒤 버튼 한 쌍.
*
* ★ 가로 슬라이더에는 쓰지 않는다 — 그건 `Rail`(embla)이 자기 화살표를 갖는다.
* 여기 남은 쓰임은 **스크롤이 아닌 이동**이다(일력: 장을 넘겨 오늘 자리를 바꾼다).
*/
export function CarouselNav({
onPrev,
onNext,

View File

@ -91,12 +91,30 @@
transform: rotateY(180deg);
}
/*
* 가로 슬라이더의 창(viewport).
*
* 스크립트가 붙기 전에는 그냥 가로 스크롤 상자다. embla 가 붙으면 `data-slider="on"` 이
* 걸리고 그때부터 드래그가 스크롤을 대신한다 — 둘을 같이 켜면 관성이 겹쳐 튄다.
*/
.w4-scroll {
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.w4-scroll::-webkit-scrollbar {
display: none;
}
.w4-scroll[data-slider='on'] {
overflow-x: hidden;
scroll-snap-type: none;
}
/* 문장을 잡으면 카드가 안 끌린다 — 끄는 동안만 선택을 끈다. */
.w4-scroll[data-dragging='true'] {
cursor: grabbing;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.w4-spin {

View File

@ -0,0 +1,179 @@
/**
* 추천 일정 — 캔버스. 승차권 한 장이 정거장 하나다.
*
* ★ 셋을 합친 자리다(반나절 산책 · 여행 스케줄 · 계절별 추천 하루). 축이 계절이 아니라 **기간**이다.
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
* 출발을 당기면 하루가 통째로 밀린다.
*/
import {
currentSeasons,
inSeason,
itineraryDays,
itineraryDurations,
parseSectionData,
type ItineraryItem,
type PlannedStop,
} from '@o2o/shared';
import {SectionBody, SectionFrame, Rail} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
export function ItineraryTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
const live = currentSeasons();
const durations = itineraryDurations(parsed.items);
const groups: (string | undefined)[] =
durations.length > 0
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
: [undefined];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="추천 일정" />
) : (
<div className="space-y-7">
{groups.map((duration) => {
const items = parsed.items.filter((item) =>
duration === undefined
? !item.duration?.trim()
: item.duration?.trim() === duration,
);
if (items.length === 0) return null;
return (
<div key={duration ?? 'etc'} className="space-y-4">
{duration && (
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{duration}
</h3>
)}
{items.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
<span className="text-[11px] opacity-60">
{[item.audience, item.season].filter(Boolean).join(' · ')}
</span>
{/* 계절을 적으면 손님 화면에서는 그 계절에만 나간다 — 사장님이 알아야 한다. */}
{item.season && (
<span className="text-[11px] opacity-60">
{inSeason(item.season, live)
? '· 지금 나갑니다'
: `· 손님 화면에는 ${item.season} 에만 나갑니다`}
</span>
)}
</div>
{item.why && (
<p className="max-w-[52ch] text-[13px] leading-relaxed opacity-80">
{item.why}
</p>
)}
{itineraryDays(item).map((entry, dayIndex) => (
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
{entry.label && (
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
{entry.label}
<span className="ml-2 font-normal opacity-60">
{entry.day.from}–{entry.day.to}
</span>
</p>
)}
<Rail label={`${item.name} ${entry.label || '정거장'}`}>
{entry.day.stops.map((planned, stopIndex) => (
<Ticket
key={`${planned.stop.name}-${stopIndex}`}
planned={planned}
no={stopIndex + 1}
isLast={stopIndex === entry.day.stops.length - 1}
/>
))}
</Rail>
{entry.day.dropped > 0 && (
<p className="text-[11px] opacity-60">
밤 9시를 넘기는 {entry.day.dropped}곳은 화면에서 뺐습니다.
</p>
)}
</div>
))}
<SourceLine source={item.source} verified={item.verified} />
</div>
))}
</div>
);
})}
</div>
)}
</SectionBody>
</SectionFrame>
);
}
/** 승차권 한 장. 마지막 장에는 완주 도장(아티팩트의 스탬프투어 형식). */
function Ticket({planned, no, isLast}: {planned: PlannedStop; no: number; isLast: boolean}) {
const {stop, time, until, move} = planned;
return (
<article
className="w4-perf relative w-[214px] shrink-0 border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-baseline justify-between gap-2 border-b border-dashed px-3.5 py-2"
style={{borderColor: ITEM_BORDER}}
>
<span className="text-[10px] tracking-[0.2em] opacity-60">
NO.{String(no).padStart(2, '0')}
</span>
<span className="text-[13px] font-bold tabular-nums" style={{fontFamily: ITEM_HEADING}}>
{time}
<span className="opacity-55">–{until}</span>
</span>
</div>
<div className="space-y-1 px-3.5 pb-3.5 pt-2.5">
<h5 className="text-sm font-bold" style={{fontFamily: ITEM_HEADING}}>
{stop.name}
</h5>
{stop.note && <p className="text-[12px] leading-relaxed opacity-80">{stop.note}</p>}
{move > 0 && <p className="text-[10px] opacity-55">↓ 앞 칸에서 {move}분 이동</p>}
</div>
{isLast && (
<span
className="absolute bottom-2.5 right-2.5 grid size-10 rotate-[-12deg] place-items-center rounded-full border-2 text-[10px] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
aria-hidden
>
완주
</span>
)}
</article>
);
}

View File

@ -1,149 +0,0 @@
/**
* 문학 서가 — 책등 캐러셀 + 세로쓰기 펼침면.
*
* 책등이 가로로 흐르고, 하나를 고르면 그 책만 앞으로 나와 펼쳐진다.
* 펼친 면의 제목은 **세로쓰기**다 — 원문을 못 싣는 대신(DB_Guide: 원문 전재 금지)
* 활자의 결로 문학을 느끼게 하는 자리다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type LiteratureItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다 — 색을 박으면 팔레트를 바꿔도 서가만 남는다. */
const FALLBACK_SPINE = ITEM_ACCENT;
export function LiteratureShelf(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<LiteratureItem>(section.type, section.data);
const [opened, setOpened] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[opened] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="문학 서가" />
) : (
<div className="space-y-5">
{/* 서가 — 아래 선반이 있어야 책이 '꽂혀 있다'로 읽힌다 */}
<div className="space-y-2">
<div
ref={ref}
className="w4-scroll flex items-end gap-1.5 overflow-x-auto border-b-4 px-2 pt-4"
// 선반 널. 글자색을 섞어 만들면 어떤 팔레트에서도 '받치는 판'으로 읽힌다.
style={{borderColor: 'color-mix(in oklab, currentColor 45%, transparent)'}}
>
{parsed.items.map((book, index) => (
<button
key={`${book.workTitle}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setOpened(index);
}}
aria-current={index === opened}
className="h-[184px] w-[40px] shrink-0 snap-center px-1 py-3 text-center transition-all"
style={{
backgroundColor: book.spineColor || FALLBACK_SPINE,
// 책등 색은 사장님이 정하므로 그 위 글자는 밝은 면 색으로 고정한다.
color: ITEM_INVERSE_INK,
// 고른 책만 한 칸 튀어나온다 — 뽑아 든 자리
transform: index === opened ? 'translateY(-12px)' : undefined,
boxShadow: index === opened ? '0 6px 14px rgba(27,26,21,.35)' : undefined,
}}
>
<span
className="mx-auto block h-full overflow-hidden text-[13px] leading-tight"
style={{fontFamily: ITEM_BODY, writingMode: 'vertical-rl'}}
>
{book.workTitle}
{book.author ? ` · ${book.author}` : ''}
</span>
</button>
))}
</div>
{parsed.items.length > 6 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="책" />
)}
</div>
{/* 펼친 면 */}
<div
className="w4-paper grid gap-5 border p-5 sm:p-6 md:grid-cols-[auto_minmax(0,1fr)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<p
className="mx-auto h-fit w-fit max-h-[240px] overflow-hidden border-r pr-4 leading-tight md:mx-0"
style={{
fontFamily: ITEM_HEADING,
fontSize: 30,
writingMode: 'vertical-rl',
borderColor: ITEM_BORDER,
}}
>
{current.workTitle}
</p>
<div className="min-w-0 space-y-3">
<p className="text-xs opacity-60">
{[current.author, current.year, current.genre].filter(Boolean).join(' · ')}
</p>
{current.background && (
<p
className="text-sm leading-relaxed opacity-85"
style={{fontFamily: ITEM_BODY}}
>
{current.background}
</p>
)}
{current.whyHere && (
<p
className="border-l-2 pl-3 text-sm leading-relaxed opacity-75"
style={{fontFamily: ITEM_BODY, borderColor: ITEM_BORDER}}
>
{current.whyHere}
</p>
)}
<span className="inline-block border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다
</span>
<SourceLine source={current.source} verified={current.verified} />
</div>
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,27 +0,0 @@
import {useEffect, useState} from 'react';
/** "실시간 업데이트: 오후 4:41:56" 표시. 1초마다 갱신된다. */
export function LiveClock() {
const [clock, setClock] = useState('');
useEffect(() => {
const update = () => {
const now = new Date();
const period = now.getHours() >= 12 ? '오후' : '오전';
const hours = now.getHours() % 12 || 12;
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
setClock(`${period} ${hours}:${minutes}:${seconds}`);
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, []);
return (
<span className="flex items-center gap-1.5 rounded-full border border-stone-200/80 bg-white px-2.5 py-1 text-[11px] text-stone-500 shadow-2xs">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-500" />
<span>실시간 업데이트: {clock}</span>
</span>
);
}

View File

@ -0,0 +1,61 @@
/**
* 지역 가이드 · 카테고리 한 묶음 — 제목(아이콘) → 도보 시간 필터 → 카드 캐러셀.
*
* 맛집·명소·축제가 전부 이 모양이다. 필터 상태는 묶음마다 따로 든다 —
* 맛집을 "5분 이내"로 걸렀는데 명소까지 줄어들면 사장님이 명소가 사라진 줄 안다.
*/
import {useState, type ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {EmptyStateNotice, PlaceCarousel, WalkFilterTabs} from '../../primitives';
import type {GuideCard} from './types';
import {matchesWalkFilter, type WalkFilterKey} from './walking';
export function LocalCategorySection({
icon: Icon,
title,
cards,
emptyText,
colors,
renderLeading,
}: {
icon: LucideIcon;
title: string;
cards: GuideCard[];
emptyText: string;
colors: TemplateItem['colors'];
renderLeading?: (card: GuideCard) => ReactNode;
}) {
const [filter, setFilter] = useState<WalkFilterKey>('all');
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
return (
<section className="space-y-4">
<h3
className="flex items-center gap-2 text-sm font-bold text-stone-900"
style={{color: colors.text}}
>
<Icon className="size-4" style={{color: colors.primary}} />
<span>{title}</span>
</h3>
{cards.length === 0 ? (
<EmptyStateNotice>{emptyText}</EmptyStateNotice>
) : (
<>
<WalkFilterTabs
distances={cards.map((c) => c.distanceMeters)}
value={filter}
onChange={setFilter}
colors={colors}
/>
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
)}
</>
)}
</section>
);
}

View File

@ -1,50 +0,0 @@
/**
* 지역 가이드 · 요약 — 날씨 한 줄과 추천 6곳만.
* 지역 정보가 주인공이 아닌 사이트에서 "있긴 하다" 정도로 둘 때.
*/
import {
EmptyStateNotice,
ListCard,
PlaceRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
export function LocalCompact(props: SectionRenderProps) {
const {section, isSelected, onSelect, location, template} = props;
// 맛집·명소를 섞어 딱 6개만. 목록이 길어지면 요약이 아니게 된다.
// ★ 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약(SectionRenderProps)에 없다.
// 시연용 목록(제주 애월)으로 자리를 메우지 않는다 — 남의 동네 맛집이 사장님 사이트에 붙는다.
const picks: {name: string; meta: string; description: string; q: string}[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="narrow">
<SectionHeading variant="minimal" title="주변 안내" colors={template.colors} />
{picks.length === 0 ? (
<EmptyStateNotice>주변 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{picks.map((p) => (
<PlaceRow
key={p.name}
name={p.name}
meta={p.meta}
description={p.description}
href={naverSearch(p.q)}
actionLabel="보기"
colors={template.colors}
/>
))}
</ListCard>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,163 +0,0 @@
/**
* 지역 가이드 · 전체 — 날씨 카드 + 맛집 + 명소 + 월별 축제를 세로로 전부.
* "여기 오면 근처에 뭐가 있나"를 한 화면에서 다 보여주고 싶을 때.
*/
import {Calendar, MapPin, Sparkles, Utensils} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {
EmptyStateNotice,
ListCard,
PlaceRow,
Pill,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {LiveClock} from './LiveClock';
import type {FestivalItem, NearbyPlace} from './types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
function GroupHeading({
icon: Icon,
title,
note,
colors,
}: {
icon: typeof Utensils;
title: string;
note: string;
colors: TemplateItem['colors'];
}) {
return (
<div className="flex items-center justify-between">
<h3
className="serif-title flex items-center gap-2 text-base font-bold text-stone-900 sm:text-lg"
style={{color: colors.text}}
>
<Icon className="size-4 text-stone-600" style={{color: colors.primary}} />
<span>{title}</span>
</h3>
{/* 오른쪽 출처 문구는 부가 정보라 중립 회색 그대로 둔다. */}
<span className="text-[11px] text-stone-400">{note}</span>
</div>
);
}
export function LocalFull(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const isStay = industryId === 'stay';
const locName = location.split(' ')[0] || '주변';
// ★ 맛집·명소·축제는 전부 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다.
// (지역 큐레이션을 읽어올 백엔드 창구가 아직 없어 당분간 계속 빈다.)
// 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다.
const foods: NearbyPlace[] = [];
const spots: NearbyPlace[] = [];
const festivals: FestivalItem[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody className="space-y-8">
<SectionHeading
variant="eyebrow"
eyebrow="AI Local Guide"
title={`AI ${locName} 실시간 가이드`}
subtitle="실시간 날씨와 인근 미식·명소·축제를 AI가 큐레이션합니다"
trailing={<LiveClock />}
colors={template.colors}
/>
<div className="space-y-3.5">
<GroupHeading
icon={Utensils}
title="숙소 인근 엄선 맛집 & 카페"
note="호스트 & 네이버 플레이스 연동"
colors={template.colors}
/>
{foods.length === 0 ? (
<EmptyStateNotice>주변 맛집·카페 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{foods.map((place) => (
<PlaceRow
key={place.name}
name={place.name}
meta={place.distance}
description={place.description}
href={naverSearch(place.searchQuery)}
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
<div className="space-y-3.5">
<GroupHeading
icon={MapPin}
title="주변 힐링 명소 & 해변"
note="차량 5~20분 거리"
colors={template.colors}
/>
{spots.length === 0 ? (
<EmptyStateNotice>주변 명소 추천은 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{spots.map((spot) => (
<PlaceRow
key={spot.name}
name={spot.name}
meta={spot.duration}
description={spot.description}
href={naverSearch(spot.searchQuery)}
actionLabel="상세정보"
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
{isStay && (
<div className="space-y-3.5">
<GroupHeading
icon={Calendar}
title="월별 축제 & 문화 행사"
note="사계절 캘린더"
colors={template.colors}
/>
{festivals.length === 0 ? (
<EmptyStateNotice>월별 축제 안내는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{festivals.map((fest) => (
<PlaceRow
key={fest.name}
name={fest.name}
description={fest.description || fest.period}
href={naverSearch(fest.searchQuery)}
actionLabel="검색"
leading={
<>
<Pill tone="accent">{fest.month}</Pill>
{fest.isFeatured && (
<Pill tone="good">
<Sparkles className="size-2.5" />
대표축제
</Pill>
)}
</>
}
colors={template.colors}
/>
))}
</ListCard>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,88 @@
/**
* 지역 가이드 — 맛집 · 명소 · 축제를 카테고리별 "도보 시간 필터 + 카드 캐러셀"로 세로 나열.
*
* ★ 2026-09-07 결정으로 전체/탭/요약 세 배리에이션을 이 하나로 통일했다.
* 데이터가 이미 카테고리별로 나뉘어 있어 "탭으로 갈아끼우기"라는 구분이 캐러셀 구조에서는
* 의미가 없어졌고, 요약(6곳)은 필터가 그 역할을 대신한다.
* ★ 값은 서버(local.place_contents)가 소유한다 — 업장 좌표 반경으로 받은 것만 그린다.
* 시연용 목록으로 자리를 메우지 않는다(남의 동네 맛집이 사장님 사이트에 붙는다).
*/
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalCard, GuideCard} from './types';
import {LocalCategorySection} from './LocalCategorySection';
import {WALK_DISCLAIMER} from './walking';
/** "2026년 9월 3일 갱신" — 서버 수집 시각(ISO)에서. 없으면 비운다(지어내지 않는다). */
function syncedLabel(iso?: string): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 갱신`;
}
/** "전북특별자치도 군산시 …" → "군산시". 시군구 토큰이 없으면 첫 토큰. */
function regionLabel(location: string): string {
const tokens = location.split(' ').filter(Boolean);
return tokens[1] ?? tokens[0] ?? '주변';
}
export function LocalGuide(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const {foods, spots, festivals, syncedAt} = useLocalGuide();
const colors = template.colors;
const isStay = industryId === 'stay';
const synced = syncedLabel(syncedAt);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide" className="space-y-10">
<SectionHeading
title={section.name || '주변 안내'}
subtitle={`${regionLabel(location)} 지역의 맛집 · 명소 안내입니다.`}
trailing={synced && <span className="text-xs text-stone-400">{synced}</span>}
colors={colors}
/>
<LocalCategorySection
icon={Utensils}
title="주변 맛집"
cards={foods}
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
colors={colors}
/>
<LocalCategorySection
icon={MapPin}
title="주변 명소"
cards={spots}
emptyText="주변 명소 추천은 아직 준비 중입니다."
colors={colors}
/>
{isStay && (
<LocalCategorySection
icon={Calendar}
title="주변 축제 & 문화 행사"
cards={festivals}
emptyText="주변 축제 안내는 아직 준비 중입니다."
colors={colors}
renderLeading={(card: GuideCard) => {
const fest = card as FestivalCard;
return (
<div className="flex flex-wrap items-center gap-1.5">
{fest.month && <Pill tone="accent">{fest.month}</Pill>}
{fest.period && <Pill mono>{fest.period}</Pill>}
</div>
);
}}
/>
)}
<p className="text-[11px] leading-relaxed text-stone-400">{WALK_DISCLAIMER}</p>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,124 +0,0 @@
/**
* 지역 가이드 · 탭 — 맛집/명소/축제를 탭으로 갈아 끼운다.
* 목록이 길어 스크롤이 부담스러울 때 화면 길이를 1/3 로 줄인다.
*/
import {useState} from 'react';
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {cn} from '@/lib/utils';
import {
EmptyStateNotice,
ListCard,
Pill,
PlaceRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalItem, NearbyPlace} from './types';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
type TabKey = 'food' | 'spots' | 'festivals';
const TABS: {key: TabKey; label: string; icon: typeof Utensils}[] = [
{key: 'food', label: '맛집 · 카페', icon: Utensils},
{key: 'spots', label: '명소', icon: MapPin},
{key: 'festivals', label: '축제', icon: Calendar},
];
export function LocalTabs(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const [tab, setTab] = useState<TabKey>('food');
const isStay = industryId === 'stay';
const tabs = isStay ? TABS : TABS.filter((t) => t.key !== 'festivals');
const locName = location.split(' ')[0] || '주변';
// ★ 세 탭 모두 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다.
// 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다.
const foods: NearbyPlace[] = [];
const spots: NearbyPlace[] = [];
const festivals: FestivalItem[] = [];
// 탭을 눌렀는데 아무것도 없으면 섹션이 사라진 것처럼 보인다 — 자리는 지키고 상태만 알린다.
const isEmpty = (tab === 'food' ? foods : tab === 'spots' ? spots : festivals).length === 0;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
variant="eyebrow"
eyebrow="AI Local Guide"
title={`AI ${locName} 실시간 가이드`}
subtitle="탭을 눌러 원하는 정보만 보세요"
colors={template.colors}
/>
<div className="flex items-center gap-1 rounded-xl border border-stone-200/80 bg-white p-1">
{tabs.map(({key, label, icon: Icon}) => (
<button
key={key}
type="button"
onClick={(e) => {
e.stopPropagation();
setTab(key);
}}
aria-pressed={tab === key}
style={tab === key ? {backgroundColor: template.colors.primary} : undefined}
className={cn(
'flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors',
tab === key ? 'text-white' : 'text-stone-500 hover:bg-stone-100',
)}
>
<Icon className="size-3.5" />
<span>{label}</span>
</button>
))}
</div>
{isEmpty ? (
<EmptyStateNotice>주변 정보는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<ListCard>
{tab === 'food' &&
foods.map((place) => (
<PlaceRow
key={place.name}
name={place.name}
meta={place.distance}
description={place.description}
href={naverSearch(place.searchQuery)}
colors={template.colors}
/>
))}
{tab === 'spots' &&
spots.map((spot) => (
<PlaceRow
key={spot.name}
name={spot.name}
meta={spot.duration}
description={spot.description}
href={naverSearch(spot.searchQuery)}
actionLabel="상세정보"
colors={template.colors}
/>
))}
{tab === 'festivals' &&
festivals.map((fest) => (
<PlaceRow
key={fest.name}
name={fest.name}
description={fest.description || fest.period}
href={naverSearch(fest.searchQuery)}
actionLabel="검색"
leading={<Pill tone="accent">{fest.month}</Pill>}
colors={template.colors}
/>
))}
</ListCard>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,32 +1,26 @@
/**
* 지역 정보 항목의 모양.
* 지역 가이드 카드 한 장의 모양(맛집·명소·축제 공통).
*
* ★ 원래 이 타입들은 시연용 데이터 파일(data/stayData.ts)이 소유했다. 그 파일을 지우면서
* 실제로 쓰는 쪽인 여기로 옮겼다 — 타입이 데이터 시드에 매여 있을 이유가 없다.
*
* ★ 값은 서버(local.local_contents)가 소유한다. 지금은 캔버스 계약(SectionRenderProps)에
* 지역 정보가 없어서 배리에이션들이 빈 목록을 그린다. 흘려보낼 창구가 생기면
* 이 모양에 맞춰 매핑하면 된다.
* ★ 값은 서버(local.place_contents)가 소유한다. hooks/useLocalGuide 가 GET /v1/local/guide
* 응답(발행 payload 와 같은 모양)을 이 타입으로 매핑한다.
* ★ 도보 시간 배지·필터는 distanceMeters 로 계산한다(walking.ts::walkMinutes, 분속 80m).
* distanceMeters 가 없으면(지역 캐시에서 온 수기 항목 등) 배지·필터 대상에서 빠진다 —
* 업장 기준 거리를 모르는 값으로 "도보 N분"을 지어내지 않는다.
*/
export interface NearbyPlace {
export interface GuideCard {
name: string;
category: string;
/** 거리 표기("차로 5분", "1.2km") — 원문 그대로 옮긴다. */
distance: string;
duration: string;
description: string;
/** 상세 페이지 대신 검색으로 보낸다 — 없는 주소를 지어내지 않기 위해서다. */
searchQuery: string;
tag: string;
imageUrl?: string;
distanceMeters?: number;
/** 화면 배지에 그대로 쓰는 거리 문자열("850m"/"1.2km"). */
distanceText?: string;
}
export interface FestivalItem {
/** 축제는 기간·월 배지가 더 붙는다. */
export interface FestivalCard extends GuideCard {
month: string;
name: string;
period: string;
location: string;
description: string;
isFeatured?: boolean;
officialUrl?: string;
searchQuery: string;
}

View File

@ -0,0 +1,36 @@
/**
* 도보 시간 환산 — 직선거리(m) → 분.
*
* ★ 분속 80m 로 나눈다(보통 걸음 4.8km/h). 스크린샷 문구와 같은 기준이며 화면 하단에도 그대로 적는다:
* "도보 시간은 숙소에서 잰 직선거리를 분속 80m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다."
* ★ 실측 검증(2026-09-07): 135m→2분 · 297m→4분 · 305m→4분 · 566m→7분 · 13m→1분 — 반올림 + 최소 1분.
*/
export const WALK_METERS_PER_MINUTE = 80;
export const WALK_DISCLAIMER =
`도보 시간은 숙소에서 잰 직선거리를 분속 ${WALK_METERS_PER_MINUTE}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다.`;
export function walkMinutes(meters: number): number {
return Math.max(1, Math.round(meters / WALK_METERS_PER_MINUTE));
}
/**
* 도보 시간 필터. ★ 구간은 배타적이 아니라 **누적**이다 — "10분 이내"는 "5분 이내"를 포함한다.
* (스크린샷 수치 8·19·5 → 19+5=24=전체 로 확인. "5분 이내"는 "10분 이내"의 부분집합.)
*/
export type WalkFilterKey = 'all' | 'within5' | 'within10' | 'over10';
export const WALK_FILTERS: {key: WalkFilterKey; label: string; test: (minutes: number) => boolean}[] = [
{key: 'all', label: '전체', test: () => true},
{key: 'within5', label: '걸어서 5분 이내', test: (m) => m <= 5},
{key: 'within10', label: '걸어서 10분 이내', test: (m) => m <= 10},
{key: 'over10', label: '걸어서 10분 이상', test: (m) => m > 10},
];
/** 거리를 모르는 항목은 '전체'에만 들어간다 — 모르는 값으로 구간을 정하지 않는다. */
export function matchesWalkFilter(key: WalkFilterKey, meters: number | undefined): boolean {
if (key === 'all') return true;
if (meters === undefined) return false;
const filter = WALK_FILTERS.find((f) => f.key === key);
return filter ? filter.test(walkMinutes(meters)) : true;
}

View File

@ -8,11 +8,10 @@
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PeopleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -33,7 +31,6 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PeopleItem>(section.type, section.data);
const [picked, setPicked] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[picked] ?? parsed.items[0];
@ -70,10 +67,7 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div
ref={ref}
className="w4-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 py-3"
>
<Rail label="인물" tone="dark" nav="overlay" gap={0.75} viewportClassName="px-3 py-3">
{parsed.items.map((person, index) => (
<button
key={`${person.name}-${index}`}
@ -110,14 +104,10 @@ export function PeopleFilmstrip(props: SectionRenderProps) {
</span>
</button>
))}
</div>
</Rail>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
{parsed.items.length > 4 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} tone="dark" label="인물" />
)}
{/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */}
<div className="space-y-2 border-l-2 pl-4" style={{borderColor: ITEM_ACCENT}}>
<h3 className="text-xl" style={{fontFamily: ITEM_HEADING}}>

View File

@ -6,6 +6,7 @@ import {useState} from 'react';
import {
Lightbox,
PhotoTile,
Rail,
SectionBody,
SectionFrame,
SectionHeading,
@ -23,27 +24,19 @@ export function PhotosCarousel(props: SectionRenderProps) {
<SectionBody width="wide">
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="scrollbar-none flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-3">
{/* ★ 예전엔 점이 그려져 있기만 하고 아무것도 가리키지 않았다(전부 같은 색·클릭 불가).
지금 몇 번째인지는 Rail 이 숫자로 말하고, 화살표와 드래그로 실제로 넘어간다. */}
<Rail label="사진" gap={0.75}>
{items.map((photo, idx) => (
<PhotoTile
key={photo.id}
photo={photo}
fit="landscape"
onOpen={() => setOpenIndex(idx)}
className="w-[78vw] max-w-[420px] shrink-0 snap-center"
className="w-[78vw] max-w-[420px] shrink-0"
/>
))}
</div>
<div className="flex items-center justify-center gap-1.5">
{items.map((photo) => (
<span
key={photo.id}
className="size-1.5 rounded-full"
style={{backgroundColor: `${template.colors.primary}40`}}
/>
))}
</div>
</Rail>
</SectionBody>
<Lightbox photos={items} index={openIndex} onClose={() => setOpenIndex(null)} onMove={setOpenIndex} />

View File

@ -1,218 +0,0 @@
/**
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
*
* ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고,
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
*/
import {useMemo, useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
currentSeasons,
parseSectionData,
planDay,
plannerSeasons,
plannerTop,
type PlannerItem,
} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<article
className="w4-paper w-[320px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(0,0,0,.08)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && (
<p className="text-[13px] leading-relaxed opacity-80" style={{fontFamily: ITEM_BODY}}>
{item.why}
</p>
)}
</div>
{day.stops.length === 0 ? (
<p className="px-4 py-5 text-center text-[11px] opacity-60">
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="pt-2 text-[12px] tabular-nums opacity-75" style={{fontFamily: ITEM_HEADING}}>
{planned.time}
</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && (
<p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>
)}
<p className="pt-1 text-sm font-bold" style={{fontFamily: ITEM_BODY}}>
{planned.stop.name}
</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{planned.stop.note}
</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
)}
<div className="space-y-1 border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */}
{day.dropped > 0 && (
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.
</p>
)}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerPodium(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
const [picked, setPicked] = useState<number>();
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[index] ?? seasons[0];
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="계절별 추천 하루" />
) : (
<div className="space-y-4">
{seasons.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{seasons.map((name, tabIndex) => (
<button
key={name}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(tabIndex);
}}
aria-current={tabIndex === index}
className={cn(
'border px-3 py-1 text-xs transition-opacity',
tabIndex !== index && 'opacity-55',
)}
style={
tabIndex === index
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{name}
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
</button>
))}
</div>
)}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, rank) => (
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
))}
</div>
<p className="text-[11px] opacity-60">
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개
</p>
{live.length > 0 && (
<p className="text-[11px] opacity-60">
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
</p>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -6,11 +6,10 @@
*/
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PostcardItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -20,7 +19,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -98,7 +96,6 @@ function Postcard({item}: {item: PostcardItem}) {
export function PostcardStack(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
@ -120,19 +117,14 @@ export function PostcardStack(props: SectionRenderProps) {
<PasteHint label="오늘의 엽서" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
<Rail label="엽서">
{parsed.items.map((item, index) => (
<Postcard key={`${item.line}-${index}`} item={item} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="엽서" />
)}
</div>
</Rail>
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
</div>
)}
</SectionBody>

View File

@ -7,11 +7,10 @@
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type QuizItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
@ -21,7 +20,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -85,7 +83,6 @@ function QuizCard({item, no}: {item: QuizItem; no: number}) {
export function QuizFlip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<QuizItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
@ -107,19 +104,14 @@ export function QuizFlip(props: SectionRenderProps) {
<PasteHint label="뒤집어 보는 질문" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
<Rail label="질문">
{parsed.items.map((item, index) => (
<QuizCard key={`${item.question}-${index}`} item={item} no={index + 1} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="질문" />
)}
</div>
</Rail>
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
</div>
)}
</SectionBody>

View File

@ -1,51 +0,0 @@
/**
* 이용 규정 · 카드 — 규정 하나에 칸 하나. 번호가 붙어 "빠뜨리지 말라"는 인상이 강하다.
* 노쇼·소음 클레임이 잦은 곳에 맞는다.
*/
import {ShieldCheck} from 'lucide-react';
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {ruleItems} from '../common';
export function RulesCards(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
// ★ 규정을 지어내지 않는다 — 확인된 fact 에서만 만든다.
const items = ruleItems(props.infoFields);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody>
<SectionHeading
variant="eyebrow"
eyebrow="HOUSE RULES"
title={section.name}
subtitle="예약 전 꼭 확인해 주세요"
trailing={<ShieldCheck className="size-5 text-stone-400" />}
/>
{items.length === 0 && (
<EmptyStateNotice>
확인된 이용 규정이 아직 없습니다 — 정보 탭에서 확인하면 여기에 나옵니다.
</EmptyStateNotice>
)}
<ol className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{items.map((rule, idx) => (
<li
key={rule}
className="flex items-start gap-3 rounded-xl border border-stone-200/80 bg-white p-4 shadow-2xs"
>
<span
className="flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{backgroundColor: template.colors.primary}}
>
{idx + 1}
</span>
<span className="text-xs leading-relaxed text-stone-700">{rule}</span>
</li>
))}
</ol>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,58 +0,0 @@
/**
* 이용 규정 · 목록 — 지켜야 할 것을 한 덩어리로 짧게. 가장 눈에 덜 띄는 형태.
*/
import {
SlateScriptRenderer,
type SlateElementNode,
type SlateTextNode,
} from '../../SlateScriptRenderer';
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {EmptyStateNotice} from '../../primitives';
import {ruleItems} from '../common';
export function RulesList(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
// ★ 규정을 지어내지 않는다. 확인된 fact(체크인·체크아웃·취소규정·취사·반려동물…)에서만 만든다 —
// 예전에는 '체크인 16:00 / 체크아웃 11:00' 같은 가공의 규정이 모든 사업장에 똑같이 떴다.
const items = ruleItems(props.infoFields);
const script: (SlateElementNode | SlateTextNode)[] | undefined = undefined;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading title={section.name} subtitle="이용 안내 및 규정" />
{items.length === 0 ? (
<EmptyStateNotice>
확인된 이용 규정이 아직 없습니다 — 정보 탭에서 확인하면 여기에 나옵니다.
</EmptyStateNotice>
) : (
<div className="space-y-2 rounded-xl border border-stone-200/80 bg-stone-50/60 p-4 text-xs shadow-2xs">
<strong className="mb-2 block font-bold text-stone-900">투숙 및 이용 수칙</strong>
{items.map((rule) => (
<p key={rule} className="text-stone-600">
• {rule}
</p>
))}
</div>
)}
{script && (
<div className="space-y-3 rounded-xl border border-stone-200 bg-stone-50 p-4 sm:p-5">
<div className="flex items-center justify-between border-b border-stone-200 pb-2">
<span className="flex items-center gap-1.5 text-xs font-bold text-stone-800">
<span className="size-2 rounded-full bg-stone-900" />
호스트-게스트 실시간 문의 & 대화 가이드
</span>
<span className="rounded border border-stone-200 bg-white px-2 py-0.5 font-mono text-[11px] text-stone-400">
Slate editor_script
</span>
</div>
<SlateScriptRenderer nodes={script} primaryColor={template.colors.primary} />
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,180 +0,0 @@
/**
* 여행 스케줄 — 대합실 시간표 캐러셀.
*
* 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 —
* 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다.
* ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return {head: time.trim()};
return {head: match[1].padStart(2, '0'), tail: match[2]};
}
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
const {head, tail} = splitTime(slot.time ?? '');
return (
<div className="flex shrink-0 snap-center items-stretch">
<div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
{/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */}
<div
className="relative px-4 py-3 text-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<span
className="inline-flex items-baseline gap-1 leading-none"
style={{fontFamily: ITEM_HEADING, fontSize: 30}}
>
{head}
{tail && <span className="opacity-60">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'color-mix(in oklab, currentColor 25%, transparent)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{slot.title}
</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: ITEM_BODY, color: ITEM_ACCENT}}>
{slot.place}
</p>
)}
{slot.note && (
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{slot.note}
</p>
)}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
</div>
</div>
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: ITEM_BORDER}} />
</div>
)}
</div>
);
}
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{schedule.name}
</h3>
<span className="text-[11px] opacity-60">
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{slots.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
)}
</div>
{slots.length === 0 ? (
<p
className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60"
style={{borderColor: ITEM_BORDER}}
>
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{slots.map((slot, index) => (
<Slot
key={`${slot.time}-${slot.title}-${index}`}
slot={slot}
isLast={index === slots.length - 1}
/>
))}
</div>
)}
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
}
export function ScheduleTimetable(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ScheduleItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="여행 스케줄" />
) : (
<div className="space-y-8">
{parsed.items.map((schedule, index) => (
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -5,11 +5,10 @@
* 숨기지 않고 라벨 아래 각인으로 드러낸다 — 제약이 곧 이 아이템의 인상이다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type SongItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
@ -17,7 +16,6 @@ import {
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
@ -28,7 +26,6 @@ export function SongsTurntable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
const parsed = parseSectionData<SongItem>(section.type, section.data);
const [playing, setPlaying] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 곡으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[playing] ?? parsed.items[0];
@ -145,46 +142,36 @@ export function SongsTurntable(props: SectionRenderProps) {
</div>
{/* 판 고르기 */}
<div className="space-y-2">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPlaying(index);
<Rail label="곡" tone="dark">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPlaying(index);
}}
aria-current={index === playing}
className="w-24 shrink-0 snap-center text-center"
>
<span
className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
boxShadow:
index === playing
? `0 0 0 2px ${ITEM_ACCENT}, 0 3px 10px rgba(0,0,0,.4)`
: undefined,
}}
aria-current={index === playing}
className="w-24 shrink-0 snap-center text-center"
>
<span
className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
boxShadow:
index === playing
? `0 0 0 2px ${ITEM_ACCENT}, 0 3px 10px rgba(0,0,0,.4)`
: undefined,
}}
/>
<span className="mt-2 block truncate text-[10px] leading-tight opacity-55">
{song.title}
</span>
</button>
))}
</div>
{parsed.items.length > 3 && (
<CarouselNav
onPrev={() => scrollBy(-1)}
onNext={() => scrollBy(1)}
tone="dark"
label="곡"
/>
)}
</div>
/>
<span className="mt-2 block truncate text-[10px] leading-tight opacity-55">
{song.title}
</span>
</button>
))}
</Rail>
</>
)}
</SectionBody>

View File

@ -0,0 +1,90 @@
/**
* 영상 — 캔버스.
*
* ★ **캔버스에서는 재생하지 않는다.** 에디터 미리보기 안에 유튜브 iframe 을 띄우면
* 섹션을 고를 때마다 남의 스크립트가 붙었다 떨어지고, 사장님이 편집 중에 광고가 돈다.
* 여기서는 표지와 자막만 보여주고, 재생은 발행본에서 한다.
* ★ 유튜브가 아닌 주소는 표지가 없다 — 그 사실을 그 자리에서 말한다.
*/
import {parseSectionData, youtubeId, youtubePoster, isVerticalVideo, type VideoItem} from '@o2o/shared';
import {Play} from 'lucide-react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {ITEM_BODY, ITEM_BORDER, ITEM_CARD, ITEM_HEADING, ParseError, PasteHint, SourceLine} from '../items/common';
import '../items/items.css';
export function VideoFrame(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<VideoItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="영상" />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{parsed.items.map((item, index) => {
const id = youtubeId(item.url);
const vertical = isVerticalVideo(item.url);
return (
<figure key={`${item.url}-${index}`} className="space-y-2">
<div
className="relative overflow-hidden border"
style={{
borderColor: ITEM_BORDER,
backgroundColor: ITEM_CARD,
aspectRatio: vertical ? '9 / 16' : '16 / 9',
maxHeight: vertical ? 420 : undefined,
marginInline: vertical ? 'auto' : undefined,
width: vertical ? 'min(100%, 236px)' : undefined,
}}
>
{id ? (
<>
<img
src={youtubePoster(id)}
alt=""
className="size-full object-cover"
loading="lazy"
/>
<span className="absolute inset-0 grid place-items-center">
<span className="grid size-12 place-items-center rounded-full bg-black/60 text-white">
<Play className="size-5" />
</span>
</span>
</>
) : (
<span className="grid size-full place-items-center px-4 text-center text-[11px] opacity-60">
유튜브 주소가 아닙니다 — 발행본에서는 링크로만 나갑니다
</span>
)}
</div>
{item.caption && (
<figcaption className="text-xs opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.caption}
</figcaption>
)}
<SourceLine source={item.source} verified={item.verified} />
</figure>
);
})}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -315,9 +315,7 @@ export const TOKEN_AWARE_VARIANT_IDS = new Set([
'photos.with-videos',
'map.detailed',
'map.compact',
'local.full',
'local.tabs',
'local.compact',
'local.guide',
'faq.accordion',
'faq.open-list',
'faq.two-column',

View File

@ -0,0 +1,113 @@
import {useEffect, useState} from 'react';
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
import {useBuilderStore} from '@/stores/builder';
export interface LocalGuideData {
foods: GuideCard[];
spots: GuideCard[];
festivals: FestivalCard[];
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
syncedAt?: string;
}
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []};
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
const MAX_PER_TYPE = 20;
/** 서버 응답 항목 — 발행 payload 의 LocalPlace / FestivalEntry 와 같은 모양(백엔드 ResLocalGuide 주석). */
interface GuidePlace {
name: string;
category: string;
searchQuery: string;
distanceText?: string;
distanceMeters?: number;
imageUrl?: string;
location?: string;
description?: string;
}
interface GuideFestival extends GuidePlace {
month: string;
period?: string;
officialUrl?: string;
}
function toCard(p: GuidePlace): GuideCard {
return {
name: p.name,
// 설명이 없으면 주소로 대신한다 — 카드에 이름만 덜렁 있는 것보다 어디인지가 낫다.
description: p.description ?? p.location ?? '',
searchQuery: p.searchQuery,
imageUrl: p.imageUrl,
distanceMeters: typeof p.distanceMeters === 'number' ? p.distanceMeters : undefined,
distanceText: p.distanceText,
};
}
function toFestival(f: GuideFestival): FestivalCard {
return {
...toCard(f),
month: f.month,
period: f.period ?? '',
officialUrl: f.officialUrl,
};
}
// 사업장 하나당 한 번만 받는다 — 필터·캐러셀 조작마다 API 를 다시 때리지 않는다.
const cache = new Map<string, LocalGuideData>();
/**
* 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 —
* 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다.
*
* ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다.
* 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다.
*/
export function useLocalGuide(): LocalGuideData {
const placeId = useBuilderStore((s) => s.placeId);
const [guide, setGuide] = useState<LocalGuideData>(() => (placeId && cache.get(placeId)) || EMPTY);
useEffect(() => {
if (!placeId) {
setGuide(EMPTY);
return;
}
const cached = cache.get(placeId);
if (cached) {
setGuide(cached);
return;
}
let alive = true;
const controller = new AbortController();
async function fetchGuide() {
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800';
const query = new URLSearchParams({place_id: placeId!});
const res = await fetch(`${baseUrl}/v1/local/guide?${query}`, {signal: controller.signal});
if (!res.ok) return;
const data = await res.json();
if (!data?.result?.success || !alive) return;
const next: LocalGuideData = {
foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
syncedAt: data.synced_at ?? undefined,
};
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면
// 수집이 끝나도 새로고침 전까지 계속 "준비 중"으로 보인다.
if (next.foods.length || next.spots.length || next.festivals.length) cache.set(placeId!, next);
setGuide(next);
} catch {
// 빈 목록 유지 — 화면에 에러를 띄우지 않는다.
}
}
void fetchGuide();
return () => {
alive = false;
controller.abort();
};
}, [placeId]);
return guide;
}

View File

@ -4,6 +4,9 @@
/* 디자인 토큰(색상·폰트·radius)은 shared 단일 소스에서 관리. site 와 공유 가능. */
@import "@o2o/shared/styles/tokens.css";
@import "@o2o/shared/styles/base.css";
/* 캔버스가 그리는 것은 발행본과 같은 사이트다 — 시안 토큰·유틸을 같은 파일에서 읽는다.
규칙은 전부 `.site-canvas` 안으로 스코프돼 있어 관리자 크롬에는 닿지 않는다. */
@import "@o2o/shared/styles/site.css";
body {
background-color: var(--background);

View File

@ -7,7 +7,8 @@
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./styles/tokens.css": "./src/styles/tokens.css",
"./styles/base.css": "./src/styles/base.css"
"./styles/base.css": "./src/styles/base.css",
"./styles/site.css": "./src/styles/site.css"
},
"dependencies": {
"clsx": "^2.1.1",

View File

@ -34,11 +34,12 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string;
surfaceAlt: colors.card,
inverse: '#1c1917',
/**
* 선 색.
* 선은 **바탕에 글자색을 조금 섞어** 만든다.
*
* ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라
* 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히).
* 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e).
* ★ 예전에는 `colors.secondary` 를 그대로 썼다. secondary 는 보조 **글자색**이라
* 레트로에서 #4c4739(짙은 갈흑)이 선이 됐다 — 갱지 위에 검은 줄이 그어져
* 인쇄물이 아니라 표가 됐다. 아티팩트의 선은 #c0b493 이다.
* 바탕에서 20% 만 당기면 어느 팔레트에서도 '한 톤 진한 같은 종이'가 된다.
*/
border: mix(colors.bg, colors.text, 0.2),
};

View File

@ -49,6 +49,22 @@ function formatNumeric(raw: string): string {
}
/** bool 타입 fact. 미검증이면 undefined — false 로 떨어뜨리지 않는다(가려진 것과 "아니오"는 다르다). */
/**
* 표에 쓸 fact 이름표.
*
* ★ "주차 가능 | 가능" 을 없앤다 (2026-09-04, 사장님 지적)
* 이름표가 이미 '가능' 으로 끝나는데 값도 '가능' 이라 같은 말이 두 번 섰다.
* 꼬리를 떼면 "주차 | 가능" 이 된다 — 표는 원래 그렇게 읽는다.
* ★ bool 일 때만 뗀다. '바비큐 이용료' 처럼 값이 숫자인 칸은 이름이 곧 뜻이다.
* ★ **shared 에 두는 이유**: 화면(derive)과 구조화 데이터(seo/jsonld)가 같은 이름표를 써야 한다.
* 갈리면 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다.
* 렌더러 쪽 한 곳에 두면 jsonld → derive → jsonld 순환 참조가 된다.
*/
export function displayFactLabel(fact: FactEntry): string {
if (fact.type !== 'bool') return fact.label;
return fact.label.replace(/\s*(가능|여부)$/, '') || fact.label;
}
export function factBool(facts: FactEntry[], key: string): boolean | undefined {
const value = factValue(facts, key);
if (value == null) return undefined;

View File

@ -85,14 +85,16 @@ export interface PeopleItem {
role?: string;
oneLine?: string;
/**
* 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다.
* 사진 검색어. 사진을 못 구했을 때 어디서 찾을지만 남긴다 —
* 모델이 지어낸 주소를 그대로 링크하면 깨진 사진이 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
*/
imageQuery?: string;
/**
* 초상 사진.
*
* ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나,
* 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다.
* 사진. 있으면 필름 프레임에 들어가고, 없으면 지금처럼 이니셜 활자가 그 자리를 지킨다 —
* 빈 회색 상자를 만들지 않는다(LocalPlace.imageUrl 과 같은 규칙).
* ★ 우리가 찾아 붙이는 사진이 아니다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것 중
* **상업적 이용을 허용하는 저작권 유형만** 수집 단계에서 담는다
* (`services/external/tour_places.py` 의 `_IMAGE_OK`).
*/
imageUrl?: string;
verified?: DataVerified;
@ -105,10 +107,10 @@ export interface ChronicleItem {
title: string;
summary?: string;
place?: string;
/** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
/** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -132,7 +134,7 @@ export interface PostcardItem {
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
/** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */
/** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
@ -148,54 +150,79 @@ export interface QuizItem {
source?: DataSource;
}
export interface ItineraryStop {
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
/**
* 좌표.
*
* ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이
* 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다.
* 없으면 지도를 그리지 않고 타임라인만 낸다.
*/
latitude?: number;
longitude?: number;
/** 사진. 권리가 확실한 출처만. */
imageUrl?: string;
}
/**
* 추천 일정 — 반나절 · 1박2일 · 2박3일.
*
* ★ 왜 하나로 합쳤나
* 한때 코스가 셋이었다 — 반나절 산책(course) · 여행 스케줄(schedule) · 계절별 추천 하루(planner).
* 축이 다르다고 나눠 뒀는데 실제 데이터를 보니 **같은 장소를 세 번 나열**하고 있었다
* (째보선창이 셋 중 둘에, 초원사진관도 마찬가지). 손님에게는 "군산에서 어디를 도나"
* 하나의 질문이고, 다른 건 **며칠짜리인가** 뿐이다. 그래서 축을 기간으로 바꿨다.
* ★ 순위를 매기지 않는다. 어느 코스가 1위인지는 우리가 정할 일이 아니다.
*/
export interface ItineraryDay {
label: string;
/** "첫째 날". 없으면 순번으로 만든다. */
label?: string;
/** 그날 나서는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */
startTime?: string;
stops?: ItineraryStop[];
stops?: PlannerStop[];
}
/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */
export interface ItineraryItem {
name: string;
/** "반나절" · "1박 2일" · "2박 3일". 이 값이 목록을 가르는 축이다. */
duration?: string;
audience?: string;
/** 왜 이 일정인가. 고르는 근거 한 문장. */
why?: string;
/**
* 계절. 적으면 그 계절에만 손님 화면에 나간다(`inSeason`).
* 겨울에 벚꽃 코스를 권하지 않기 위한 것이지, 목록을 계절로 가르기 위한 게 아니다.
*/
season?: string;
/** 하루 이상이면 이 배열을 쓴다. */
days?: ItineraryDay[];
/** 하루짜리는 days 없이 이것만 써도 된다 — 한 덩이로 본다. */
stops?: PlannerStop[];
startTime?: string;
verified?: DataVerified;
source?: DataSource;
}
/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */
export interface EventItem {
kind?: string;
title: string;
summary?: string;
body?: string;
verified?: DataVerified;
}
export interface VideoItem {
/** 유튜브 주소. watch · youtu.be · shorts · embed 넷 다 받는다. */
url: string;
caption?: string;
verified?: DataVerified;
source?: DataSource;
}
/**
* 이벤트·소식 — 사장님이 인스타에 올리는 그때그때의 행사.
*
* ★ 인스타를 읽어 오지 않는다. 계정 페이지는 로그인·봇 탐지 뒤에 있고, 우회는 영구 금지다
* (DECISIONS 1-1). 사장님이 **본인 게시물을 옮겨 적고 원문 주소를 단다** —
* 손님은 우리 요약을 읽고, 정확한 것은 원문에서 본다.
* ★ 날짜는 'YYYY-MM-DD'. 끝 날짜가 없으면 상시로 본다.
*/
export interface EventItem {
title: string;
/** '이벤트' 면 진행 여부 배지가 붙고, '공지' 면 그냥 공지다. 없으면 이벤트로 본다. */
kind?: '이벤트' | '공지';
startDate?: string;
endDate?: string;
/** 무엇을 주는 행사인가 — 한두 문장. 카드에 보이는 요약이다. */
summary?: string;
/** 긴 본문. 공지는 지켜야 할 것이 여러 줄이라 요약 한두 문장으로는 안 된다.
* 카드에서는 잘려 보이고 모달에서 전부 펴진다 — 잘리는 건 화면뿐, 문서에는 다 있다. */
body?: string;
/** 어떻게 참여하나. "예약 시 요청사항에 '9월 이벤트' 라고 적어 주세요" 같은 것. */
howTo?: string;
/** 원문(인스타 게시물) 주소. */
postUrl?: string;
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface PlannerStop {
@ -205,7 +232,16 @@ export interface PlannerStop {
/** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */
moveMinutes?: number;
note?: string;
/** 정거장 사진. 없으면 승차권은 지금처럼 활자만으로 선다(PeopleItem.imageUrl 과 같은 출처·규칙). */
imageUrl?: string;
searchQuery?: string;
/**
* 지도에 핀을 찍을 좌표. **둘 다 있어야** 쓴다 — 하나만 있으면 없는 것으로 본다.
* 사장님이 손으로 적을 값이 아니다(발행 때 지오코딩으로 채울 자리). 없으면 그 칸은
* 핀 없이 시간표에만 선다 — 틀린 핀을 찍느니 안 찍는다(LocationSection 과 같은 규칙).
*/
latitude?: number;
longitude?: number;
}
export interface PlannerItem {
@ -241,16 +277,61 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
postcard: 'line',
quiz: 'question',
planner: 'name',
// ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다.
itinerary: 'name',
event: 'title',
video: 'url',
event: 'title',
};
/**
* 유튜브 주소 → 영상 id.
*
* ★ **유튜브가 아니면 undefined 다.** 아무 주소나 iframe 에 넣으면 남의 페이지를 우리 도메인
* 안에서 여는 통로가 된다. 알아보는 형식만 재생하고, 나머지는 링크로만 남긴다.
* ★ 네 가지 형식을 받는다 — 사장님이 복사해 오는 자리가 그때그때 다르다:
* `watch?v=ID` · `youtu.be/ID` · `shorts/ID`(모바일 공유) · `embed/ID`.
*/
export function youtubeId(url: string | undefined): string | undefined {
const text = (url ?? '').trim();
if (!text) return undefined;
const match =
/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec(
text,
);
return match?.[1];
}
/**
* 세로 영상인가.
*
* ★ 쇼츠는 9:16 이다. 16:9 틀에 넣으면 좌우가 까맣게 비고 영상이 손톱만 해진다 —
* 주소가 이미 말해 주는 것을 무시하지 않는다.
*/
export function isVerticalVideo(url: string | undefined): boolean {
return /youtube\.com\/shorts\//.test((url ?? '').trim());
}
/** 재생 전에 보여줄 표지. 유튜브가 영상마다 만들어 두는 것이라 우리가 만들지 않는다. */
export function youtubePoster(id: string): string {
return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
}
/**
* 재생용 주소.
*
* ★ `youtube-nocookie.com` 을 쓴다. 손님이 재생을 누르기 전에는 아무것도 안 붙고,
* 눌러도 광고 추적 쿠키를 먼저 심지 않는다.
*/
export function youtubeEmbed(id: string): string {
return `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&modestbranding=1`;
}
export interface ParsedSectionData<T> {
items: T[];
title?: string;
subtitle?: string;
/** 섹션 머리에 거는 바깥 링크. 그 섹션을 만든 서비스로 보내는 자리다(예: 영상 → ADO2). */
linkUrl?: string;
linkLabel?: string;
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
error?: string;
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
@ -352,6 +433,11 @@ export function parseSectionData<T extends object>(
items,
title: typeof envelope.title === 'string' ? envelope.title : undefined,
subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined,
// http(s) 만 받는다 — javascript: 를 그대로 <a href> 에 실으면 붙여넣은 JSON 이 스크립트가 된다.
linkUrl: typeof envelope.linkUrl === 'string' && /^https?:\/\//.test(envelope.linkUrl)
? envelope.linkUrl
: undefined,
linkLabel: typeof envelope.linkLabel === 'string' ? envelope.linkLabel : undefined,
kindMismatch: kind && kind !== sectionType ? kind : undefined,
unverified,
sourced,
@ -471,6 +557,60 @@ export function currentSeasons(now: Date = new Date()): string[] {
}
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
/**
* 이 항목이 지금 나갈 계절인가.
*
* ★ 계절을 가진 아이템은 셋이다 — 계절별 추천 하루(planner) · 여행 스케줄(schedule) ·
* 일력(daily). 한동안 planner 에만 걸려 있었다. "계절별 콘텐츠는 계절 따라 나간다"는
* 규칙은 아이템 하나가 아니라 계절을 적은 모든 아이템의 규칙이다.
* ★ 계절을 안 적은 항목은 **계절을 타지 않는 것**으로 본다 — 가려서는 안 된다.
* ★ schedule 의 season 은 자유 문구다("장마", "여름 장마"). 네 계절 이름이 섞여 있으면
* 그 계절로 보고, 우리가 못 읽는 값("장마")은 가리지 않는다 — 분류 못 하는 것을
* 숨기면 사장님이 쓴 콘텐츠가 아무 계절에도 안 나가는 일이 생긴다.
*/
export function inSeason(season: string | undefined, live: string[]): boolean {
const text = season?.trim();
if (!text) return true;
const named = SEASON_ORDER.filter((name) => text.includes(name));
if (named.length === 0) return true;
return named.some((name) => live.includes(name));
}
/**
* 일정 한 건 → 일자별로 시각이 박힌 결과.
*
* ★ `days` 가 없으면 하루짜리로 본다 — 반나절 코스가 그렇다.
* ★ 시각 계산은 `planDay` 한 벌이다. 하루짜리와 여러 날짜리가 다른 식을 쓰면
* 같은 코스가 자리에 따라 다른 시각을 낸다.
*/
export function itineraryDays(item: ItineraryItem): {label: string; day: PlannedDay}[] {
const raw =
item.days && item.days.length > 0
? item.days
: [{label: undefined, startTime: item.startTime, stops: item.stops}];
return raw
.map((entry, index) => ({
label: entry.label?.trim() || (raw.length > 1 ? `${index + 1}일차` : ''),
day: planDay({name: item.name, startTime: entry.startTime, stops: entry.stops}),
}))
.filter((entry) => entry.day.stops.length > 0);
}
/**
* 기간별 묶음. 목록의 축이다.
*
* ★ 적힌 순서를 지킨다 — 사장님이 쓴 순서가 곧 권하는 순서다.
* 기간을 안 적은 일정은 맨 뒤 한 덩이로 모은다(빈 소제목을 만들지 않는다).
*/
export function itineraryDurations(items: ItineraryItem[]): string[] {
const seen: string[] = [];
for (const item of items) {
const key = item.duration?.trim();
if (key && !seen.includes(key)) seen.push(key);
}
return seen;
}
export function plannerSeasons(items: PlannerItem[]): string[] {
const seen: string[] = [];
for (const item of items) {

View File

@ -36,16 +36,17 @@ body {
scrollbar-width: none;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, currentColor 18%, transparent);
border-radius: 9999px;
/*
* 스크롤바.
*
* ★ `::-webkit-scrollbar` 로 폭을 주면 **macOS 의 오버레이 스크롤바가 꺼진다.**
* 그러면 스크롤바가 자리를 차지하는 옛날 방식으로 바뀌어, 맥에서 화면 오른쪽에
* 6px 짜리 빈 띠가 늘 생긴다(실측: innerWidth 1440 ↔ clientWidth 1434).
* 표준 속성만 쓰면 맥은 오버레이를 유지하고(자리 차지 0), 윈도우·리눅스에서는
* 가는 스크롤바가 된다.
*/
html {
scrollbar-width: thin;
}
/* 스크린리더 전용 — 화면에서 감추되 접근성 트리에는 남긴다.

View File

@ -0,0 +1,258 @@
/*
* 발행본과 캔버스가 함께 읽는 사이트 디자인 토큰·유틸.
*
* ★ 왜 shared 로 뺐나 (2026-09-09)
* 같은 시안을 두 앱이 각자 구현하고 있었다. 발행본(`solution/site`)에는 유동 타이포와
* `.shell` `.h2` `.panel` 이 있는데 캔버스(`solution/frontend` 빌더)에는 아예 없어서,
* 사장님이 에디터에서 본 화면과 발행된 화면이 **구조부터** 달랐다. 값을 양쪽에 적으면
* 한쪽만 고쳐지고 다시 갈라진다 — 파일 하나를 둘이 읽는다.
*
* ★ 값은 시안(/s/stay)을 구운 그 값 그대로다. 여기를 손대면 시안과 어긋난다.
*
* ★ 스코프가 두 개인 이유
* 발행본은 페이지 전체가 사이트다(`<body class="site">`). 캔버스는 관리자 화면 **안의
* 일부**라(`.site-canvas`), 여기 규칙이 관리자 크롬으로 새면 안 된다. 실제로 `.text-muted`
* 는 관리자 화면 45곳이 쓰고 있어서 전역에 풀면 그쪽 색이 바뀐다.
*
* ★ 색 토큰에 `--site-` 접두어를 쓴다.
* `--color-muted` 는 관리자 토큰(`tokens.css`)이 이미 다른 뜻으로 쓰는 이름이다.
* 같은 이름을 공유 파일에 두면 캔버스를 띄운 화면에서 관리자 색이 조용히 덮인다.
*/
/*
* 글자 크기는 뷰포트를 따라 흐른다(clamp).
*
* ★ 왜 단계별 `sm:text-3xl lg:text-4xl` 을 그만뒀나
* 중단점 사이에서 크기가 안 변해, 태블릿 가로(1024px)에서는 모바일 크기의 제목이
* 1000px 폭에 덩그러니 놓였다. 그리고 본문이 전부 `text-xs`(12px)였다 —
* 데스크톱에서 12px 는 읽으라고 만든 크기가 아니다. 화면 폭과 함께 늘린다.
*/
:where(.site, .site-canvas) {
/*
* ★ 한 번 더 줄였다. 앞선 값(제목 최대 38px · 히어로 64px)은 서구 편집형 스케일이다.
* 국내 숙박·공간 사이트는 제목이 24~28px, 히어로가 40px 안팎이고 대신 정보 밀도가 높다.
* 큰 활자 + 넓은 여백은 국내 사용자에게 '내용이 없는 페이지'로 읽힌다.
*/
--fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem);
--fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem);
--fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem);
--fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem);
--fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem);
--fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem);
--fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem);
/*
* 섹션 세로 여백.
* 템플릿 값(`--tpl-section-space`)을 상한으로 두고 화면이 좁아지면 줄인다 —
* 매거진의 5rem 을 모바일에 그대로 주면 한 화면에 제목만 남는다.
*/
--section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem));
}
/*
* 선 색은 **둘레 글자색에서 뽑는다.**
* 예전에는 전 섹션이 `border-black/8` 이었다 — 어두운 바탕(푸터·필름·플립보드)에서
* 검은 선은 그냥 안 보인다. currentColor 를 섞으면 밝은 면에서는 진해지고
* 어두운 면에서는 밝아져, 어떤 팔레트에서도 선이 남는다.
*/
:where(.site, .site-canvas) {
--site-line: color-mix(in oklab, currentColor 14%, transparent);
--site-line-soft: color-mix(in oklab, currentColor 8%, transparent);
/* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */
--site-muted: color-mix(in oklab, currentColor 62%, transparent);
}
/* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 —
레트로면 간판체, 심플이면 고딕이다. 토큰이 없으면 예전처럼 명조로 떨어진다. */
:where(.site, .site-canvas) .serif {
font-family: var(--tpl-font-heading, var(--font-serif));
letter-spacing: var(--tpl-heading-tracking, normal);
}
/*
* ★ 가짜 굵게를 끈다 (2026-09-04, 사장님: "폰트가 헤더랑 맞지 않는다")
* 제목 서체는 템플릿이 정한다. 그런데 굵기가 한 벌뿐인 얼굴이 있다 —
* 1안의 옛 간판체(Gugi)가 **400 하나뿐**이다. 거기에 font-bold(700)를 걸면 브라우저가
* 획을 부풀려 그리고(synthetic bold), 같은 서체인데 그 자리만 뭉개져 보인다.
* 실측: 헤더 워드마크 · 아이템 카드 제목 등 13곳이 그 상태였다.
* ★ 마크업을 열세 번 고치지 않고 여기서 끈다. 700 이 실제로 있는 얼굴
* (Gowun Batang · Noto Serif KR)은 아무것도 달라지지 않는다 —
* 없는 굵기를 지어내지 말라는 말이지, 굵게 쓰지 말라는 말이 아니다.
*/
:where(.site, .site-canvas) :is(.serif, .h2, .h3) {
font-synthesis-weight: none;
}
/* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */
:where(.site, .site-canvas) .tpl-border {
border-width: var(--tpl-border-width, 1px);
}
/* 카드 그림자. 레트로는 각진 오프셋, 매거진은 none. */
:where(.site, .site-canvas) .tpl-shadow {
box-shadow: var(--tpl-shadow, none);
}
/*
* 섹션 공통 폭.
*
* ★ 1120px 고정이었다. 1440px 화면에서는 좌우가 비고, 그 안에서 다시 `max-w-3xl` 로
* 묶은 카드들이 한 번 더 좁아져 오른쪽 절반이 통째로 죽었다.
* 폭은 여기 한 곳에서만 정하고, 섹션은 이 폭을 다 쓴다.
*/
:where(.site, .site-canvas) .shell {
width: 100%;
max-width: 78rem; /* 1248px */
margin-inline: auto;
padding-inline: clamp(1rem, 4vw, 2.5rem);
}
/* 카드 안 설명 — 넘치면 자른다. 카드 높이가 제각각이면 격자가 무너진다. */
:where(.site, .site-canvas) .line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 읽는 글(소개문·답변)만 한 줄 길이를 제한한다 — 90자 줄은 눈이 돌아온 자리를 잃는다. */
:where(.site, .site-canvas) .measure {
max-width: 68ch;
}
/*
* 바탕 질감(레트로의 갱지 결).
*
* ★ 화면 전체를 덮는 오버레이로 두지 않는다 — multiply 로 깔면 사진까지 누렇게 뜬다.
* `background-image` 는 그 요소의 배경색 **위**, 내용 **아래**에 깔리므로
* 종이에만 결이 앉고 사진은 그대로다.
*/
:where(.site, .site-canvas) .paper {
background-image: var(--tpl-texture, none);
}
/*
* 카드·패널 한 면.
*
* ★ 바탕색을 팔레트에서 **고르지 않고** 둘레 글자색을 한 겹 씌워 만든다.
* 예전에는 카드가 `--tpl-card` 였는데 그 값이 곧 `--tpl-surface-alt`(섹션 바탕 두 번째)라,
* alt 섹션 위의 카드는 섹션과 **같은 색**이었다 — 테두리 말고는 카드로 안 보였다.
* 반대로 base 섹션 위에서는 팔레트에 따라 카드가 섹션보다 밝기도 어둡기도 했다.
* 글자색을 섞으면 어느 섹션 위에서도 방향이 같다 — 밝은 면에서는 한 단 가라앉고,
* 어두운 면에서는 한 단 뜬다.
* ★ 모서리·테두리 두께·그림자는 템플릿이 정한다(레트로는 각지고 그림자가 각인처럼 찍힌다).
*/
:where(.site, .site-canvas) .panel {
background-color: color-mix(in oklab, currentColor 5%, transparent);
border: var(--tpl-border-width, 1px) solid var(--site-line);
border-radius: var(--tpl-radius, 0.75rem);
box-shadow: var(--tpl-shadow, none);
}
/* 패널 안에서 한 단 더 들어간 면(표 머리·강조 줄). */
:where(.site, .site-canvas) .panel-sunken {
background-color: color-mix(in oklab, currentColor 6%, transparent);
}
/* 제목 3종. 크기는 clamp 가 정하고, 굵기·자간은 템플릿이 정한다. */
:where(.site, .site-canvas) .h2 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h2);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.01em);
line-height: 1.2;
}
:where(.site, .site-canvas) .h3 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h3);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.005em);
line-height: 1.35;
}
/*
* 작은 라벨.
*
* ★ `text-transform: uppercase` + 영문 눈썹(ABOUT · GALLERY · LOCATION …)을 걷어냈다.
* 섹션마다 영문 대문자 라벨을 다는 건 **생성형 템플릿의 표식**이고, 국내 사이트는
* 그렇게 쓰지 않는다 — 한글 제목 하나로 끝낸다. 이 클래스는 표 안의 항목 이름처럼
* 진짜 라벨이 필요한 자리에만 남긴다.
*/
:where(.site, .site-canvas) .label {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--site-muted);
}
/*
* 터치 표적 최소 크기.
* 아이콘 버튼은 눈에 보이는 크기(20px)와 누를 수 있는 크기(44px)가 달라야 한다 —
* iOS·Android 접근성 지침의 하한이 44px 다.
*/
:where(.site, .site-canvas) .tap {
min-width: 2.75rem;
min-height: 2.75rem;
}
/*
* 터치 기기에서만 보이는 것(티맵처럼 앱 스킴밖에 없는 링크).
* ★ 화면 폭으로 가르지 않는다 — 좁은 창의 PC 에서도 앱은 없다. 포인터 종류로 가른다.
*/
:where(.site, .site-canvas) .only-touch {
display: none;
}
@media (hover: none) and (pointer: coarse) {
:where(.site, .site-canvas) .only-touch {
display: flex;
}
}
/* 하단 고정 탭 — 아이폰 홈 인디케이터를 피한다. 예전엔 클래스만 있고 정의가 없었다. */
:where(.site, .site-canvas) .safe-b {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
}
/*
* 가로 슬라이더.
*
* ★ 스크립트가 뜨기 전(그리고 안 뜨는 크롤러에게)에는 그냥 가로 스크롤 상자다 —
* 내용이 HTML 에 다 있어야 인용된다. embla 가 붙으면 `data-slider="on"` 이 걸리고
* 그때부터 드래그·화살표가 움직인다.
*/
:where(.site, .site-canvas) .slider-viewport {
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
:where(.site, .site-canvas) .slider-viewport::-webkit-scrollbar {
display: none;
}
:where(.site, .site-canvas) .slider-viewport[data-slider="on"] {
overflow-x: hidden;
scroll-snap-type: none;
}
:where(.site, .site-canvas) .slider-track {
display: flex;
}
:where(.site, .site-canvas) .slider-track > * {
scroll-snap-align: start;
}
/* 드래그 중인 슬라이더 위에서 텍스트가 잡히면 카드가 안 끌린다. */
:where(.site, .site-canvas) .slider-viewport[data-dragging="true"] {
cursor: grabbing;
user-select: none;
}
/*
* 흐린 글자·선 유틸.
*
* ★ 발행본에서는 Tailwind 가 `@theme inline` 의 --color-muted/--color-line 으로 같은 유틸을
* 만들어 준다(값 동일). 캔버스 쪽 Tailwind 는 그 이름을 관리자 뜻으로 이미 쓰고 있어
* 유틸을 만들어 주지 않으므로 여기에 명시 정의를 둔다 — 없으면 캔버스에서만
* 흐린 글자와 선이 통째로 죽는다(발행본 실측: text-muted 188곳 · border-line 101곳).
*/
:where(.site, .site-canvas) .text-muted {
color: var(--site-muted);
}
:where(.site, .site-canvas) .border-line {
border-color: var(--site-line);
}
:where(.site, .site-canvas) .divide-line > :not(:last-child) {
border-color: var(--site-line);
}

View File

@ -107,10 +107,10 @@ export interface TemplateLook {
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
/**
* 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`).
* 바탕 질감. CSS `background-image` 값 그대로다(갱지 결·격자).
*
* ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서
* 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다.
* ★ 색·서체만으로는 '인쇄물'이 안 된다. 레트로의 정체성 절반이 이 갱지 결이라
* 토큰으로 내려보낸다 — 없는 템플릿은 이 칸을 비우면 그만이다.
*/
texture?: string;
}

View File

@ -164,39 +164,71 @@ export interface ChannelLink {
confirmed: boolean;
}
import type {ItineraryItem} from '../lib/section-data';
export interface LocalContents {
weather?: WeatherSnapshot;
attractions: LocalPlace[];
restaurants: LocalPlace[];
festivals: FestivalEntry[];
/**
* 업장 좌표로 **서버가 조립한** 추천 일정(1박2일·2박3일).
*
* ★ 사장님이 붙여넣는 아이템(`theme.sections[].data`)과 자리를 나눈다 — 이건 우리가
* 만든 값이라 payload 의 서버 영역에 둔다. 화면은 사장님 값을 먼저 보고, 없을 때
* 이걸 쓴다(`ItinerarySection`).
* ★ 저장하지 않는다. 재료(주변 정보)가 갱신되면 다음 빌드에서 저절로 최신이 된다.
*/
itineraries?: ItineraryItem[];
/** 지역 정보를 마지막으로 갱신한 시각. 화면에 그대로 노출한다(오래된 정보를 숨기지 않는다). */
syncedAt?: string;
}
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
export interface WeatherSnapshot {
temperature: number;
condition: string;
/** 날씨에 따른 안내 문구. LLM 문장이므로 사실 주장(가격·시간)을 담지 않는다. */
note?: string;
/**
* 날씨별 한 줄.
*
* ★ `note` 한 칸으로는 안 된다 (2026-09-04, 사장님: "저렇게만 말고 콘텐츠가 있어야")
* 발행본은 정적이라 굽는 순간의 문장이 박히는데, 날씨는 브라우저에서 갱신된다.
* 비 오는 날 "마당에 앉기 좋습니다"가 뜨는 사고가 난다. 그래서 **조건별로 미리 적어 두고**
* 화면이 지금 날씨에 맞는 것을 고른다.
* ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다.
*/
notes?: Partial<Record<WeatherMood, string>>;
observedAt: string;
}
export interface LocalPlace {
name: string;
category: string;
/** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */
distanceText?: string;
/**
* 업장 좌표 기준 거리(m).
*
* ★ 사람이 읽는 `distanceText` 와 따로 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를
* 계산하려면 숫자가 필요하고, "1.2km" 를 화면이 다시 파싱하면 백엔드와 규칙이 갈린다.
*/
distanceMeters?: number;
durationText?: string;
description?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
/**
* 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다.
* 대표 사진.
*
* ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다.
* ★ 이 칸이 없어서 주변 안내가 이름만 적힌 상자였다 — 아무도 보지 않는다.
* ★ 우리가 찾아 붙이는 사진이 아니다. 출처(공공데이터)가 그 장소의 사진으로 준 것만 싣고,
* **저작권 유형이 상업적 이용을 막는 건은 수집 단계에서 뺀다**(services/external/tour_places).
*/
imageUrl?: string;
/** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */
distanceMeters?: number;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
}
export interface FestivalEntry {
@ -207,7 +239,16 @@ export interface FestivalEntry {
description?: string;
officialUrl?: string;
searchQuery: string;
/** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */
/**
* 봄·여름·가을·겨울. 축제를 계절로 묶어 보여주려면(FestivalSection) 화면이 날짜를
* 다시 계절로 환산해야 하는데, 그 규칙이 렌더러에 생기면 수집 쪽과 갈라진다.
* 시작일에서 한 번만 정해 payload 에 싣는다. 비어 있으면 계절을 타지 않는 행사로 본다.
*/
season?: string;
/** 시작·종료일(YYYY-MM-DD). `period` 는 사람이 읽는 문구라 기계가 못 읽는다 — 정렬·계절 산출의 근거. */
startDate?: string;
endDate?: string;
/** 대표 사진. `LocalPlace.imageUrl` 과 같은 규칙 — 출처가 준 것만, 저작권 유형이 상업적 이용을 허용할 때만. */
imageUrl?: string;
}

View File

@ -17,7 +17,7 @@
/>
<title>발행 사이트 미리보기 (dev)</title>
</head>
<body>
<body class="site">
<div id="root"></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>

View File

@ -329,7 +329,10 @@ function prerenderSite(
' <head>',
head,
' </head>',
' <body>',
/* ★ class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스
안에서만 산다. 빼면 유동 타이포와 .shell·.h2 가 통째로 죽어 글자 크기가 본문으로 떨어진다.
빌더 캔버스는 같은 규칙을 `.site-canvas` 로 받는다. */
' <body class="site">',
` <div id="root">${appHtml}</div>`,
` <script>window.__SITE_PAYLOAD__=${serializePayload(payload)}</script>`,
' </body>',

Some files were not shown because too many files have changed in this diff Show More