o2o-site-AEO/postgres-init/init-data/init.sql
Mina Choi 3910feddfb [fix] postgres-init,solution/backend: init.sql 이 스키마 해체를 안 따라왔다 — 새 DB 가 옛 구조로 섰다
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데 `init.sql` 은 94줄이 **추가**만 됐고
삭제가 0줄이었다. 그래서 이 파일 한 벌로 세운 DB 는 `place.place_links`·`job.jobs` 를 갖고
ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 — 기동은 정상이고 첫 쿼리에서 죽는다.
"init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을 유지한다"(migrations/README.md)는
계약이 깨져 있었다. 이걸 잡아야 할 test_schema_ddl.py 는 로컬 DB 인증 실패로 5건 전부
error 라 안 돌고 있어서 안 걸렸다.

- init.sql: public 한 벌 · 표 14개로 다시 썼다. 옛 도메인 스키마가 있는 DB 에서 다시 돌면
  RAISE EXCEPTION 으로 멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation
  already exists" 로 실패해 데이터가 옛 스키마에 갇힌다
- init.sql 말미에 **마이그레이션 기준선**을 심는다. 이게 없으면 새 DB 에서 migrate.py 가
  0001 부터 다시 돌다가 `schema "local" does not exist` 로 죽는다
- 0009: 두 DB 를 실제로 찍어 비교해 나온 어긋남 셋
  · `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
  · `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 조용히 틀린다
  · 0005 의 RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)
- models.py: `area_contents.region_code` 를 nullable 로. 0004 가 DROP NOT NULL 한 것을
  ORM 만 NOT NULL 로 들고 있었다 — 축제·관광지·맛집은 전국 공용이라 지역이 유일성의 근거가 아니다

검증: 빈 컨테이너에 init.sql 로 세운 DB 와 마이그레이션으로 따라온 로컬 DB 를
`pg_dump --schema-only` 로 비교 — 표 14개 · 인덱스 · 제약 · 컬럼 전부 동일.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:14:39 +09:00

455 lines
35 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 새 DB 를 세우는 전체 DDL — **이 파일 한 벌이 최신 스키마다.**
--
-- ★ 이 파일은 DB 를 **처음 만들 때만** 돈다(postgres 이미지의 초기화 훅).
-- 이미 데이터가 든 DB 는 `postgres-init/migrations/NNNN_*.sql` 로 따라온다.
-- 표를 고칠 때는 **둘 다** 고친다 — 한쪽만 고치면 새로 세운 DB 와 서버 DB 가 조용히 갈라진다.
-- 규칙과 적용법: postgres-init/migrations/README.md
--
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db), **스키마는 public 한 벌**.
--
-- users 사람
-- jobs 작업 큐 (PostgreSQL 을 큐로)
-- places 사업장 — 모든 것의 스코프 키
-- ├ place_channels 채널 URL(네이버·TourAPI·홈페이지)
-- ├ place_units 객실 · 메뉴 · 프로그램
-- ├ place_photos 사진
-- ├ place_facts ★ 사실. 값 + 출처 + 검증상태
-- ├ place_faqs FAQ
-- └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)
-- area_contents ★ 지역 콘텐츠 실체 — 접두어 없는 유일한 표. 그게 공유물이라는 표시다
-- sites 발행 사이트 (사업장당 1개)
-- ├ site_sections 섹션 콘텐츠
-- ├ site_versions ★ 빌드 버전 — snapshot 박제
-- └ site_publish_logs 발행 시도 기록
--
-- ★ 도메인별 스키마(company·place·fact·local·site·job)는 2026-09-09 에 걷어냈다(migrations/0005).
-- 19개 중 9개가 스키마 이름을 다시 말했고(place.places · fact.facts · job.jobs …),
-- 무엇보다 **경계가 조인 방향과 반대**였다 — places.place_id 를 참조하는 표 11개 중
-- place 스키마 안에 있는 것이 하나도 없었다. 지금은 **접두어가 소속을 말한다.**
--
-- 설계 컨벤션
-- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리).
-- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum, CHECK 없음).
-- - 소프트 삭제(deleted BOOLEAN)를 쓰므로 자연키 유니크는 **부분 인덱스**로 건다.
-- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC.
-- - 인덱스 이름은 옛 표 이름을 그대로 쓴다(idx_facts_place · uq_place_links_place_url …).
-- 0005 의 RENAME 이 인덱스를 따라 옮겼을 뿐 이름을 바꾸지 않았고, 서버 DB 에 그 이름으로
-- 이미 있다 — 여기서만 새 이름을 쓰면 같은 인덱스가 두 벌 생긴다.
-- PostgreSQL 은 CREATE DATABASE IF NOT EXISTS 를 지원하지 않으므로,
-- 존재하지 않을 때만 생성하도록 psql \gexec 로 처리한다 (재실행 안전).
SELECT 'CREATE DATABASE web4ai_db'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'web4ai_db')\gexec
\connect web4ai_db
-- 모든 세션에서 시각을 UTC 로 다룬다 (DEFAULT now() 가 UTC 기준으로 저장·조회됨).
ALTER DATABASE web4ai_db SET timezone TO 'UTC';
SET timezone TO 'UTC';
-- uuid 기본값(gen_random_uuid) 사용을 위한 확장
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- ★ 옛 스키마가 있는 DB 에서는 여기서 멈춘다
-- ============================================================
-- 이 파일은 이제 public 에 표를 만든다. 옛 구조(place.places …)가 남은 DB 에 그대로 돌리면
-- **public 에 빈 표가 새로 생기고**, 뒤이어 0005 의 `place.places SET SCHEMA public` 이
-- "relation already exists" 로 실패한다 — 데이터는 옛 스키마에 갇히고 앱은 빈 표를 본다.
-- 그런 DB 는 init.sql 이 아니라 마이그레이션으로 따라와야 한다.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'place') THEN
RAISE EXCEPTION
'옛 도메인 스키마(place …)가 있는 DB 다. init.sql 을 다시 돌리지 말고 마이그레이션으로 따라오게 한다: cd solution/backend && .venv/bin/python scripts/migrate.py';
END IF;
END $$;
-- ============================================================
-- 사람
-- ============================================================
CREATE TABLE IF NOT EXISTS public.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
id VARCHAR(64) NOT NULL, -- 로그인 ID. ★ 구글 계정은 google_<sub> 라 28자까지 간다 — 20자였을 때 앞자리가 같은 두 계정이 겹쳤다
password VARCHAR(255) NULL, -- bcrypt 해시. ★ 소셜 계정은 NULL 이다 — 더미 해시를 넣으면 id/pw 로그인이 그 계정을 계속 노린다
name VARCHAR(50) NULL,
email VARCHAR(255) NULL,
contact_number VARCHAR(20) NULL,
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status SMALLINT NOT NULL DEFAULT 1, -- UserStatus: 1=active 2=inactive
role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer
provider SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google
provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ============================================================
-- 작업 큐 — 원자적 CAS claim + lease 소유권 + dead-letter
-- 수집·비전분석·빌드는 몇 분 걸린다. 동기 요청으로 처리하지 않는다.
-- 워커가 죽어도 lease 가 만료되면 reaper 가 회수한다.
-- ============================================================
CREATE TABLE IF NOT EXISTS public.jobs (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB NULL,
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code}
attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1
max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD
run_after TIMESTAMPTZ NOT NULL DEFAULT now(), -- 이 시각 이후에만 claim(백오프)
lease_until TIMESTAMPTZ NULL, -- 소유권 임대 만료(reaper 회수 기준)
worker_id VARCHAR(80) NULL,
run_started_at TIMESTAMPTZ NULL,
last_error TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 공통 규약. 잡은 이력이라 실제로는 쓰지 않는다
);
-- ============================================================
-- 사업장 — 모든 것의 스코프 키
-- ============================================================
CREATE TABLE IF NOT EXISTS public.places (
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(users.user_id) — 회사(테넌트)를 걷어내고 이 컬럼이 그 자리를 받았다
name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- PlaceCategory: 1=숙박 2=카페 3=음식점 4=피부과·성형외과 — 업종 스키마 선택 키
status SMALLINT NOT NULL DEFAULT 1, -- PlaceStatus: 1=draft 2=collecting 3=review 4=published 5=suspended
external_source SMALLINT NULL, -- ExternalPlaceSource: 1=kakao 2=naver
external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다(그때는 상호명+도로명주소가 대체 키)
road_address VARCHAR(255) NULL,
address VARCHAR(255) NULL, -- 지번
phone VARCHAR(30) NULL,
latitude NUMERIC(10,7) NULL, -- 빌드 시점 TourAPI 반경 조회가 읽는다
longitude NUMERIC(10,7) NULL,
region_code VARCHAR(10) NULL, -- ★ 지역 콘텐츠 캐시 키. 같은 지역에 사이트 50개여도 외부 조회는 1회
external_category VARCHAR(200) NULL, -- 외부 DB 분류 원문("음식점 > 한식 > 육류" · "펜션") — 주변 맛집에서 경쟁 업소를 빼는 기준(폴백)
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
verified_by uuid NULL,
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- Perplexity·네이버·TourAPI 가 발견한 채널 URL.
-- ★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 동일 업소임을 확인한 URL 만 넘긴다.
CREATE TABLE IF NOT EXISTS public.place_channels (
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
channel SMALLINT NOT NULL, -- LinkChannel: 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타
url VARCHAR(1000) NOT NULL,
title VARCHAR(300) NULL,
discovered_by SMALLINT NOT NULL, -- SourceType: 2=api(Perplexity) 1=owner(직접)
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
confirmed_at TIMESTAMPTZ NULL, -- ★ NULL = 크롤링 금지
confirmed_by uuid NULL,
raw JSONB NULL, -- Perplexity 응답 원문 — ★ 환각 추적용이고 사실 근거로 쓰지 않는다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과=프로그램.
-- 가변 필드는 place_facts(unit_id 있음)로 가고, 여기엔 목록 렌더에 필요한 뼈대만 둔다.
CREATE TABLE IF NOT EXISTS public.place_units (
unit_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
name VARCHAR(200) NOT NULL,
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
);
CREATE TABLE IF NOT EXISTS public.place_photos (
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
unit_id uuid NULL, -- 객실·메뉴 사진이면 연결
url VARCHAR(1000) NOT NULL, -- 우리가 보관하는 접근 URL
origin_url VARCHAR(1000) NULL, -- ★ 수집 원본 URL — 이미지 재게시 권리가 미결이라(DECISIONS 1-2) 결론에 따라 걸러낼 수 있어야 한다
source_type SMALLINT NOT NULL, -- SourceType: 1=owner 업로드 3=crawl 수집
source_url VARCHAR(1000) NULL, -- 수집한 페이지 URL
label VARCHAR(200) NULL, -- Gemini Vision 분류 라벨
alt_text VARCHAR(500) NULL, -- Vision 이 만든 alt
vision_confidence NUMERIC(4,3) NULL, -- ★ 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다
status SMALLINT NOT NULL DEFAULT 1, -- MediaStatus: 1=pending_review 2=approved 3=rejected. ★ 2 만 사이트에 나간다
width INTEGER NULL,
height INTEGER NULL,
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
);
-- ★ 가장 중요한 표. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
-- 출처 없는 사실은 이 제품의 규칙 위반이다.
CREATE TABLE IF NOT EXISTS public.place_facts (
fact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
unit_id uuid NULL, -- NULL = 사업장 단위 fact, 있으면 객실·메뉴 단위
key VARCHAR(100) NOT NULL, -- ★ 업종 스키마(common/category_schema)에 정의된 key 만 허용된다
value TEXT NULL,
unit VARCHAR(30) NULL, -- 값의 단위(원·명·분…)
source_type SMALLINT NOT NULL, -- SourceType: 1=owner 2=api 3=crawl 4=llm
source_url VARCHAR(1000) NULL,
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_by uuid NULL,
verified_at TIMESTAMPTZ NULL,
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus: 1=unverified 2=pending_owner 3=verified 4=corrected 5=rejected 6=expired
expires_at TIMESTAMPTZ NULL, -- 지나면 EXPIRED 전이 대상
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다.
CREATE TABLE IF NOT EXISTS public.place_faqs (
faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
question VARCHAR(500) NOT NULL,
answer TEXT NOT NULL,
source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려
generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출
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
);
-- ============================================================
-- 지역 콘텐츠 — 접두어가 없는 유일한 표. **그게 공유물이라는 표시다.**
-- ★ 키는 place_id 가 아니다. 축제·관광지·맛집의 유일성은 출처가 준 external_id 이고,
-- 지역 이야기(가요·일력·인물·연표·엽서·퀴즈)의 유일성은 (region_code, kind) 다.
-- ★ 외부 API 가 실패해도 이 행을 지우거나 비우지 않는다 — 직전 값을 유지하고 알림만 낸다.
-- ============================================================
CREATE TABLE IF NOT EXISTS public.area_contents (
local_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드. ★ NULL 허용 — 축제·관광지·맛집은 전국 공용이라 지역이 유일성의 근거가 아니다(0004)
content_type SMALLINT NOT NULL, -- LocalContentType: 1=날씨 2=축제 3=관광지 4=맛집 5=여행코스 6=지역이야기
source SMALLINT NOT NULL, -- LocalSource: 1=Open-Meteo 2=TourAPI 3=카카오로컬 4=Perplexity
external_id VARCHAR(100) NULL, -- TourAPI contentid 등. 날씨·지역이야기는 NULL
title VARCHAR(300) NULL,
body JSONB NOT NULL, -- ★ 렌더러가 읽는 이름으로 저장한다(name·location·imageUrl). TourAPI 원문 이름으로 두면 빌드마다 같은 변환을 다시 한다(0008)
status SMALLINT NOT NULL DEFAULT 1, -- LocalContentStatus: 1=검수대기 2=발행 3=종료
published_at TIMESTAMPTZ NULL,
published_by uuid NULL,
display_start_at TIMESTAMPTZ NULL,
display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NULL, -- TTL. 지나면 갱신 대상(값은 유지)
latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 뒀다 — 거리 계산이 행마다 JSON 을 펴지 않게(0004)
longitude NUMERIC(10,7) NULL,
kind VARCHAR(50) NULL, -- 'weather' 'festival' 'attraction' 'restaurant' 'course' / 이야기는 'songs' 'daily' 'people' 'chronicle' 'postcard' 'quiz'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.**
-- ★ 예전에는 이 표가 값을 통째로 들고 있었다. 키가 place_id 라서 업장마다 TourAPI 응답이
-- 복제됐다 — 실측(2026-09-09) 한 곳에 144행이고, 같은 동네에 모텔이 열 곳 들어오면
-- 같은 축제가 열 벌이 된다. 실체는 area_contents 에 한 행, 여기엔 관계만 남긴다.
CREATE TABLE IF NOT EXISTS public.place_area_refs (
place_id uuid NOT NULL,
local_content_id uuid NOT NULL,
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)
);
-- ============================================================
-- 사이트
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
-- ============================================================
CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1
domain VARCHAR(255) NULL,
path_prefix VARCHAR(100) NULL,
template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키. ★ 서버는 해석하지 않고 보관·반환만 한다 — 목록은 프론트가 소유한다
theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지)
status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished
current_version_id uuid NULL, -- site_versions.site_version_id
published_at TIMESTAMPTZ NULL,
thumbnail_url VARCHAR(500) NULL, -- ★ 발행에 성공한 뒤에만 채운다. 스크린샷이 아니라 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. NULL 이면 업종 기본 템플릿으로 굽는다.';
COMMENT ON COLUMN public.sites.theme IS '에디터가 정한 디자인. {"colors":{...},"fontStyle":"...","sections":[{"id","name","enabled","locked","variantId"}]} — 서버는 해석하지 않고 그대로 보관·반환한다(목록은 프론트가 소유). NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다. templateId 는 sites.template_id 가 소유한다.';
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%).
-- 크기가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도 42 KB 를 다시 쓰고,
-- 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다 "누가 넣었나·확인됐나"를 물을 자리가 없었다.
CREATE TABLE IF NOT EXISTS public.site_sections (
site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL,
section_id VARCHAR(50) NOT NULL, -- 'songs' 'itinerary' 'video' 'people' 'local' …
data JSONB NOT NULL, -- shared 의 XxxItem[] 계약. ★ section_id='local' 은 배열이 아니라 ref→값 **맵**이다
source_type SMALLINT NOT NULL DEFAULT 1, -- SourceType: 1=owner 2=api 4=llm
shared_ref uuid NULL, -- ★ 공용 원본(area_contents)을 가리킬 때. 값을 복제하지 않고 발행할 때 펼친다
status SMALLINT NOT NULL DEFAULT 1, -- FactStatus 와 같은 축
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
);
CREATE TABLE IF NOT EXISTS public.site_versions (
site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL,
version INTEGER NOT NULL, -- 1부터
build_status SMALLINT NOT NULL DEFAULT 1, -- BuildStatus: 1=pending 2=building 3=built 4=failed
snapshot JSONB NULL, -- ★ 빌드 시점 데이터 박제. 이게 정적 빌드의 경계이자 감사 기록이다
jsonld JSONB NULL, -- ★ 렌더러가 **실제로 내보낸** 값. 백엔드가 따로 계산하지 않는다 — 따로 계산하면 게이트의 근거와 실제 페이지가 어긋난다
unique_content_count INTEGER NOT NULL DEFAULT 0, -- ★ 0 이면 발행 거부(스팸 판정 대상)
build_error TEXT NULL,
built_at TIMESTAMPTZ NULL, -- places.content_updated_at 과 비교해 재빌드 대상을 고른다
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- 발행 시도 기록. ★ site_versions 와 1:1 이 아니다 — 같은 버전을 다시 굽거나 내리면 2:1 이 된다.
-- site_versions 는 "무엇을 구웠나", 여기는 "언제 무슨 일이 있었나"다.
CREATE TABLE IF NOT EXISTS public.site_publish_logs (
publish_log_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL,
site_version_id uuid NULL,
action SMALLINT NOT NULL, -- PublishAction: 1=publish 2=unpublish 3=rebuild 4=suspend 5=resume
result SMALLINT NOT NULL, -- PublishResult: 1=success 2=rejected 3=failed
reject_reason SMALLINT NULL, -- PublishRejectReason: 1=미검증fact 2=고유콘텐츠없음 3=JSON-LD불일치 4=필수fact누락
detail JSONB NULL, -- ★ 막힌 항목 목록. 화면의 반려 카드가 이걸로 "무엇을 고쳐야 하는지"를 말한다
actor_user_id uuid NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- ============================================================
-- 인덱스
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
-- ============================================================
-- users
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON public.users (id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON public.users (provider, provider_uid)
WHERE deleted = FALSE AND provider_uid IS NOT NULL;
-- places
CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON public.places (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_places_region_code ON public.places (region_code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_places_external ON public.places (external_source, external_place_id)
WHERE deleted = FALSE AND external_place_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_place_links_place ON public.place_channels (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON public.place_channels (place_id, url)
WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
-- place_facts
CREATE INDEX IF NOT EXISTS idx_facts_place ON public.place_facts (place_id);
CREATE INDEX IF NOT EXISTS idx_facts_unit ON public.place_facts (unit_id);
CREATE INDEX IF NOT EXISTS idx_faqs_place ON public.place_faqs (place_id);
-- ★ 사이트에 나가는 값(3=VERIFIED, 4=CORRECTED)은 (사업장, 단위, key) 당 1건.
-- 후보(1·2)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것이다.
-- 유니크를 활성 전체에 걸면 재수집 때마다 확인된 값이 사이트에서 사라진다.
-- unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 사업장 단위 / 하위 단위를 나눠 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_place_key ON public.place_facts (place_id, key)
WHERE deleted = FALSE AND unit_id IS NULL AND status IN (3, 4);
CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_unit_key ON public.place_facts (place_id, unit_id, key)
WHERE deleted = FALSE AND unit_id IS NOT NULL AND status IN (3, 4);
-- 후보 조회 경로(사람 확인 큐).
CREATE INDEX IF NOT EXISTS idx_facts_candidate ON public.place_facts (place_id, key, source_type)
WHERE deleted = FALSE AND status IN (1, 2);
-- 발행 게이트가 "노출 가능한 fact" 만 훑는 경로.
CREATE INDEX IF NOT EXISTS idx_facts_publishable ON public.place_facts (place_id, status)
WHERE deleted = FALSE AND status IN (3, 4);
-- area_contents
CREATE INDEX IF NOT EXISTS idx_local_contents_region ON public.area_contents (region_code);
CREATE INDEX IF NOT EXISTS idx_local_contents_status ON public.area_contents (status, collected_at DESC)
WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_local_contents_type_region ON public.area_contents (content_type, region_code)
WHERE deleted = FALSE;
-- ★ 유일성의 근거가 셋이고 서로 겹치면 안 된다. 실제로 겹쳐서 조용히 틀린 적이 있다 —
-- 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만
-- 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류).
-- 그래서 조건에 external_id / kind 의 유무를 넣어 세 인덱스가 각자 자기 몫만 보게 한다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_external ON public.area_contents (source, external_id)
WHERE deleted = FALSE AND external_id IS NOT NULL; -- 축제·관광지·맛집: 출처 id 하나면 한 행(지역 무관)
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_kind ON public.area_contents (region_code, kind)
WHERE deleted = FALSE AND kind IS NOT NULL AND external_id IS NULL; -- 지역 이야기: 지역 × 종류 한 벌
CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single ON public.area_contents (region_code, content_type)
WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL; -- 날씨: 지역 × 종류 한 행
CREATE INDEX IF NOT EXISTS idx_place_contents_place ON public.place_area_refs (place_id) WHERE deleted = FALSE;
-- sites
CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_place ON public.sites (place_id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_domain ON public.sites (domain)
WHERE deleted = FALSE AND domain IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_site_contents_site ON public.site_sections (site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON public.site_sections (site_id, section_id)
WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_site_versions_site ON public.site_versions (site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_site_versions_no ON public.site_versions (site_id, version)
WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_publish_logs_site ON public.site_publish_logs (site_id);
-- jobs
-- claim 경로: PENDING 이면서 run_after 가 지난 잡을 priority·created_at 순으로.
CREATE INDEX IF NOT EXISTS ix_jobs_claim ON public.jobs (status, run_after, priority, created_at);
-- reaper 경로: 만료된 lease 회수.
CREATE INDEX IF NOT EXISTS ix_jobs_lease ON public.jobs (status, lease_until);
-- 활성 중복 방지: 같은 dedupe_key 는 PENDING(1)/RUNNING(2) 중 하나만.
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key)
WHERE status IN (1, 2) AND dedupe_key IS NOT NULL;
-- ============================================================
-- 마이그레이션 기준선(baseline)
-- ============================================================
-- ★ 위 DDL 은 0008 까지 적용된 모습이다. 그 사실을 기록해 두지 않으면, 새로 세운 DB 에서
-- `migrate.py` 가 0001 부터 다시 돌리려다 **`schema "local" does not exist` 로 죽는다** —
-- 그 파일들은 옛 스키마를 전제로 쓰였기 때문이다.
-- ★ 마이그레이션을 새로 더하고 그 내용을 이 파일에 반영했다면, 그 번호를 여기에도 넣는다.
-- 빠뜨려도 치명적이진 않다(파일들은 재실행 안전하게 쓴다) — 다만 새 DB 에서 한 번 더 돈다.
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO public.schema_migrations (version) VALUES
('0001_place_contents_external_category'),
('0002_spots_shared'),
('0003_site_contents'),
('0004_local_contents_unify'),
('0005_flatten_schemas'),
('0006_prune_unused'),
('0007_story_rows_per_kind'),
('0008_personalization_to_site_sections'),
('0009_align_with_init_sql')
ON CONFLICT (version) DO NOTHING;