-- 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 는 남긴다. 읽는 코드가 새 구조로 옮겨간 뒤 별도 번호로 뗀다 — -- 한 번에 갈아엎으면 되돌릴 자리가 없다.