o2o-site-AEO/solution/backend/common/database/model/models.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

606 lines
30 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, 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)
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'")),)