도메인별 스키마(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>
76 lines
4.8 KiB
SQL
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 으로 떨어지게 한 뒤, 별도 번호로 뗀다.
|