o2o-site-AEO/solution/backend/router/v1/local/protocol.py
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

103 lines
2.9 KiB
Python

import uuid
from datetime import datetime
from typing import Any
from pydantic import ConfigDict, Field
from common.enums import LocalContentStatus, LocalContentType, LocalSource
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class LocalContentData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
local_content_id: uuid.UUID
region_code: str
content_type: LocalContentType
source: LocalSource
external_id: str | None = None
title: str | None = None
body: dict[str, Any]
status: LocalContentStatus
collected_at: datetime | None = None
published_at: datetime | None = None
display_start_at: datetime | None = None
display_end_at: datetime | None = None
class PlaceContentData(WebPacketProtocol):
"""업장 반경 주변정보 1건(admin 목록용)."""
model_config = ConfigDict(from_attributes=True)
place_content_id: uuid.UUID
place_id: uuid.UUID
content_type: LocalContentType
external_id: str
title: str
body: dict[str, Any]
distance_m: int
has_image: bool
hidden: bool
display_end_at: datetime | None = None
collected_at: datetime | None = None
class ResPlaceContentList(Res_WebPacketProtocol):
contents: list[PlaceContentData] = []
class ReqHidePlaceContent(WebPacketProtocol):
hidden: bool
class ReqPublishLocalContent(WebPacketProtocol):
content_ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
class ReqUpdateLocalContent(WebPacketProtocol):
title: str | None = Field(default=None, min_length=1, max_length=300)
body: dict[str, Any] | None = None
display_start_at: datetime | None = None
display_end_at: datetime | None = None
class ResLocalContentList(Res_WebPacketProtocol):
contents: list[LocalContentData] = []
class ResSyncPlace(Res_WebPacketProtocol):
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지."""
festivals: int = 0
attractions: int = 0
restaurants: int = 0
courses: int = 0
changed: bool = False
class ResLocalGuide(Res_WebPacketProtocol):
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
attractions: list[dict[str, Any]] = []
restaurants: list[dict[str, Any]] = []
festivals: list[dict[str, Any]] = []
courses: list[dict[str, Any]] = []
synced_at: str | None = None
class WeatherData(WebPacketProtocol):
temperature: float
weather_code: int
wind_speed: float | None = None
observed_at: str
timezone: str | None = None
latitude: float
longitude: float
class ResWeather(Res_WebPacketProtocol):
weather: WeatherData | None = None
cached: bool = False
stale: bool = False