런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 — 새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다. ★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고 userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest) + 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다 (401 은 "여기 뭔가 있다" 를 알려 준다). - router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로 - services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인 - 0022: owner_kakao_links 에 current_place_id · pending_* 빌더 화면과 다른 것 셋: - 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다 - place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다 - 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 돈다 5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 — 메신저에서는 500 도 침묵으로 보인다. 밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)). 우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜 화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다. test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일). npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
760 lines
46 KiB
Python
760 lines
46 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 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
||
MAIN_BASE = declarative_base()
|
||
|
||
|
||
# 공통 mixin
|
||
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||
def _utc_now_sql():
|
||
"""TIMESTAMPTZ 컬럼의 기본값. **init.sql 과 같은 `now()` 여야 한다.**
|
||
|
||
★ 예전 값은 `(now() AT TIME ZONE 'utc')` 였는데, 이건 timestamptz 에 쓰면 틀린다.
|
||
AT TIME ZONE 'utc' 는 timestamptz 를 **시간대 없는 벽시계 값**으로 떨어뜨리고,
|
||
그 값이 timestamptz 컬럼에 들어가며 세션 시간대로 다시 해석된다 — 서버 시간대만큼
|
||
미래(또는 과거)로 밀린 시각이 저장된다.
|
||
|
||
★ 운영에서는 안 드러났다. 운영 DB 는 init.sql(`DEFAULT now()`)로 만들어지고, 이 기본값은
|
||
**ORM 이 스키마를 만들 때만** 쓰이기 때문이다 — 즉 테스트 DB 뿐이다(conftest).
|
||
실측(2026-09-10): 테스트에서 잡의 run_after 가 7시간 뒤로 박혀 claim 조건
|
||
(`run_after <= now()`)에 영영 안 걸렸다. 워커가 잡을 하나도 못 집어 COPY 관련 테스트가
|
||
"잡이 PENDING 인 채" 로 무더기 실패했고, 원인이 코드가 아니라 스키마라 읽히지 않았다.
|
||
→ 스키마는 init.sql 이 단일 출처다. ORM 기본값이 그것과 다르면 이런 식으로 갈라진다.
|
||
"""
|
||
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)
|
||
# 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 — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
||
# ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다
|
||
# (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서,
|
||
# 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다.
|
||
# ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는
|
||
# refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다.
|
||
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
|
||
|
||
|
||
# ============================================================
|
||
# 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)
|
||
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
|
||
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
|
||
notify_email = Column(String(255), 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)
|
||
|
||
|
||
class place_songs(MainTableMixin, MAIN_BASE):
|
||
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||
|
||
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
|
||
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
|
||
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
|
||
|
||
__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):
|
||
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
|
||
|
||
- 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. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다.
|
||
|
||
LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다)
|
||
OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다
|
||
TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다.
|
||
★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다."""
|
||
|
||
__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 이 만든 여행 일정. **기간당 한 행**이고 `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 place_posts(MainTableMixin, MAIN_BASE):
|
||
"""미니 블로그 글 하나. 기획: docs/MINI_BLOG.md
|
||
|
||
★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
|
||
★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
|
||
★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
|
||
|
||
__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). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date).
|
||
scheduled_date = Column(Date, nullable=True)
|
||
# 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
|
||
# 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
|
||
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
|
||
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):
|
||
"""손님이 남긴 이용 후기.
|
||
|
||
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
|
||
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
|
||
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
|
||
|
||
__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):
|
||
"""발행 대상 사이트. 사업장당 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_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 가 쓰고 읽는다.
|
||
|
||
★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다.
|
||
장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다.
|
||
★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) —
|
||
같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다.
|
||
★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면
|
||
다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다."""
|
||
|
||
__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):
|
||
"""섹션 하나의 콘텐츠. **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) # 잡 출력(완료 시)
|
||
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.
|
||
|
||
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
|
||
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
|
||
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
|
||
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
|
||
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
|
||
|
||
__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'")),)
|