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