o2o-site-AEO/solution/backend/common/database/model/models.py
김성경 01824a7a91 [feat] solution,admin,postgres-init: 주변정보를 업장 좌표 기준으로 — 시군구 캐시는 "가까운 곳"을 못 준다
지역 정보(맛집·관광지·축제)는 행정구역 코드(local_contents.region_code) 단위로 캐시돼
"그 시군구에 있는 것"을 줬다. 양양군 업장 옆 5km 속초 관광지는 빠지고 같은 군 반대편
30km 맛집이 붙는 구조라, 캔버스의 지역 정보 섹션은 늘 "준비 중"이었다. 업장 좌표로
TourAPI 를 직접 물어 업장 단위(place_contents)에 담고, 캔버스는 스크린샷으로 받은
형식(도보 시간 필터 + 카드 캐러셀)으로 통일했다.
2026-09-08 11:46:03 +09:00

560 lines
32 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,
CompanyStatus,
PlaceStatus,
FactStatus,
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 companies(MainTableMixin, MAIN_BASE):
__tablename__ = "companies"
__table_args__ = {"schema": "company"}
company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
business_number = Column(String(30), nullable=True)
code = Column(Integer, nullable=True) # 내부 인덱스용
representative_name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True)
website_url = Column(String(255), nullable=True)
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
# 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)
settings = Column(JSONB, nullable=True)
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
__table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 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")),
{"schema": "place"},
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies)
owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.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_aliases(MainTableMixin, MAIN_BASE):
"""상호 별칭. 동명 업소 구분과 재검색에 쓴다(옛 상호, '본관/별관' 표기 등)."""
__tablename__ = "place_aliases"
__table_args__ = {"schema": "place"}
alias_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alias = Column(String(200), nullable=False)
source_type = Column(SmallInteger, nullable=False) # SourceType
class place_links(MainTableMixin, MAIN_BASE):
"""Perplexity 가 발견한 채널 URL.
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
__tablename__ = "place_links"
__table_args__ = (
Index(
"uq_place_links_place_url",
"place_id",
"url",
unique=True,
postgresql_where=text("deleted = false"),
),
{"schema": "place"},
)
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 units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "units"
__table_args__ = {"schema": "place"}
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 media(MainTableMixin, MAIN_BASE):
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다.
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
__tablename__ = "media"
__table_args__ = {"schema": "place"}
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 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__ = "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)"),
),
{"schema": "fact"},
)
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) # company.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 faqs(MainTableMixin, MAIN_BASE):
"""FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다."""
__tablename__ = "faqs"
__table_args__ = {"schema": "fact"}
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)
# ============================================================
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================
class local_contents(MainTableMixin, MAIN_BASE):
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "local_contents"
__table_args__ = (
# external_id 가 있는 항목(축제·관광지·맛집)은 출처 고유 ID 로 중복을 막는다.
Index(
"uq_local_contents_keyed",
"region_code",
"content_type",
"external_id",
unique=True,
postgresql_where=text("deleted = false AND external_id IS NOT NULL"),
),
# external_id 가 없는 항목(날씨)은 지역 × 종류당 1행.
Index(
"uq_local_contents_single",
"region_code",
"content_type",
unique=True,
postgresql_where=text("deleted = false AND external_id IS NULL"),
),
{"schema": "local"},
)
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
region_code = Column(String(10), nullable=False, 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 — 지나면 갱신 대상(값은 유지)
class place_contents(MainTableMixin, MAIN_BASE):
"""업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id 다.
local_contents 는 행정구역(region_code) 단위 캐시라 '그 시군구에 있는 것'을 줄 뿐
'이 업장에서 가까운 것'을 못 준다(2026-09-04 결정, specs/2026-09-04-tourapi-radius-spike.md).
그래서 업장 좌표로 TourAPI locationBasedList2 를 직접 물어 여기 담는다.
★ 빌드 때마다 갱신된다(services/local_content_service.sync_place). 응답에서 사라진 행은
소프트 삭제한다 — 업장 전용 데이터라 반경 밖으로 밀린 것을 남길 이유가 없다.
단 hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다.
★ 외부 API 실패 시 행을 지우거나 비우지 않는다 — 직전 값을 그대로 쓴다."""
__tablename__ = "place_contents"
__table_args__ = (
Index(
"uq_place_contents_keyed",
"place_id", "content_type", "external_id",
unique=True,
postgresql_where=text("deleted = false"),
),
{"schema": "local"},
)
place_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
content_type = Column(SmallInteger, nullable=False) # LocalContentType 2~5
external_id = Column(String(100), nullable=False) # TourAPI contentid
title = Column(String(300), nullable=False)
body = Column(JSONB, nullable=False) # 정규화한 TourAPI 항목(좌표·주소·사진·기간)
distance_m = Column(Integer, nullable=False) # 업장 좌표에서의 거리(m). 정렬 기준
has_image = Column(Boolean, nullable=False, server_default=text("false"), default=False) # 상업 이용 가능한 대표사진이 있는가
display_end_at = Column(DateTime(timezone=True), nullable=True) # 축제 종료(KST 자정). 지나면 스냅샷이 거른다
hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False) # 운영자 숨김
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
class routes(MainTableMixin, MAIN_BASE):
"""가는 길. 검증 상태(FactStatus)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다."""
__tablename__ = "routes"
__table_args__ = {"schema": "local"}
route_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
origin_name = Column(String(200), nullable=False) # 출발지 (예: "서울역")
transport = Column(SmallInteger, nullable=False) # TransportType
duration_min = Column(Integer, nullable=True)
distance_m = Column(Integer, nullable=True)
description = Column(Text, nullable=True)
source_type = Column(SmallInteger, nullable=False) # SourceType
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 nearby_links(MainTableMixin, MAIN_BASE):
"""주변 맛집·시설. 카카오 카테고리 검색 결과를 사업장에 붙인 것.
카테고리 검색은 좌표 변환보다 4배 비싸므로 region_code 단위 캐시(local_contents)에서 파생시킨다."""
__tablename__ = "nearby_links"
__table_args__ = {"schema": "local"}
nearby_link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
region_code = Column(String(10), nullable=True) # 어느 지역 캐시에서 왔는지
name = Column(String(200), nullable=False)
category_name = Column(String(100), nullable=True) # 카카오 category_name
kakao_place_id = Column(String(32), nullable=True)
distance_m = Column(Integer, nullable=True)
url = Column(String(1000), nullable=True)
latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True)
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
# ============================================================
# site : 사이트 / 버전 / 발행 로그 / AI 노출 점검
# ============================================================
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")),
{"schema": "site"},
)
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.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_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")),
{"schema": "site"},
)
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 publish_logs(MainTableMixin, MAIN_BASE):
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
__tablename__ = "publish_logs"
__table_args__ = {"schema": "site"}
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 ai_check_results(MainTableMixin, MAIN_BASE):
"""AI 검색 노출 점검. 이 서비스의 목표 지표 —
AI 가 이 가게를 **우리 사이트를 근거로** 설명하는가, 아니면 여전히 OTA 를 인용하는가."""
__tablename__ = "ai_check_results"
__table_args__ = {"schema": "site"}
ai_check_result_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
engine = Column(SmallInteger, nullable=False) # AiEngine
query = Column(String(500), nullable=False) # 던진 질의
answer = Column(Text, nullable=True)
cited_urls = Column(JSONB, nullable=True) # 인용된 URL 목록
is_own_site_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # ★ 핵심 지표
ota_cited = Column(Boolean, nullable=False, server_default=text("false"), default=False) # OTA 가 대신 인용됐는지
checked_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
# ============================================================
# job : 작업 큐 (PostgreSQL 을 큐로 — LPS 의 job 큐 구조 이식)
# ============================================================
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"),
),
{"schema": "job"},
)
# ★ 이 테이블만 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)