o2o-site-AEO/postgres-init/init-data/init.sql
hbyang 58d6249102 Merge remote-tracking branch 'origin/main' into feature/social-post
# Conflicts:
#	docs/DECISIONS.md
#	docs/DEVLOG.md
#	solution/backend/requirements.txt
#	solution/backend/scheduler/__init__.py
#	solution/backend/worker/handlers.py
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/index.ts
2026-09-16 08:46:48 +09:00

571 lines
42 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 7=song 8=rollback
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,
progress 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
);
-- 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
-- ★ 곡은 사실이 아니라 만들어진 창작물이라 fact·사진과 같은 검증 상태를 두지 않는다.
-- 대신 완성(READY)된 것만 사이트에 나간다 — 생성 중인 곡을 실으면 재생 버튼이 빈 파일을 가리킨다.
CREATE TABLE IF NOT EXISTS public.place_songs (
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
title VARCHAR(200) NOT NULL,
lyrics TEXT NULL, -- 화면에 같이 싣는다. 손님이 무슨 노래인지 읽을 수 있어야 한다
style VARCHAR(200) NULL, -- Suno 에 넘긴 장르·분위기 문자열
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
provider_task_id VARCHAR(120) NULL, -- Suno taskId. 폴링·재조회의 유일한 열쇠
origin_url VARCHAR(1000) NULL, -- ★ Suno 가 준 원본 주소. 만료되므로 이 주소를 사이트에 싣지 않는다
file_name VARCHAR(200) NULL, -- 우리가 받아 둔 파일(solution/site/songs/<이것>). 프리렌더가 사이트로 복사한다
duration_sec NUMERIC(6,2) NULL,
status SMALLINT NOT NULL DEFAULT 1, -- SongStatus: 1=generating 2=ready 3=failed. ★ 2 만 사이트에 나간다
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.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
);
-- FAQ. 출처(generated_by)마다 근거 요구가 다르다 — LLM 생성분은 source_fact_ids 필수(없으면 저장 안 함),
-- 사장님 입력(OWNER)·20개 채우기 문의 안내(TEMPLATE)는 근거가 없다. 규칙: docs/DECISIONS.md 8절
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 key 배열(LLM 생성분만)
generated_by SMALLINT NOT NULL, -- SourceType: 1=owner 4=llm 5=template(문의 안내)
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
);
-- migrations/0012 와 같은 문구다 — 바꾸면 둘 다 고친다.
COMMENT ON COLUMN public.place_faqs.generated_by IS 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)';
COMMENT ON COLUMN public.place_faqs.source_fact_ids IS '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.';
-- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다.
-- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
-- ★ body 는 렌더러 계약(shared/lib/section-data.ts 의 ItineraryItem[]) 그대로다.
CREATE TABLE IF NOT EXISTS public.place_itineraries (
place_itinerary_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
duration VARCHAR(20) NOT NULL, -- '1박 2일' · '2박 3일'
body JSONB NOT NULL, -- ItineraryItem[]
generated_by SMALLINT NOT NULL, -- SourceType: 4=llm
model VARCHAR(100) NULL, -- 'perplexity:sonar'
generated_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 INDEX IF NOT EXISTS ix_place_itineraries_place ON public.place_itineraries (place_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_itineraries
ON public.place_itineraries (place_id, duration)
WHERE deleted = 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.site_search_status (
site_id uuid PRIMARY KEY,
site_version_id uuid NOT NULL,
property_url TEXT NOT NULL,
page_url TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
sitemap_submitted_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
first_indexed_at TIMESTAMPTZ NULL,
inspection JSONB NULL,
error_code VARCHAR(100) NULL,
failures INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
alerted_at TIMESTAMPTZ 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.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_songs_place ON public.place_songs (place_id, created_at DESC);
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;
-- SNS: credentials and approval records never enter public payloads.
CREATE TABLE IF NOT EXISTS public.owner_social_accounts (
account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
provider smallint NOT NULL CHECK (provider IN (1,2)),
provider_user_id varchar(200) NOT NULL,
handle varchar(200) NOT NULL,
profile_url text NOT NULL,
access_token text,
refresh_token text,
access_expires_at timestamptz,
scopes jsonb NOT NULL DEFAULT '[]',
status varchar(20) NOT NULL DEFAULT 'linked',
last_error text,
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_social_account ON public.owner_social_accounts(user_id, provider) WHERE deleted=false AND status IN ('linked','needs_reauth');
-- SNS: credentials and approval records never enter public payloads.
CREATE TABLE IF NOT EXISTS public.place_social_posts (
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL,
user_id uuid NOT NULL,
site_version_id uuid NOT NULL,
account_id uuid,
provider smallint NOT NULL CHECK (provider IN (1,2)),
body text NOT NULL DEFAULT '',
link_url text NOT NULL,
grounded_facts jsonb NOT NULL DEFAULT '[]',
status varchar(24) NOT NULL DEFAULT 'DRAFTING',
approval_token_sha varchar(64),
approval_sent_at timestamptz,
approval_channel varchar(20),
approval_expires_at timestamptz,
decided_at timestamptz,
decided_via varchar(20),
provider_post_id varchar(200),
permalink text,
posted_at timestamptz,
last_error text,
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_social_version ON public.place_social_posts(place_id, site_version_id) WHERE deleted=false;
CREATE INDEX IF NOT EXISTS idx_social_posted ON public.place_social_posts(place_id, posted_at DESC) WHERE deleted=false AND status='POSTED';