[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>
This commit is contained in:
Mina Choi 2026-09-10 11:14:39 +09:00
parent 3e62d08e39
commit 3910feddfb
3 changed files with 381 additions and 430 deletions

View File

@ -1,24 +1,40 @@
-- 단일 초기화 파일 — 스키마 DDL 전부를 이 한 파일로 적용한다.
-- 전부 IF NOT EXISTS 라 재실행 안전. 기존 DB 에 재실행하면 말미의 "기존 DB 보정(ALTER)" 섹션이 최신 스키마로 맞춰준다.
-- 컬럼 추가/타입 변경은 postgres-init/alters/YYYY-MM-DD-<주제>.sql 로 따로 쌓는다.
-- 새 DB 를 세우는 전체 DDL — **이 파일 한 벌이 최신 스키마다.**
--
-- 단일 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 를 **처음 만들 때만** 돈다(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 스키마 안에 있는 것이 하나도 없었다. 지금은 **접두어가 소속을 말한다.**
--
-- 설계 컨벤션
-- - 단일 DB 안에서 도메인별 schema 로 묶는다. 테이블은 schema 한정자로 참조한다.
-- - FK 제약은 걸지 않고 관계 컬럼만 둔다 (무결성은 애플리케이션 레이어에서 관리). schema 간 관계도 동일.
-- - 코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다 (1부터; 매핑은 애플리케이션 enum 기준, CHECK 없음).
-- - 소프트 삭제(deleted BOOLEAN)를 사용하므로 자연키 유니크는 부분 인덱스로 건다.
-- - 시각은 TIMESTAMPTZ, 금액은 BIGINT, 비율은 NUMERIC 으로 둔다.
-- - 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 로 처리한다 (재실행 안전).
@ -32,115 +48,137 @@ SELECT 'CREATE DATABASE web4ai_db'
ALTER DATABASE web4ai_db SET timezone TO 'UTC';
SET timezone TO 'UTC';
-- uuid 기본값(gen_random_uuid) 사용을 위한 확장 (public 스키마에 설치)
-- uuid 기본값(gen_random_uuid) 사용을 위한 확장
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- 스키마 (도메인별 네임스페이스)
-- ★ 옛 스키마가 있는 DB 에서는 여기서 멈춘다
-- ============================================================
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;
-- 이 파일은 이제 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 $$;
-- ============================================================
-- 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_<sub>)
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 -- 소프트 삭제 여부
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
);
-- ============================================================
-- place : 사업장 / 별칭 / 채널 링크 / 하위 단위 / 사진
-- 작업 큐 — 원자적 CAS claim + lease 소유권 + dead-letter
-- 수집·비전분석·빌드는 몇 분 걸린다. 동기 요청으로 처리하지 않는다.
-- 워커가 죽어도 lease 가 만료되면 reaper 가 회수한다.
-- ============================================================
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 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 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 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
);
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) — 환각 추적용, 사실 근거 아님
-- 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
);
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, -- 노출 순서
-- 업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과=프로그램.
-- 가변 필드는 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 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
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,
@ -149,38 +187,36 @@ CREATE TABLE IF NOT EXISTS place.media (
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 전이 대상
-- ★ 가장 중요한 표. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
-- 출처 없는 사실은 이 제품의 규칙 위반이다.
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
);
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)
-- ★ 확보된 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(),
@ -188,362 +224,231 @@ CREATE TABLE IF NOT EXISTS fact.faqs (
);
-- ============================================================
-- local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
-- ★ 캐시 키는 place_id 가 아니라 region_code — 같은 지역 사이트 50개여도 외부 조회는 1회.
-- ★ 외부 API 실패 시 행을 지우거나 비우지 않는다. 직전 값을 유지하고 내부 알림만 낸다.
-- 지역 콘텐츠 — 접두어가 없는 유일한 표. **그게 공유물이라는 표시다.**
-- ★ 키는 place_id 가 아니다. 축제·관광지·맛집의 유일성은 출처가 준 external_id 이고,
-- 지역 이야기(가요·일력·인물·연표·엽서·퀴즈)의 유일성은 (region_code, kind) 다.
-- ★ 외부 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
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 — 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
-- 업장 ↔ 지역 콘텐츠. **업장별로 다른 것은 거리와 숨김뿐이다.**
-- ★ 예전에는 이 표가 값을 통째로 들고 있었다. 키가 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)
);
-- ============================================================
-- site : 사이트 / 버전 / 발행 로그 / AI 노출 점검
-- 사이트
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
-- ★ 개별 재빌드 단위. 해지는 삭제가 아니라 상태 전이.
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
-- ============================================================
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). 발행에 성공한 뒤에만 채워진다 — 쇼케이스·사이트 목록 카드가 읽는다
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 의 단위**다.
-- 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 만 노출
-- 실측(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 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, -- 빌드 완료 시각
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
);
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)
-- 발행 시도 기록. ★ 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
);
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)로 건다.
-- ============================================================
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE 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;
-- 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)
-- 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 UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON place.place_links (place_id, url)
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;
-- 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);
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=UNVERIFIED, 2=PENDING_OWNER)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것.
-- 후보(1·2)는 여러 건 공존한다 — 재수집이 노출값을 밀어내지 않고 쌓이게 하려는 것이다.
-- 유니크를 활성 전체에 걸면 재수집 때마다 확인된 값이 사이트에서 사라진다.
-- unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 사업장 단위 / 하위 단위를 나눠 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_facts_published_place_key ON fact.facts (place_id, key)
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 fact.facts (place_id, unit_id, key)
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 fact.facts (place_id, key, source_type)
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"(VERIFIED·CORRECTED) 만 훑는 경로.
CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status)
-- 발행 게이트가 "노출 가능한 fact" 만 훑는 경로.
CREATE INDEX IF NOT EXISTS idx_facts_publishable ON public.place_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;
-- 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;
-- 지역 캐시 중복 방지. 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;
-- ★ 유일성의 근거가 셋이고 서로 겹치면 안 된다. 실제로 겹쳐서 조용히 틀린 적이 있다 —
-- 지역 이야기 다섯 종이 (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; -- 날씨: 지역 × 종류 한 행
-- 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 INDEX IF NOT EXISTS idx_place_contents_place ON public.place_area_refs (place_id) WHERE deleted = FALSE;
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;
-- 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;
-- job (작업 큐)
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 job.jobs (status, 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 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)
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;
-- ============================================================
-- 기존 DB 보정(ALTER)
-- 마이그레이션 기준선(baseline)
-- ============================================================
-- 위 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)
-- ★ 위 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()
);
-- 2026-09-02 구글 로그인. 소셜 계정은 비밀번호가 없고(NULL), 로그인 아이디가 google_<sub> 라 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;
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;

View File

@ -0,0 +1,43 @@
-- 0009 · init.sql 을 현재 스키마로 다시 쓰면서 드러난 어긋남을 맞춘다
--
-- ★ 어떻게 찾았나 (2026-09-10)
-- `init.sql` 이 0005 의 스키마 해체를 따라오지 않아 옛 도메인 스키마를 그대로 만들고 있었다.
-- 그걸 현재 모습으로 다시 쓴 뒤, **빈 컨테이너에 새로 세운 DB** 와 **마이그레이션으로 따라온
-- 기존 DB** 를 `pg_dump --schema-only` 로 나란히 놓고 비교했다. 표 목록은 같았고 아래 셋이 달랐다.
-- 이 비교는 앞으로도 같은 방법으로 한다 — ORM 주석이나 기억이 아니라 두 DB 를 실제로 찍어 본다.
--
-- ★ 이 파일은 데이터를 건드리지 않는다. 이름·타입·인덱스만 맞춘다.
-- ── 1. 없는 인덱스 (실사용에 영향) ──────────────────────────────────────
-- 0003 이 기존 DB 에 site_contents 를 만들 때 유니크(uq_site_contents_section)만 걸고
-- place 조회용 인덱스를 빠뜨렸다. init.sql 에는 처음부터 있었으므로 **새 DB 에만 있고
-- 기존 DB 에는 없는** 상태였다 — 사이트 하나의 섹션을 읽을 때마다 시퀀셜 스캔이다.
CREATE INDEX IF NOT EXISTS idx_site_contents_site ON public.site_sections (site_id);
-- ── 2. 컬럼 폭 (ORM 과 불일치) ──────────────────────────────────────────
-- ORM 은 String(64) 인데 DB 는 VARCHAR(32) 였다. 지금 쓰는 카카오 place id 는 8~10자라
-- 아직 안 터졌지만, 다른 출처(네이버·TourAPI)의 id 를 넣는 날 잘려 들어간다 —
-- 잘린 id 는 "동일 업소 판정" 을 조용히 틀리게 만드는 종류다.
ALTER TABLE public.places ALTER COLUMN external_place_id TYPE VARCHAR(64);
-- ── 3. 옛 이름이 남은 PK 제약 ───────────────────────────────────────────
-- `ALTER TABLE ... RENAME TO` 는 제약 이름을 따라 바꾸지 않는다. 그래서 0005 이후로
-- `place_facts` 의 PK 가 `facts_pkey` 로 남아 있었다. 동작에는 영향이 없지만,
-- 스키마를 덤프해 비교할 때마다 "새 DB 와 기존 DB 가 다르다" 로 보인다 —
-- 진짜 차이를 찾는 눈을 가리는 잡음이라 지금 맞춰 둔다.
ALTER INDEX IF EXISTS public.facts_pkey RENAME TO place_facts_pkey;
ALTER INDEX IF EXISTS public.faqs_pkey RENAME TO place_faqs_pkey;
ALTER INDEX IF EXISTS public.media_pkey RENAME TO place_photos_pkey;
ALTER INDEX IF EXISTS public.units_pkey RENAME TO place_units_pkey;
ALTER INDEX IF EXISTS public.place_links_pkey RENAME TO place_channels_pkey;
ALTER INDEX IF EXISTS public.local_contents_pkey RENAME TO area_contents_pkey;
ALTER INDEX IF EXISTS public.place_contents_pkey RENAME TO place_area_refs_pkey;
ALTER INDEX IF EXISTS public.site_contents_pkey RENAME TO site_sections_pkey;
ALTER INDEX IF EXISTS public.publish_logs_pkey RENAME TO site_publish_logs_pkey;
-- 0004 가 place_contents 를 새로 만들면서 붙은 번호(_pkey1). 위 이름이 이미 비어 있으면
-- 이쪽이 진짜 PK 다.
ALTER INDEX IF EXISTS public.place_contents_pkey1 RENAME TO place_area_refs_pkey;
-- ★ 인덱스 이름은 바꾸지 않는다(idx_facts_place · uq_place_links_place_url …).
-- ORM 의 __table_args__ 가 그 이름을 들고 있어서, 여기서 바꾸면 ORM 도 같이 고쳐야 하고
-- 그 사이에 같은 인덱스가 두 벌 생긴다. 제약 이름과 달리 이건 코드가 참조한다.

View File

@ -286,7 +286,10 @@ class area_contents(MainTableMixin, MAIN_BASE):
)
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
region_code = Column(String(10), nullable=False, index=True) # 카카오 행정구역 코드 = 캐시 키
# ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 —
# 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004).
# 지역 이야기·날씨만 이 값을 키로 쓴다.
region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드
content_type = Column(SmallInteger, nullable=False) # LocalContentType
source = Column(SmallInteger, nullable=False) # LocalSource
external_id = Column(String(100), nullable=True) # TourAPI contentid 등 출처 고유 ID