도메인별 스키마(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>
134 lines
6.3 KiB
Python
134 lines
6.3 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, place_area_refs
|
|
from common.utils.gtime import GTime
|
|
|
|
|
|
class PlaceContentCRUD:
|
|
"""업장 주변의 지역 콘텐츠.
|
|
|
|
★ 실체와 관계가 갈려 있다 (2026-09-09).
|
|
예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI
|
|
응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다.
|
|
지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고
|
|
`place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다.
|
|
그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다.
|
|
"""
|
|
|
|
async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
|
|
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다."""
|
|
conds = [
|
|
place_area_refs.place_id == place_id,
|
|
place_area_refs.deleted == False, # noqa: E712
|
|
area_contents.deleted == False, # noqa: E712
|
|
]
|
|
if not include_hidden:
|
|
conds.append(place_area_refs.hidden == False) # noqa: E712
|
|
return await DB_SESSION_MNG.execute(
|
|
db,
|
|
select(
|
|
area_contents.local_content_id,
|
|
area_contents.content_type,
|
|
area_contents.external_id,
|
|
area_contents.title,
|
|
area_contents.body,
|
|
area_contents.latitude,
|
|
area_contents.longitude,
|
|
area_contents.display_end_at,
|
|
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
|
|
place_area_refs.distance_m.label("distance_m"),
|
|
place_area_refs.hidden.label("hidden"),
|
|
)
|
|
.join(area_contents, area_contents.local_content_id == place_area_refs.local_content_id)
|
|
.where(and_(*conds))
|
|
.order_by(
|
|
area_contents.content_type.asc(),
|
|
place_area_refs.distance_m.asc(),
|
|
),
|
|
)
|
|
|
|
async def upsert_content(self, db, values: dict):
|
|
"""공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다.
|
|
|
|
★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고
|
|
("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다.
|
|
"""
|
|
stmt = pg_insert(area_contents).values(**values)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[area_contents.source, area_contents.external_id],
|
|
index_where=and_(
|
|
area_contents.deleted == False, # noqa: E712
|
|
area_contents.external_id.isnot(None),
|
|
),
|
|
set_={
|
|
"title": stmt.excluded.title,
|
|
"body": stmt.excluded.body,
|
|
"latitude": stmt.excluded.latitude,
|
|
"longitude": stmt.excluded.longitude,
|
|
"region_code": stmt.excluded.region_code,
|
|
"display_end_at": stmt.excluded.display_end_at,
|
|
"collected_at": stmt.excluded.collected_at,
|
|
"updated_at": GTime.UTC(),
|
|
},
|
|
)
|
|
return await DB_SESSION_MNG.add(db, stmt)
|
|
|
|
async def find_content_id(self, db, source: int, external_id: str):
|
|
"""방금 upsert 한 공용 콘텐츠의 id."""
|
|
return await DB_SESSION_MNG.execute(
|
|
db,
|
|
select(area_contents.local_content_id).where(
|
|
area_contents.source == source,
|
|
area_contents.external_id == external_id,
|
|
area_contents.deleted == False, # noqa: E712
|
|
).limit(1),
|
|
)
|
|
|
|
async def upsert_ref(self, db, place_id, local_content_id, distance_m):
|
|
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다."""
|
|
stmt = pg_insert(place_area_refs).values(
|
|
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
|
|
)
|
|
stmt = stmt.on_conflict_do_update(
|
|
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
|
|
set_={"distance_m": stmt.excluded.distance_m, "deleted": False, "updated_at": GTime.UTC()},
|
|
)
|
|
return await DB_SESSION_MNG.add(db, stmt)
|
|
|
|
async def soft_delete_missing(self, db, place_id, keep_ids: set):
|
|
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 —
|
|
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
|
|
err, rows = await DB_SESSION_MNG.execute(
|
|
db,
|
|
select(place_area_refs.local_content_id).where(
|
|
place_area_refs.place_id == place_id,
|
|
place_area_refs.deleted == False, # noqa: E712
|
|
),
|
|
)
|
|
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
|
|
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
|
|
gone = [r for r in (rows or []) if r not in keep_ids]
|
|
if not gone:
|
|
return err, 0
|
|
return await DB_SESSION_MNG.add_with_rowcount(
|
|
db,
|
|
update(place_area_refs)
|
|
.where(place_area_refs.place_id == place_id, place_area_refs.local_content_id.in_(gone))
|
|
.values(deleted=True, updated_at=GTime.UTC()),
|
|
)
|
|
|
|
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
|
|
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다."""
|
|
return await DB_SESSION_MNG.add_with_rowcount(
|
|
db,
|
|
update(place_area_refs)
|
|
.where(
|
|
place_area_refs.place_id == place_id,
|
|
place_area_refs.local_content_id == local_content_id,
|
|
place_area_refs.deleted == False, # noqa: E712
|
|
)
|
|
.values(hidden=hidden, updated_at=GTime.UTC()),
|
|
)
|