o2o-site-AEO/postgres-init/migrations/0004_local_contents_unify.sql
Mina Choi 64ce467f21 [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>
2026-09-09 17:08:02 +09:00

103 lines
6.7 KiB
SQL

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