-- 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 는 지우지 않는다. 읽는 쪽이 새 테이블로 다 옮겨간 뒤에 별도 번호로 뗀다 — -- 한 번에 갈아엎으면 되돌릴 자리가 없다.