o2o-site-AEO/solution/backend/common/database/model/models.py

530 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import uuid
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text
from common.enums import (
AuthProvider,
DBType,
UserStatus,
UserRole,
PlaceStatus,
FactStatus,
SourceType,
MediaStatus,
SiteStatus,
BuildStatus,
JobStatus,
)
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
MAIN_BASE = declarative_base()
# 공통 mixin
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
def _utc_now_sql():
return text("(now() AT TIME ZONE 'utc')")
class _DBTypeMixin:
"""모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택)."""
@staticmethod
def DBType():
return DBType.MAIN.value
# ERD 공통 컬럼
class MainTableMixin(_DBTypeMixin):
created_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql())
deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False)
# ERD 도메인 모델
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
# 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여
# id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다.
password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True)
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
# ============================================================
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
# ============================================================
class places(MainTableMixin, MAIN_BASE):
"""사업장. 상호명 하나로 시작해서, 카카오 로컬 검증을 통과해야 수집이 열린다.
★ verified_at 이 NULL 이면 collector 진입 금지 — 검증 없이 수집하면 남의 가게가 섞인다."""
__tablename__ = "places"
__table_args__ = (
Index("idx_places_region_code", "region_code", postgresql_where=text("deleted = false")),
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
# ---- 카카오 로컬 검증 산출물 (동일 업소 판정) ----
# 동일 업소 판정 키. 소스에 따라 있을 수도 없을 수도 있다 —
# 카카오는 고유 id 를 주지만 네이버는 안 준다(그 경우 상호명+도로명주소가 대체 키).
external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource
external_place_id = Column(String(64), nullable=True)
road_address = Column(String(255), nullable=True)
address = Column(String(255), nullable=True) # 지번
phone = Column(String(30), nullable=True)
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다.
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
external_category = Column(String(200), nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지
verified_by = Column(UUID(as_uuid=True), nullable=True)
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
content_updated_at = Column(DateTime(timezone=True), nullable=True)
class place_channels(MainTableMixin, MAIN_BASE):
"""Perplexity 가 발견한 채널 URL.
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
__tablename__ = "place_channels"
__table_args__ = (
Index(
"uq_place_links_place_url",
"place_id",
"url",
unique=True,
postgresql_where=text("deleted = false"),
),
)
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
channel = Column(SmallInteger, nullable=False) # LinkChannel
url = Column(String(1000), nullable=False)
title = Column(String(300), nullable=True) # 발견 시 제목/스니펫
discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력)
discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
confirmed_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미확정, 크롤링 금지
confirmed_by = Column(UUID(as_uuid=True), nullable=True)
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
class place_units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "place_units"
unit_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
name = Column(String(200), nullable=False)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class place_photos(MainTableMixin, MAIN_BASE):
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다.
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
__tablename__ = "place_photos"
media_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
unit_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 객실·메뉴 사진이면 연결
url = Column(String(1000), nullable=False) # 우리가 보관하는 접근 URL
origin_url = Column(String(1000), nullable=True) # 수집 원본 이미지 URL
source_type = Column(SmallInteger, nullable=False) # SourceType — OWNER 업로드 / CRAWL 수집
source_url = Column(String(1000), nullable=True) # 수집한 페이지 URL
label = Column(String(200), nullable=True) # Vision 분류 라벨 (예: "A동 침실")
alt_text = Column(String(500), nullable=True) # Vision 생성 alt
vision_confidence = Column(Numeric(4, 3), nullable=True) # 0.000~1.000
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=MediaStatus.PENDING_REVIEW.value)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
# ============================================================
# fact : 사실 / FAQ
# ============================================================
class place_facts(MainTableMixin, MAIN_BASE):
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
- key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다.
- unit_id 가 NULL 이면 사업장 단위 fact, 있으면 객실·메뉴·프로그램 단위 fact.
- ★ VERIFIED / CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES).
- ★ CORRECTED(사장님 수정본)는 잠긴다 — 자동 갱신이 덮어쓰지 않는다.
활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다."""
__tablename__ = "place_facts"
__table_args__ = (
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
Index(
"uq_facts_published_place_key",
"place_id",
"key",
unique=True,
postgresql_where=text("deleted = false AND unit_id IS NULL AND status IN (3, 4)"),
),
Index(
"uq_facts_published_unit_key",
"place_id",
"unit_id",
"key",
unique=True,
postgresql_where=text("deleted = false AND unit_id IS NOT NULL AND status IN (3, 4)"),
),
# 후보 조회 경로(사람 확인 큐) — 재수집이 올려놓은 대기 항목.
Index(
"idx_facts_candidate",
"place_id",
"key",
"source_type",
postgresql_where=text("deleted = false AND status IN (1, 2)"),
),
# 발행 게이트가 "노출 가능한 fact" 만 훑는 경로.
Index(
"idx_facts_publishable",
"place_id",
"status",
postgresql_where=text("deleted = false AND status IN (3, 4)"),
),
)
fact_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
unit_id = Column(UUID(as_uuid=True), nullable=True, index=True)
key = Column(String(100), nullable=False) # 업종 스키마의 필드 key
value = Column(Text, nullable=True)
unit = Column(String(30), nullable=True) # 값의 단위(원·명·분…)
source_type = Column(SmallInteger, nullable=False) # SourceType — owner | api | crawl | llm
source_url = Column(String(1000), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
verified_by = Column(UUID(as_uuid=True), nullable=True) # users.user_id
verified_at = Column(DateTime(timezone=True), nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
expires_at = Column(DateTime(timezone=True), nullable=True) # 지나면 EXPIRED 전이 대상
class place_faqs(MainTableMixin, MAIN_BASE):
"""FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다."""
__tablename__ = "place_faqs"
faq_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
question = Column(String(500), nullable=False)
answer = Column(Text, nullable=False)
source_fact_ids = Column(JSONB, nullable=True) # ★ 근거 fact_id 배열. 비면 반려
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class place_itineraries(MainTableMixin, MAIN_BASE):
"""LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다.
★ 왜 area_contents 가 아닌가
그 표의 유일성 근거는 셋 다 지역·출처 기준이다((source, external_id) ·
(region_code, kind) · (region_code, content_type)). 이 값은 **업장 하나에 붙는다** —
업소 이름이 프롬프트에 들어가고, 같은 지역 옆집이 나눠 쓸 수 없다.
넷째 근거를 그 표에 더하면 0004·0007 에서 겪은 "제약이 겹쳐 조용히 틀리는" 사고를
다시 만든다(area_contents.__table_args__ 주석).
★ body 는 렌더러 계약 그대로다(`shared/lib/section-data.ts` 의 ItineraryItem[]).
읽는 쪽이 모양을 다시 바꾸지 않아야 사장님이 손으로 붙여넣은 것과 갈리지 않는다 —
지역 이야기가 body 에 봉투째 담는 것과 같은 이유다.
★ 코스마다 한 행으로 쪼개지 않는다. 다시 생성할 때 그 한 행을 덮어쓰면 되고,
쪼개면 "5개를 받았는데 3개만 갱신된" 상태가 생긴다.
"""
__tablename__ = "place_itineraries"
__table_args__ = (
Index(
"uq_place_itineraries",
"place_id", "duration",
unique=True,
postgresql_where=text("deleted = false"),
),
)
place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# '1박 2일' · '2박 3일'. 화면 탭이 되는 값이라 표기를 바꾸지 않는다(prompts/itinerary.DURATIONS).
duration = Column(String(20), nullable=False)
body = Column(JSONB, nullable=False) # ItineraryItem[]
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM
model = Column(String(100), nullable=True) # 'perplexity:sonar'
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
# ============================================================
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================
class area_contents(MainTableMixin, MAIN_BASE):
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "area_contents"
# ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 —
# 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만
# 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류).
# 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다.
# ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는
# 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 —
# 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다.
__table_args__ = (
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** —
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
Index(
"uq_local_contents_external",
"source",
"external_id",
unique=True,
postgresql_where=text("deleted = false AND external_id IS NOT NULL"),
),
# 지역 이야기: 한 지역에 종류당 한 벌.
Index(
"uq_local_contents_kind",
"region_code",
"kind",
unique=True,
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
),
# 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007).
Index(
"uq_local_contents_single",
"region_code",
"content_type",
unique=True,
postgresql_where=text("deleted = false AND external_id IS NULL AND kind IS NULL"),
),
)
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ 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
title = Column(String(300), nullable=True)
body = Column(JSONB, nullable=False) # 원문 페이로드
status = Column(SmallInteger, nullable=False, server_default=text("1")) # LocalContentStatus
published_at = Column(DateTime(timezone=True), nullable=True)
published_by = Column(UUID(as_uuid=True), nullable=True)
display_start_at = Column(DateTime(timezone=True), nullable=True)
display_end_at = Column(DateTime(timezone=True), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL.
kind = Column(String(50), nullable=True)
class place_area_refs(MainTableMixin, MAIN_BASE):
"""업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다.
★ 예전엔 값을 통째로 들고 키가 place_id 라 업장마다 복제됐다(한 곳에 144행).
★ hidden 은 재수집이 덮어쓰지 않는다."""
__tablename__ = "place_area_refs"
place_id = Column(UUID(as_uuid=True), primary_key=True) # places.place_id
local_content_id = Column(UUID(as_uuid=True), primary_key=True) # area_contents.local_content_id
distance_m = Column(Integer, nullable=True) # 정렬·도보 시간의 원값
hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False)
class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
__tablename__ = "sites"
__table_args__ = (
Index("uq_sites_place", "place_id", unique=True, postgresql_where=text("deleted = false")),
Index("uq_sites_domain", "domain", unique=True, postgresql_where=text("deleted = false AND domain IS NOT NULL")),
)
site_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False)
domain = Column(String(255), nullable=True)
path_prefix = Column(String(100), nullable=True)
# 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리의 id). 서버는 해석하지 않고 보관·반환만 한다 —
# 템플릿 목록은 프론트가 소유하므로, 서버가 값을 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다.
# NULL 이면 발행 잡이 업종 기본 템플릿으로 굽는다(services/site_payload).
template_id = Column(String(100), nullable=True)
# 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션). template_id 와 같은 이유로 서버에 저장한다 —
# 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽고, 고른 디자인과 발행본이 갈린다.
# ★ 컬럼으로 펼치지 않고 jsonb 로 통째로 담는 이유: 섹션 목록·배리에이션 키·색 토큰 이름은
# 프론트가 소유한다. 펼치면 프론트가 항목 하나 늘릴 때마다 마이그레이션이 따라와야 한다.
# ★ templateId 는 여기 넣지 않는다 — 위 template_id 컬럼이 소유한다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
# NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
theme = Column(JSONB, nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
published_at = Column(DateTime(timezone=True), nullable=True)
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
thumbnail_url = Column(String(500), nullable=True)
class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
★ 왜 theme 에서 꺼냈나 (2026-09-09)
색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다.
실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다.
크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도
42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다
"누가 넣었나 · 확인됐나"를 물을 자리가 없었다.
★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다.
★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 —
발행할 때 펼쳐 payload 에 싣는다."""
__tablename__ = "site_sections"
__table_args__ = (
Index(
"uq_site_contents_section",
"site_id", "section_id",
unique=True,
postgresql_where=text("deleted = false"),
),
)
site_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
section_id = Column(String(50), nullable=False) # 'songs' 'itinerary' 'video' 'people' …
data = Column(JSONB, nullable=False) # shared 의 XxxItem[] 계약
source_type = Column(SmallInteger, nullable=False, server_default=text("1"), default=SourceType.OWNER.value)
shared_ref = Column(UUID(as_uuid=True), nullable=True) # 공유 원본을 가리킬 때
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
class site_versions(MainTableMixin, MAIN_BASE):
"""빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다.
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다.
★ jsonld 값은 화면에 보이는 값과 같아야 한다 — 불일치면 빌드 실패(PUBLISH_JSONLD_MISMATCH).
★ unique_content_count 가 0 이면 발행 API 가 거부한다(스팸 판정 대상)."""
__tablename__ = "site_versions"
__table_args__ = (
Index("uq_site_versions_no", "site_id", "version", unique=True, postgresql_where=text("deleted = false")),
)
site_version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
version = Column(Integer, nullable=False) # 1부터
build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value)
snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제
jsonld = Column(JSONB, nullable=True) # 구조화 데이터
unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # ★ 0 이면 발행 거부
build_error = Column(Text, nullable=True)
built_at = Column(DateTime(timezone=True), nullable=True)
class site_publish_logs(MainTableMixin, MAIN_BASE):
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
__tablename__ = "site_publish_logs"
publish_log_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
site_id = Column(UUID(as_uuid=True), nullable=False, index=True)
site_version_id = Column(UUID(as_uuid=True), nullable=True)
action = Column(SmallInteger, nullable=False) # PublishAction
result = Column(SmallInteger, nullable=False) # PublishResult
reject_reason = Column(SmallInteger, nullable=True) # PublishRejectReason
detail = Column(JSONB, nullable=True) # 막힌 항목 목록(미검증 fact key 등)
actor_user_id = Column(UUID(as_uuid=True), nullable=True)
class jobs(MainTableMixin, MAIN_BASE):
"""작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다.
- 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING.
워커 컨테이너가 몇 개든 같은 잡 이중 할당이 불가능하다.
- 복구는 타임아웃 추측이 아니라 **lease 만료 소유권** — 워커가 죽어도 reaper 가 회수한다.
(도커에서 컨테이너를 재시작해도 진행 중이던 잡이 증발하지 않는다.)
- 재시도·백오프·dead-letter 를 큐에 내장한다.
- dedupe_key 로 활성 중복(PENDING/RUNNING)을 막는다 — 같은 사업장 수집이 두 번 돌지 않게.
※ 이 테이블만 MainTableMixin 의 deleted 를 쓰지 않는다(잡은 이력이지 소프트 삭제 대상이 아니다).
그래도 컬럼은 남겨 공통 규약을 깨지 않는다.
"""
__tablename__ = "jobs"
__table_args__ = (
# claim 경로: status=PENDING & run_after<=now() 을 priority·created_at 순으로 훑는다.
Index("ix_jobs_claim", "status", "run_after", "priority", "created_at"),
# reaper 경로: 만료된 lease 회수.
Index("ix_jobs_lease", "status", "lease_until"),
# 활성 중복 방지 — 같은 dedupe_key 는 PENDING(1)/RUNNING(2) 중 하나만.
Index(
"uq_jobs_dedupe_active",
"dedupe_key",
unique=True,
postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"),
),
)
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라
# ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. init.sql 의 DEFAULT gen_random_uuid() 와 맞춘다.
# (다른 테이블은 ORM 으로만 INSERT 하므로 원본 보일러플레이트대로 Python default 만 둔다.)
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4)
job_type = Column(SmallInteger, nullable=False) # JobType
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value)
priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3)
run_after = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) # 백오프
lease_until = Column(DateTime(timezone=True), nullable=True) # 소유권 임대 만료(reaper 회수 기준)
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각
last_error = Column(Text, nullable=True)