o2o-site-AEO/postgres-init/migrations/0003_site_contents.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

76 lines
4.8 KiB
SQL

-- 0003 · 섹션 콘텐츠를 `sites.theme` 에서 꺼내 행으로
--
-- 지금은 색·서체(디자인)와 섹션별 콘텐츠가 `site.sites.theme` JSONB 한 칸에 같이 있다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고
-- 나머지 41,500 B 가 섹션이다. 그중 콘텐츠(sections[].data)만 39,645 B — 94%.
-- 가장 큰 섹션 하나(itinerary)가 17,990 B 로, 디자인 전체의 28 배다.
--
-- 크기가 문제인 게 아니다(JSONB 는 1GB 까지 든다). 문제는 **쓰기 단위**다:
-- · 사장님이 영상 주소 하나(592 B)를 고쳐도 42 KB 를 통째로 다시 쓴다
-- · 같은 사이트를 둘이 만지면 나중 쓰기가 앞을 통째로 덮는다
-- · 항목마다 "누가 넣었나 · 확인됐나"를 물을 자리가 없다(facts 는 status 를 갖는다)
-- · 검증이 없어 렌더러에 존재하지도 않는 섹션이 남는다
-- (실측: 조이모텔에 course·schedule — 켤 수는 있는데 화면엔 아무 일도 안 일어난다)
--
-- ★ 이 테이블이 JSON import/export 의 단위다. 사이트 하나 = 이 행 묶음 + theme(디자인).
CREATE TABLE IF NOT EXISTS site.site_contents (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL, -- site.sites.site_id
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' …
data JSONB NOT NULL, -- 그 섹션의 항목 배열(shared 의 XxxItem[] 계약)
source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 3=crawl 4=llm
-- ★ 공유 콘텐츠는 값을 복제하지 않고 **id 로 가리킨다**(축제·지역 이야기).
-- 가리키는 동안 data 는 비어 있을 수 있다 — 발행할 때 원본을 펼쳐 payload 에 싣는다.
shared_ref uuid NULL,
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축: 3=verified 4=corrected 만 노출
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 한 사이트의 한 섹션은 한 행이다. 순서·on/off 는 theme 이 갖는다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section
ON site.site_contents (site_id, section_id) WHERE deleted = FALSE;
-- 지역 이야기 — 가요·인물·연표·엽서·퀴즈는 **업장이 아니라 지역의 것**이다.
-- 군산 이야기는 군산 숙소가 같이 쓴다. 지금은 만들 자리가 아예 없어서
-- 시안(/s/stay)에는 사람이 3만 자를 손으로 넣었다.
CREATE TABLE IF NOT EXISTS local.region_stories (
region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
region_code VARCHAR(10) NOT NULL, -- 카카오 행정구역 코드
kind VARCHAR(50) NOT NULL, -- 'songs' 'people' 'chronicle' 'postcard' 'quiz'
data JSONB NOT NULL,
source_type SMALLINT NOT NULL DEFAULT 4, -- 4=llm 1=운영자
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 검수 전에는 안 나간다
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_region_stories_kind
ON local.region_stories (region_code, kind) WHERE deleted = FALSE;
-- 이미 theme 에 박혀 있는 콘텐츠를 옮긴다.
-- ★ data 는 문자열로 저장돼 있다(서버가 파싱하지 않는 규약) — JSONB 로 되돌린다.
-- 깨진 JSON 은 옮기지 않는다. 원본은 theme 에 남으므로 잃지 않는다.
INSERT INTO site.site_contents (site_id, section_id, data, source_type, status, sort_order)
SELECT s.site_id,
sec->>'id',
(sec->>'data')::JSONB,
1, -- 사장님이 넣은 것으로 본다
3, -- 이미 발행에 쓰이던 값이라 verified
ordinality - 1
FROM site.sites s
CROSS JOIN LATERAL jsonb_array_elements(s.theme->'sections') WITH ORDINALITY AS t(sec, ordinality)
WHERE s.deleted = FALSE
AND s.theme ? 'sections'
AND sec->>'data' IS NOT NULL
AND sec->>'data' <> ''
AND jsonb_typeof((sec->>'data')::JSONB) IS NOT NULL
ON CONFLICT DO NOTHING;
-- ★ theme.sections[].data 는 지우지 않는다. 읽는 쪽(site_payload._theme)이 새 테이블을
-- 먼저 보고 없을 때만 theme 으로 떨어지게 한 뒤, 별도 번호로 뗀다.