가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.
**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
(같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.
**자리 가르기**
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
**첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.
검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
6.8 KiB
Python
143 lines
6.8 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 upsert_kind(self, db, values: dict):
|
||
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.
|
||
|
||
★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다.
|
||
이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라
|
||
`body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기
|
||
위해서다. 다시 생성하면 그 한 행을 덮어쓴다.
|
||
★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도
|
||
걸려, 종류가 다른 두 행이 같은 키로 충돌한다."""
|
||
stmt = pg_insert(area_contents).values(**values)
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=[area_contents.region_code, area_contents.kind],
|
||
index_where=and_(area_contents.deleted == False, area_contents.kind.isnot(None)), # noqa: E712
|
||
set_={
|
||
"title": stmt.excluded.title,
|
||
"body": stmt.excluded.body,
|
||
"content_type": stmt.excluded.content_type,
|
||
"source": stmt.excluded.source,
|
||
"status": stmt.excluded.status,
|
||
"collected_at": stmt.excluded.collected_at,
|
||
"published_at": stmt.excluded.published_at,
|
||
"updated_at": GTime.UTC(),
|
||
},
|
||
)
|
||
return await DB_SESSION_MNG.add(db, stmt)
|
||
|
||
async def list_kinds(self, db, region_code: str):
|
||
"""지역의 이야기 행 전부(종류당 1행). cache-aside 판단에 쓴다."""
|
||
return await DB_SESSION_MNG.execute(
|
||
db,
|
||
select(area_contents).where(
|
||
area_contents.region_code == region_code,
|
||
area_contents.kind.isnot(None),
|
||
area_contents.deleted == False, # noqa: E712
|
||
),
|
||
)
|
||
|
||
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)
|