-- 새 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_ 라 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 ); -- 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.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;