최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
508 lines
28 KiB
Python
508 lines
28 KiB
Python
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 (
|
||
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)
|
||
id = Column(String(20), nullable=False, unique=True, index=True) # 로그인 아이디
|
||
password = Column(String(255), nullable=False) # 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)
|
||
|
||
|
||
# ============================================================
|
||
# 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회)
|
||
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 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)
|
||
|
||
|
||
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)
|