-- 단일 초기화 파일 — 스키마 DDL 전부를 이 한 파일로 적용한다. -- 전부 IF NOT EXISTS 라 재실행 안전. 기존 DB 에 재실행하면 말미의 "기존 DB 보정(ALTER)" 섹션이 최신 스키마로 맞춰준다. -- 컬럼 추가/타입 변경은 postgres-init/alters/YYYY-MM-DD-<주제>.sql 로 따로 쌓는다. -- -- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. -- postgres (1개 서버, 5432) -- └── web4ai_db -- ├── company : users -- ├── place : places, place_aliases, place_links, units, media -- ├── fact : facts, faqs -- ├── local : local_contents, routes, nearby_links -- ├── site : sites, site_versions, publish_logs, ai_check_results -- └── job : jobs (작업 큐 — PostgreSQL 을 큐로) -- (예정: report — reports 모듈이 붙을 때 추가) -- -- 설계 컨벤션 -- - 단일 DB 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다. -- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리). schema 간 관계도 동일. -- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum 기준, CHECK 없음). -- - 소프트 삭제(deleted BOOLEAN)를 사용하므로 자연키 유니크는 부분 인덱스로 건다. -- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC 으로 둔다. -- 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) 사용을 위한 확장 (public 스키마에 설치) CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================ -- 스키마 (도메인별 네임스페이스) -- ============================================================ CREATE SCHEMA IF NOT EXISTS company; CREATE SCHEMA IF NOT EXISTS place; CREATE SCHEMA IF NOT EXISTS fact; CREATE SCHEMA IF NOT EXISTS local; CREATE SCHEMA IF NOT EXISTS site; CREATE SCHEMA IF NOT EXISTS job; -- ============================================================ -- company : 계정 -- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 — -- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다. -- ============================================================ CREATE TABLE IF NOT EXISTS company.users ( user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_) password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL 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, -- 상태: 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(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 ); -- ============================================================ -- place : 사업장 / 별칭 / 채널 링크 / 하위 단위 / 사진 -- ============================================================ CREATE TABLE IF NOT EXISTS place.places ( place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.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, -- 검증에 쓴 외부 장소 DB(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, -- 위도 longitude NUMERIC(10,7) NULL, -- 경도 region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드 — ★ 지역정보 캐시 키 external_category VARCHAR(200) NULL, -- 외부 장소 DB 분류 원문("음식점 > 한식 > 육류" / "펜션") — 주변 맛집 경쟁업소 제외 기준(폴백) verified_at TIMESTAMPTZ NULL, -- ★ 동일 업소 검증 통과 시각. NULL = 수집·발행 금지 verified_by uuid NULL, -- 검증자(company.users.user_id) content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 ); CREATE TABLE IF NOT EXISTS place.place_aliases ( alias_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 별칭 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) alias VARCHAR(200) NOT NULL, -- 별칭(옛 상호, 본관/별관 표기 등) source_type SMALLINT NOT NULL, -- 출처(SourceType): 1=owner 2=api 3=crawl 4=llm 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 place.place_links ( link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타 url VARCHAR(1000) NOT NULL, -- 발견된 URL 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, -- 확정자(company.users.user_id) raw JSONB NULL, -- Perplexity 응답 원문(본문 + search_results) — 환각 추적용, 사실 근거 아님 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 place.units ( unit_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 단위 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) 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 place.media ( media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사진 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) unit_id uuid NULL, -- 하위 단위(place.units.unit_id) 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 분류 라벨 (예: "A동 침실") alt_text VARCHAR(500) NULL, -- Vision 이 생성한 alt 텍스트 vision_confidence NUMERIC(4,3) NULL, -- ★ 신뢰도 0.000~1.000. 낮으면 자동 반영 금지 status SMALLINT NOT NULL DEFAULT 1, -- 상태(MediaStatus): 1=pending_review 2=approved 3=rejected 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 ); -- ============================================================ -- fact : 사실 / FAQ -- ★ 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. -- ★ VERIFIED(3) / CORRECTED(4) 만 사이트에 노출한다. -- ============================================================ CREATE TABLE IF NOT EXISTS fact.facts ( fact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- fact 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) unit_id uuid NULL, -- 하위 단위(place.units.unit_id). 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, -- 출처 URL collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수집 시각 verified_by uuid NULL, -- 검증자(company.users.user_id) 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 ); CREATE TABLE IF NOT EXISTS fact.faqs ( faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- FAQ 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) 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) 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 ); -- ============================================================ -- local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 -- ★ 캐시 키는 place_id 가 아니라 region_code — 같은 지역 사이트 50개여도 외부 조회는 1회. -- ★ 외부 API 실패 시 행을 지우거나 비우지 않는다. 직전 값을 유지하고 내부 알림만 낸다. -- ============================================================ CREATE TABLE IF NOT EXISTS local.local_contents ( local_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 지역 콘텐츠 식별자(PK) region_code VARCHAR(10) NOT NULL, -- ★ 카카오 행정구역 코드 = 캐시 키 content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 1=날씨 2=축제 3=관광지 4=맛집 source SMALLINT NOT NULL, -- 출처(LocalSource): 1=Open-Meteo 2=TourAPI 3=카카오로컬 external_id VARCHAR(100) NULL, -- 출처 고유 ID(TourAPI contentid 등). 날씨는 NULL title VARCHAR(300) NULL, -- 제목 body JSONB NOT NULL, -- 원문 페이로드(weathercode·기간·좌표 등) status SMALLINT NOT NULL DEFAULT 1, -- 1=검수대기 2=발행 3=종료 published_at TIMESTAMPTZ NULL, published_by uuid NULL, -- 운영 관리자(company.users.user_id) display_start_at TIMESTAMPTZ NULL, display_end_at TIMESTAMPTZ NULL, collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수집 시각 expires_at TIMESTAMPTZ NULL, -- TTL. 지나면 갱신 대상(값은 유지) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); -- 업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id — local_contents(행정구역 캐시)와 다르다. -- 빌드 때마다 TourAPI locationBasedList2 로 갱신. 응답에서 사라진 행은 소프트 삭제, hidden 은 유지. CREATE TABLE IF NOT EXISTS local.place_contents ( place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 정보 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 5=여행코스 external_id VARCHAR(100) NOT NULL, -- TourAPI contentid title VARCHAR(300) NOT NULL, body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간) distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준 has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) collected_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 ); -- 축제·관광지·맛집 **실체**. 전국 공용 — 같은 장소를 업장마다 복제하지 않는다. -- ★ place_contents 는 키가 (place_id, …) 라 업장마다 TourAPI 응답을 통째로 복제했다. -- 실측(2026-09-09): 업장 한 곳에 144행. 열 곳이면 같은 축제가 열 벌이다. -- 실체는 여기 한 행, 업장별로 다른 것(거리·숨김)만 place_spots 에 남긴다. CREATE TABLE IF NOT EXISTS local.spots ( spot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 장소 식별자(PK) source SMALLINT NOT NULL, -- 출처(LocalSource): 2=TourAPI external_id VARCHAR(100) NOT NULL, -- TourAPI contentid content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 title VARCHAR(300) NOT NULL, body JSONB NOT NULL, -- 정규화한 원본(주소·사진·기간·분류) latitude NUMERIC(10,7) NULL, -- ★ body 에서 꺼내 컬럼으로 — 거리 계산이 읽는다 longitude NUMERIC(10,7) NULL, region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드(지역 단위 조회) has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 collected_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 TABLE IF NOT EXISTS local.place_spots ( place_id uuid NOT NULL, -- 사업장(place.places.place_id) spot_id uuid NOT NULL, -- 장소(local.spots.spot_id) distance_m INTEGER NOT 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, spot_id) ); -- 지역 이야기(가요·인물·연표·엽서·퀴즈). ★ 업장이 아니라 **지역**의 것이다 — -- 군산 이야기는 군산 숙소가 같이 쓴다. 검수 전에는 발행에 나가지 않는다(status). CREATE TABLE IF NOT EXISTS local.region_stories ( region_story_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 이야기 식별자(PK) 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, -- 출처(SourceType): 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 TABLE IF NOT EXISTS local.routes ( route_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 경로 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) origin_name VARCHAR(200) NOT NULL, -- 출발지 (예: "서울역") transport SMALLINT NOT NULL, -- 수단(TransportType): 1=자동차 2=대중교통 3=도보 duration_min INTEGER NULL, -- 소요 시간(분) distance_m INTEGER NULL, -- 거리(m) description TEXT NULL, -- 경로 설명 source_type SMALLINT NOT NULL, -- 출처(SourceType) 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 local.nearby_links ( nearby_link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 항목 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) region_code VARCHAR(10) NULL, -- 어느 지역 캐시에서 파생됐는지 name VARCHAR(200) NOT NULL, -- 상호명 category_name VARCHAR(100) NULL, -- 카카오 category_name kakao_place_id VARCHAR(32) NULL, -- 카카오 장소 ID distance_m INTEGER NULL, -- 거리(m) url VARCHAR(1000) NULL, -- 링크 latitude NUMERIC(10,7) NULL, longitude NUMERIC(10,7) 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 ); -- ============================================================ -- site : 사이트 / 버전 / 발행 로그 / AI 노출 점검 -- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다. -- ★ 개별 재빌드 단위. 해지는 삭제가 아니라 상태 전이. -- ============================================================ CREATE TABLE IF NOT EXISTS site.sites ( site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사이트 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) — 1:1 domain VARCHAR(255) NULL, -- 발행 도메인 path_prefix VARCHAR(100) NULL, -- 경로 프리픽스 template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리 id). NULL 이면 업종 기본 템플릿으로 굽는다 theme JSONB NULL, -- ★ 에디터가 정한 색·서체·섹션. {"colors":{},"fontStyle":"","sections":[{"id","name","enabled","locked","variantId"}]}. NULL 이면 업종 기본으로 굽는다. templateId 는 위 컬럼이 소유한다(중복 보관 금지) status SMALLINT NOT NULL DEFAULT 1, -- 상태(SiteStatus): 1=draft 2=review 3=published 4=suspended 5=unpublished current_version_id uuid NULL, -- 현재 발행 버전(site.site_versions.site_version_id) published_at TIMESTAMPTZ NULL, -- 최초/최근 발행 시각 thumbnail_url VARCHAR(500) NULL, -- 발행 썸네일(Azure Blob 공개 URL). 발행에 성공한 뒤에만 채워진다 — 쇼케이스·사이트 목록 카드가 읽는다 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); -- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다. -- sites.theme 은 색·서체·섹션 순서/on-off 만 갖고, 내용은 여기로 나온다. -- 실측(2026-09-09, /s/stay): theme 42,150 B 중 콘텐츠가 39,645 B(94%)였다. -- 크기가 아니라 쓰기 단위가 문제였다 — 영상 주소 하나를 고쳐도 42 KB 를 다시 썼다. CREATE TABLE IF NOT EXISTS site.site_contents ( site_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 콘텐츠 식별자(PK) 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 4=llm shared_ref uuid NULL, -- ★ 공유 원본(local.region_stories 등)을 가리킬 때. 값을 복제하지 않는다 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 ); CREATE TABLE IF NOT EXISTS site.site_versions ( site_version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 버전 식별자(PK) site_id uuid NOT NULL, -- 사이트(site.sites.site_id) 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 이면 발행 API 가 거부(스팸 판정 대상) build_error TEXT NULL, -- 빌드 실패 사유 built_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 site.publish_logs ( publish_log_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 발행 로그 식별자(PK) site_id uuid NOT NULL, -- 사이트(site.sites.site_id) site_version_id uuid NULL, -- 버전(site.site_versions.site_version_id) 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, -- 막힌 항목 목록(미검증 fact key 등) actor_user_id uuid NULL, -- 실행자(company.users.user_id) 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 site.ai_check_results ( ai_check_result_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 점검 결과 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) engine SMALLINT NOT NULL, -- 엔진(AiEngine): 1=ChatGPT 2=Perplexity 3=Gemini 4=Claude 99=기타 query VARCHAR(500) NOT NULL, -- 던진 질의 answer TEXT NULL, -- 받은 답변 cited_urls JSONB NULL, -- 인용된 URL 목록 is_own_site_cited BOOLEAN NOT NULL DEFAULT FALSE, -- ★ 핵심 지표 — 우리 사이트를 근거로 답했는가 ota_cited BOOLEAN NOT NULL DEFAULT FALSE, -- OTA(야놀자·여기어때)가 대신 인용됐는가 checked_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 ); -- ============================================================ -- job : 작업 큐 (PostgreSQL 을 큐로 — 원자적 CAS claim + lease 소유권 + dead-letter) -- 수집·비전분석·빌드는 몇 분 걸린다. 동기 요청으로 처리하지 않는다. -- 워커 컨테이너가 죽어도 lease 가 만료되면 reaper 가 잡을 회수한다. -- ============================================================ CREATE TABLE IF NOT EXISTS job.jobs ( job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 잡 식별자(PK) 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, -- 활성 중복 방지 키(부분 유니크) 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, -- RUNNING 진입 시각 last_error TEXT NULL, -- 마지막 실패 사유 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE -- 공통 규약(잡은 이력이라 실제로는 쓰지 않음) ); -- ============================================================ -- 인덱스 -- ============================================================ -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; -- place CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id); CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id); CREATE INDEX IF NOT EXISTS idx_place_links_place ON place.place_links (place_id); CREATE INDEX IF NOT EXISTS idx_units_place ON place.units (place_id); CREATE INDEX IF NOT EXISTS idx_media_place ON place.media (place_id); CREATE INDEX IF NOT EXISTS idx_media_unit ON place.media (unit_id); CREATE INDEX IF NOT EXISTS idx_places_external ON place.places (external_source, external_place_id) WHERE deleted = FALSE AND external_place_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON place.place_links (place_id, url) WHERE deleted = FALSE; -- fact CREATE INDEX IF NOT EXISTS idx_facts_place ON fact.facts (place_id); CREATE INDEX IF NOT EXISTS idx_facts_unit ON fact.facts (unit_id); CREATE INDEX IF NOT EXISTS idx_faqs_place ON fact.faqs (place_id); -- ★ 사이트에 나가는 값(3=VERIFIED, 4=CORRECTED)은 (사업장, 단위, key) 당 1건. -- 후보(1=UNVERIFIED, 2=PENDING_OWNER)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것. -- 유니크를 활성 전체에 걸면 재수집 때마다 확인된 값이 사이트에서 사라진다. -- unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 사업장 단위 / 하위 단위를 나눠 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_place_key ON fact.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 fact.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 fact.facts (place_id, key, source_type) WHERE deleted = FALSE AND status IN (1, 2); -- 발행 게이트가 "노출 가능한 fact"(VERIFIED·CORRECTED) 만 훑는 경로. CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status) WHERE deleted = FALSE AND status IN (3, 4); -- local CREATE INDEX IF NOT EXISTS idx_local_contents_region ON local.local_contents (region_code); CREATE INDEX IF NOT EXISTS idx_routes_place ON local.routes (place_id); CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed ON local.place_contents (place_id, content_type, external_id) WHERE deleted = false; CREATE INDEX IF NOT EXISTS idx_nearby_links_place ON local.nearby_links (place_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_spots_external ON local.spots (source, external_id) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_spots_region ON local.spots (region_code, content_type); CREATE INDEX IF NOT EXISTS idx_place_spots_place ON local.place_spots (place_id) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_region_stories_kind ON local.region_stories (region_code, kind) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_site_contents_site ON site.site_contents (site_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_site_contents_section ON site.site_contents (site_id, section_id) WHERE deleted = FALSE; -- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_keyed ON local.local_contents (region_code, content_type, external_id) WHERE deleted = FALSE AND external_id IS NOT NULL; -- ★ kind 가 있는 행(지역 이야기 다섯 종)은 이 인덱스에서 뺀다 — 그 다섯은 external_id 가 없어 -- (region_code, content_type) 하나를 두고 서로 부딪친다. 이야기의 유일성은 -- uq_local_contents_kind (region_code, kind) 가 책임진다(migrations/0004·0007). CREATE UNIQUE INDEX IF NOT EXISTS uq_local_contents_single ON local.local_contents (region_code, content_type) WHERE deleted = FALSE AND external_id IS NULL AND kind IS NULL; -- site CREATE INDEX IF NOT EXISTS idx_site_versions_site ON site.site_versions (site_id); CREATE INDEX IF NOT EXISTS idx_publish_logs_site ON site.publish_logs (site_id); CREATE INDEX IF NOT EXISTS idx_ai_check_place ON site.ai_check_results (place_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_place ON site.sites (place_id) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_sites_domain ON site.sites (domain) WHERE deleted = FALSE AND domain IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_site_versions_no ON site.site_versions (site_id, version) WHERE deleted = FALSE; -- job (작업 큐) -- claim 경로: PENDING 이면서 run_after 가 지난 잡을 priority·created_at 순으로. CREATE INDEX IF NOT EXISTS ix_jobs_claim ON job.jobs (status, run_after, priority, created_at); -- reaper 경로: 만료된 lease 회수. CREATE INDEX IF NOT EXISTS ix_jobs_lease ON job.jobs (status, lease_until); -- 활성 중복 방지: 같은 dedupe_key 는 PENDING(1)/RUNNING(2) 중 하나만 존재 가능. CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON job.jobs (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL; -- ============================================================ -- 기존 DB 보정(ALTER) -- ============================================================ -- 위 CREATE TABLE IF NOT EXISTS 는 기존 테이블을 바꾸지 못하므로, 컬럼 추가/타입 변경은 여기에 누적한다. ALTER TABLE place.places ADD COLUMN IF NOT EXISTS content_updated_at TIMESTAMPTZ NULL, -- 2026-08-27 노출값 변경 시각(개별 재빌드 판별) ADD COLUMN IF NOT EXISTS external_source SMALLINT NULL; -- 2026-08-27 검증 소스(kakao/naver) -- 2026-09-02 구글 로그인. 소셜 계정은 비밀번호가 없고(NULL), 로그인 아이디가 google_ 라 20자를 넘는다. ALTER TABLE company.users ADD COLUMN IF NOT EXISTS provider SMALLINT NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; -- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로 -- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL; -- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 -- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; -- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고 -- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다. -- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다. -- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다. DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN UPDATE place.places p SET owner_user_id = ( SELECT u.user_id FROM company.users u WHERE u.company_id = p.company_id AND u.deleted = FALSE ORDER BY u.created_at LIMIT 1) WHERE p.owner_user_id IS NULL; -- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다. DELETE FROM place.places WHERE owner_user_id IS NULL; ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL; ALTER TABLE place.places DROP COLUMN company_id; END IF; END $$; ALTER TABLE company.users DROP COLUMN IF EXISTS company_id; DROP TABLE IF EXISTS company.companies;