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