충돌은 이 브랜치의 주석 정리 커밋(11d30bb)과 main 의 기능 변경이 겹친 백엔드 9개 파일 —
main 쪽을 그대로 썼다(기능이 main 에만 있다). AGENTS.md 는 양쪽 링크를 모두 두고,
DEVLOG 는 요약본 위에 main 의 09-28 에이전트 항목 셋을 붙였다.
site eslint 통과 · vitest 123 passed · frontend tsc·eslint 통과 · 백엔드 py_compile 통과
(백엔드 pytest 는 이 기계에 테스트 DB 계정이 없어 실행하지 못함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
609 lines
30 KiB
Python
609 lines
30 KiB
Python
import uuid
|
||
|
||
from sqlalchemy.orm import declarative_base
|
||
from sqlalchemy import Column, Date, 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,
|
||
SongStatus,
|
||
SiteStatus,
|
||
PostStatus,
|
||
ReviewStatus,
|
||
BuildStatus,
|
||
JobStatus,
|
||
)
|
||
|
||
# 모든 ORM 모델의 베이스.
|
||
MAIN_BASE = declarative_base()
|
||
|
||
|
||
# 공통 mixin DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||
def _utc_now_sql():
|
||
"""TIMESTAMPTZ 컬럼의 기본값."""
|
||
return text("now()")
|
||
|
||
|
||
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)
|
||
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
|
||
# 소셜 계정은 비밀번호가 없다(NULL).
|
||
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 — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
||
# refresh 토큰 무효화 키.
|
||
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
|
||
|
||
|
||
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
|
||
class places(MainTableMixin, MAIN_BASE):
|
||
"""사업장."""
|
||
|
||
__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)
|
||
|
||
# 카카오 로컬 검증 산출물 (동일 업소 판정)
|
||
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 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션").
|
||
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)이 마지막으로 바뀐 시각.
|
||
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||
# 미니 블로그 승인 메일 수신 주소.
|
||
notify_email = Column(String(255), nullable=True)
|
||
|
||
|
||
|
||
class place_channels(MainTableMixin, MAIN_BASE):
|
||
"""Perplexity 가 발견한 채널 URL."""
|
||
|
||
__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):
|
||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램."""
|
||
|
||
__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):
|
||
"""사진."""
|
||
|
||
__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)
|
||
|
||
|
||
class place_songs(MainTableMixin, MAIN_BASE):
|
||
"""이 숙소의 노래."""
|
||
|
||
__tablename__ = "place_songs"
|
||
|
||
song_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||
title = Column(String(200), nullable=False)
|
||
lyrics = Column(Text, nullable=True) # 화면에 함께 싣는다
|
||
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
||
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
||
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
|
||
origin_url = Column(String(1000), nullable=True) # 만료되는 주소.
|
||
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
|
||
duration_sec = Column(Numeric(6, 2), nullable=True)
|
||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
||
last_error = Column(Text, nullable=True)
|
||
|
||
|
||
# fact : 사실 / FAQ
|
||
class place_facts(MainTableMixin, MAIN_BASE):
|
||
"""가장 중요한 테이블."""
|
||
|
||
__tablename__ = "place_facts"
|
||
__table_args__ = (
|
||
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
|
||
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."""
|
||
|
||
__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 key 배열 — LLM 생성분만 채운다
|
||
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER | TEMPLATE
|
||
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 이 만든 여행 일정."""
|
||
|
||
__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일'.
|
||
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):
|
||
"""지역 정보 캐시."""
|
||
|
||
__tablename__ = "area_contents"
|
||
__table_args__ = (
|
||
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다.
|
||
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"),
|
||
),
|
||
# 날씨: 지역 × 종류당 한 행.
|
||
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 이다.
|
||
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 — 지나면 갱신 대상(값은 유지)
|
||
latitude = Column(Numeric(10, 7), nullable=True)
|
||
longitude = Column(Numeric(10, 7), nullable=True)
|
||
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류.
|
||
kind = Column(String(50), nullable=True)
|
||
|
||
|
||
class place_area_refs(MainTableMixin, MAIN_BASE):
|
||
"""업장 ↔ 지역 콘텐츠."""
|
||
|
||
__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 place_posts(MainTableMixin, MAIN_BASE):
|
||
"""미니 블로그 글 하나."""
|
||
|
||
__tablename__ = "place_posts"
|
||
__table_args__ = (
|
||
Index("uq_place_posts_topic", "place_id", "topic_key", unique=True,
|
||
postgresql_where=text("deleted = false")),
|
||
Index("uq_place_posts_scheduled_date", "place_id", "scheduled_date", unique=True,
|
||
postgresql_where=text("deleted = false AND scheduled_date IS NOT NULL")),
|
||
Index("ix_place_posts_status", "status", "created_at", postgresql_where=text("deleted = false")),
|
||
)
|
||
|
||
post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
||
body = Column(String(400), nullable=False)
|
||
topic_kind = Column(SmallInteger, nullable=False)
|
||
topic_key = Column(String(120), nullable=False)
|
||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
|
||
# 이 업장 몫 하루 한 통 배정일(KST).
|
||
scheduled_date = Column(Date, nullable=True)
|
||
generation_meta = Column(JSONB, nullable=True)
|
||
approve_token_hash = Column(String(64), nullable=True)
|
||
# ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을
|
||
# 통짜로 실어 보냈다 — 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
|
||
edit_token_hash = Column(String(64), nullable=True)
|
||
token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||
published_version_id = Column(UUID(as_uuid=True), nullable=True)
|
||
|
||
|
||
class place_reviews(MainTableMixin, MAIN_BASE):
|
||
"""손님이 남긴 이용 후기."""
|
||
|
||
__tablename__ = "place_reviews"
|
||
__table_args__ = (
|
||
Index("ix_place_reviews_status", "status", "created_at", postgresql_where=text("deleted = false")),
|
||
)
|
||
|
||
review_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
||
body = Column(String(1000), nullable=False)
|
||
nickname = Column(String(40), nullable=True)
|
||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=ReviewStatus.PENDING.value)
|
||
submitted_ip_hash = Column(String(64), nullable=True)
|
||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||
published_version_id = Column(UUID(as_uuid=True), nullable=True)
|
||
|
||
|
||
class sites(MainTableMixin, MAIN_BASE):
|
||
"""발행 대상 사이트."""
|
||
|
||
__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(solution/shared/src/data/templates.json).
|
||
template_id = Column(String(100), nullable=True)
|
||
# 색·섹션(순서·on/off·본문).
|
||
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).
|
||
thumbnail_url = Column(String(500), nullable=True)
|
||
|
||
|
||
class site_search_status(MainTableMixin, MAIN_BASE):
|
||
"""발행 성공과 Google 색인 성공은 다른 사건이라 별도 보관한다."""
|
||
|
||
__tablename__ = "site_search_status"
|
||
site_id = Column(UUID(as_uuid=True), primary_key=True)
|
||
site_version_id = Column(UUID(as_uuid=True), nullable=False)
|
||
property_url = Column(Text, nullable=False)
|
||
page_url = Column(Text, nullable=False)
|
||
published_at = Column(DateTime(timezone=True), nullable=False)
|
||
sitemap_submitted_at = Column(DateTime(timezone=True), nullable=True)
|
||
inspected_at = Column(DateTime(timezone=True), nullable=True)
|
||
first_indexed_at = Column(DateTime(timezone=True), nullable=True)
|
||
inspection = Column(JSONB, nullable=True)
|
||
error_code = Column(String(100), nullable=True)
|
||
failures = Column(Integer, nullable=False, server_default=text("0"))
|
||
next_check_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||
alerted_at = Column(DateTime(timezone=True), nullable=True)
|
||
|
||
|
||
class alert_outbox(MainTableMixin, MAIN_BASE):
|
||
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다."""
|
||
|
||
__tablename__ = "alert_outbox"
|
||
alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
kind = Column(String(50), nullable=False) # job_dead · build_failed · partial_failure · queue_stuck · recovery …
|
||
dedupe_key = Column(String(200), nullable=True)
|
||
title = Column(String(200), nullable=False)
|
||
detail = Column(Text, nullable=True) # 이미 비밀·개인정보를 걷어낸 텍스트만 들어온다(alert_service._scrub)
|
||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # AlertStatus: 1=pending 2=sent 3=failed(소진)
|
||
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
||
next_attempt_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||
|
||
|
||
class site_sections(MainTableMixin, MAIN_BASE):
|
||
"""섹션 하나의 콘텐츠."""
|
||
|
||
__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):
|
||
"""빌드 버전."""
|
||
|
||
__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):
|
||
"""발행 시도 기록."""
|
||
|
||
__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):
|
||
"""작업 큐."""
|
||
|
||
__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)가 적용되지 않는다.
|
||
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) # 잡 출력(완료 시)
|
||
progress = 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)
|
||
|
||
|
||
class owner_social_accounts(MainTableMixin, MAIN_BASE):
|
||
__tablename__ = "owner_social_accounts"
|
||
account_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
||
provider = Column(SmallInteger, nullable=False)
|
||
provider_user_id = Column(String(200), nullable=False)
|
||
handle = Column(String(200), nullable=False)
|
||
profile_url = Column(Text, nullable=False)
|
||
access_token = Column(Text, nullable=True)
|
||
refresh_token = Column(Text, nullable=True)
|
||
access_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||
scopes = Column(JSONB, nullable=False, server_default=text("'[]'"))
|
||
status = Column(String(20), nullable=False, server_default=text("'linked'"))
|
||
last_error = Column(Text, nullable=True)
|
||
__table_args__ = (Index("uq_social_account", "user_id", "provider", unique=True, postgresql_where=text("deleted=false AND status IN ('linked','needs_reauth')")),)
|
||
|
||
|
||
class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
||
"""카카오톡 채널 발화자 ↔ 우리 user_id."""
|
||
|
||
__tablename__ = "owner_kakao_links"
|
||
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
||
channel_user_key = Column(String(200), nullable=True)
|
||
code_sha = Column(String(64), nullable=True)
|
||
code_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||
code_attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
||
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
||
linked_at = Column(DateTime(timezone=True), nullable=True)
|
||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||
# pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
|
||
current_place_id = Column(UUID(as_uuid=True), nullable=True)
|
||
pending_tool = Column(String(40), nullable=True)
|
||
pending_args = Column(JSONB, nullable=True)
|
||
pending_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||
__table_args__ = (
|
||
Index("uq_kakao_link_user", "user_id", unique=True, postgresql_where=text("deleted=false AND status IN ('PENDING','LINKED')")),
|
||
Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")),
|
||
Index("uq_kakao_link_code", "code_sha", unique=True, postgresql_where=text("deleted=false AND status='PENDING'")),
|
||
)
|
||
|
||
|
||
class place_social_posts(MainTableMixin, MAIN_BASE):
|
||
__tablename__ = "place_social_posts"
|
||
post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
||
site_version_id = Column(UUID(as_uuid=True), nullable=False)
|
||
account_id = Column(UUID(as_uuid=True), nullable=True)
|
||
provider = Column(SmallInteger, nullable=False)
|
||
body = Column(Text, nullable=False, server_default=text("''"))
|
||
link_url = Column(Text, nullable=False)
|
||
grounded_facts = Column(JSONB, nullable=False, server_default=text("'[]'"))
|
||
status = Column(String(24), nullable=False, server_default=text("'DRAFTING'"))
|
||
approval_token_sha = Column(String(64), nullable=True)
|
||
approval_sent_at = Column(DateTime(timezone=True), nullable=True)
|
||
approval_channel = Column(String(20), nullable=True)
|
||
approval_expires_at = Column(DateTime(timezone=True), nullable=True)
|
||
decided_at = Column(DateTime(timezone=True), nullable=True)
|
||
decided_via = Column(String(20), nullable=True)
|
||
provider_post_id = Column(String(200), nullable=True)
|
||
permalink = Column(Text, nullable=True)
|
||
posted_at = Column(DateTime(timezone=True), nullable=True)
|
||
last_error = Column(Text, nullable=True)
|
||
__table_args__ = (Index("uq_social_version", "place_id", "site_version_id", unique=True, postgresql_where=text("deleted=false")), Index("idx_social_posted", "place_id", "posted_at", postgresql_where=text("deleted=false AND status='POSTED'")),)
|