도메인별 스키마(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>
106 lines
4.9 KiB
Python
106 lines
4.9 KiB
Python
from sqlalchemy import and_, select, update
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
|
||
from common.database.db_session_manager import DB_SESSION_MNG
|
||
from common.database.model.models import area_contents
|
||
from common.enums import ErrorType, LocalContentType
|
||
from common.utils.gtime import GTime
|
||
|
||
|
||
class LocalContentCRUD:
|
||
async def list(self, db, status: int | None = None, region_code: str | None = None):
|
||
"""축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은
|
||
관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨)."""
|
||
conds = [area_contents.deleted == False] # noqa: E712
|
||
if status is not None:
|
||
conds.append(area_contents.status == status)
|
||
if region_code:
|
||
conds.append(area_contents.region_code == region_code)
|
||
return await DB_SESSION_MNG.execute(
|
||
db, select(area_contents).where(and_(*conds)).order_by(area_contents.collected_at.desc())
|
||
)
|
||
|
||
async def insert(self, db, row):
|
||
return await DB_SESSION_MNG.insert(db, row)
|
||
|
||
async def publish(self, db, ids: list, user_id):
|
||
return await DB_SESSION_MNG.add_with_rowcount(
|
||
db,
|
||
update(area_contents).where(
|
||
area_contents.local_content_id.in_(ids), area_contents.deleted == False # noqa: E712
|
||
).values(status=2, published_at=GTime.UTC(), published_by=user_id, updated_at=GTime.UTC()),
|
||
)
|
||
|
||
async def update(self, db, content_id, data: dict):
|
||
return await DB_SESSION_MNG.add_with_rowcount(
|
||
db,
|
||
update(area_contents).where(
|
||
area_contents.local_content_id == content_id, area_contents.deleted == False # noqa: E712
|
||
).values(**data, updated_at=GTime.UTC()),
|
||
)
|
||
|
||
async def end(self, db, content_id):
|
||
return await self.update(db, content_id, {"status": 3})
|
||
|
||
async def list_keyed(self, db, region_code: str, content_type: int):
|
||
"""지역 × 종류의 외부 ID 있는 행 전부(축제·관광지·맛집). 동기화가 기존값과 비교할 때 쓴다."""
|
||
return await DB_SESSION_MNG.execute(
|
||
db,
|
||
select(area_contents).where(
|
||
area_contents.region_code == region_code,
|
||
area_contents.content_type == content_type,
|
||
area_contents.external_id.isnot(None),
|
||
area_contents.deleted == False, # noqa: E712
|
||
),
|
||
)
|
||
|
||
async def upsert_keyed(self, db, values: dict):
|
||
"""외부 ID 로 식별되는 행(축제·관광지·맛집)의 삽입/갱신.
|
||
|
||
★ uq_local_contents_keyed 부분 유니크 인덱스에 태운다 — 같은 지역을 두 번 동기화해도
|
||
중복 행이 생기지 않고 기존 값만 갱신된다."""
|
||
stmt = pg_insert(area_contents).values(**values)
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=[area_contents.region_code, area_contents.content_type, area_contents.external_id],
|
||
index_where=and_(area_contents.deleted == False, area_contents.external_id.isnot(None)), # noqa: E712
|
||
set_={
|
||
"title": stmt.excluded.title,
|
||
"body": stmt.excluded.body,
|
||
"source": stmt.excluded.source,
|
||
"status": stmt.excluded.status,
|
||
"collected_at": stmt.excluded.collected_at,
|
||
"display_end_at": stmt.excluded.display_end_at,
|
||
"published_at": stmt.excluded.published_at,
|
||
"updated_at": GTime.UTC(),
|
||
},
|
||
)
|
||
return await DB_SESSION_MNG.add(db, stmt)
|
||
|
||
async def get_weather(self, db, region_code: str):
|
||
err, rows = await DB_SESSION_MNG.execute(
|
||
db,
|
||
select(area_contents).where(
|
||
area_contents.region_code == region_code,
|
||
area_contents.content_type == LocalContentType.WEATHER.value,
|
||
area_contents.external_id.is_(None),
|
||
area_contents.deleted == False, # noqa: E712
|
||
).limit(1),
|
||
)
|
||
return err, rows[0] if rows else None
|
||
|
||
async def upsert_weather(self, db, values: dict):
|
||
stmt = pg_insert(area_contents).values(**values)
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=[area_contents.region_code, area_contents.content_type],
|
||
index_where=and_(area_contents.deleted == False, area_contents.external_id.is_(None)), # noqa: E712
|
||
set_={
|
||
"source": stmt.excluded.source,
|
||
"body": stmt.excluded.body,
|
||
"status": stmt.excluded.status,
|
||
"collected_at": stmt.excluded.collected_at,
|
||
"expires_at": stmt.excluded.expires_at,
|
||
"updated_at": stmt.excluded.updated_at,
|
||
},
|
||
)
|
||
return await DB_SESSION_MNG.add(db, stmt)
|