o2o-site-AEO/solution/backend/services/local_content_service.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

478 lines
22 KiB
Python

import re
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, place_area_refs, place_channels, places, site_sections
from common.enums import (
AREA_KIND, DBWRType, ErrorType, FactStatus, LocalContentStatus, LocalContentType,
LocalSource, PlaceCategory, SourceType,
)
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from crud.place_content_crud import PlaceContentCRUD
from crud.place_crud import PlaceCRUD
from crud.site_section_crud import SiteSectionCRUD
from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed
from services.external.naver import region_key
from services.llm import perplexity
from services import story_service
from router.v1.local.protocol import (
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData,
)
from services.external import tour_api
from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed
from services.place_category import guess_food_class
# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크.
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백.
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대.
_KST = timezone(timedelta(hours=9))
# 업장 반경(m).
_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude")
RESTAURANT_RADIUS_M = 5_000
ATTRACTION_RADIUS_M = 10_000
def _as_float(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
class LocalContentService:
def __init__(self):
self.crud = LocalContentCRUD()
self.place_crud = PlaceContentCRUD()
async def list(self, status=None, region_code=None):
res = ResLocalContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code)
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
# ── 업장 반경 주변정보 ───────────────────────────────────────────────
async def sync_place(self, place) -> ResSyncPlace:
"""업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_area_refs 를 맞춘다."""
res = ResSyncPlace()
place_id = getattr(place, "place_id", None)
if place_id is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
if lat is None or lng is None:
# 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색).
found = await self._geocode_and_store(place)
if found is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)."
return res
lat, lng = found
try:
async with tour_api.make_client() as client:
# 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다.
restaurants = await tour_api.fetch_nearby(
client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39")
# 업종별 제외: 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 — 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
own_class = await self._own_food_class(client, place)
if own_class:
before = len(restaurants)
restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class]
LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외")
attractions = await tour_api.fetch_nearby(
client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12")
sido_code = str(getattr(place, "region_code", None) or "")[:2] or None
if not sido_code:
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
derived = region_key(address)
sido_code = derived[:2] if derived else None
today = datetime.now(_KST).date()
if sido_code:
festivals = await tour_api.fetch_festivals_in_sido(
client, lat, lng, sido_code=sido_code, today=today)
else:
LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀")
festivals = []
except TourApiNotConfigured:
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
res.msg = "TOUR_API_KEY를 먼저 설정해주세요."
return res
except TourApiRequestFailed as ex:
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
res.msg = str(ex)
return res
kept: list[dict] = restaurants + attractions + festivals
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
existing = {(int(r.content_type), r.external_id): r for r in (rows or [])} # 조인 결과(area_contents + 거리)
now = datetime.now(timezone.utc)
# 공용 콘텐츠에 실어 둘 지역.
region_code = str(getattr(place, "region_code", None) or "").strip() or None
changed = 0
kept_ids: set = set()
personal: dict = {} # area_contents.local_content_id → 이 사이트만의 값(거리·숨김)
for body in kept:
key = (body["content_type"], body["contentid"])
prev = existing.get(key)
# 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다.
shared_body = {k: v for k, v in body.items() if k not in _BODY_DROP}
content_values = {
"source": LocalSource.TOUR_API.value,
"external_id": body["contentid"],
"content_type": body["content_type"],
"kind": AREA_KIND.get(body["content_type"]),
"title": body["name"],
"body": shared_body,
"latitude": _as_float(body.get("latitude")),
"longitude": _as_float(body.get("longitude")),
"region_code": region_code,
# has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다.
"status": LocalContentStatus.PUBLISHED.value,
# 축제도 종료일과 무관하게 노출한다 — display_end_at 을 두지 않는다.
"display_end_at": None,
"collected_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()],
[lambda s, v=content_values: self.place_crud.upsert_content(s, v)],
)
if write_err != ErrorType.SUCCESS:
continue
err_i, rows_i = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s, e=body["contentid"]: self.place_crud.find_content_id(s, LocalSource.TOUR_API.value, e),
)
if err_i != ErrorType.SUCCESS or not rows_i:
continue
content_id = rows_i[0] # 단일 컬럼 SELECT 는 scalars() 로 펴져 값이 곧 행이다
kept_ids.add(content_id)
# 원문이 그대로면 관계만 확인하고 넘어간다 — collected_at 만 밀리면 '갱신된 척'이 된다.
await DB_SESSION_MNG.execute_lambda_run(
[place_area_refs.DBType()],
[lambda s, cid=content_id, d=body["distance_m"]: self.place_crud.upsert_ref(s, place_id, cid, d)],
)
# 사이트 개인화(거리·숨김)는 아래에서 한 번에 쓴다 — 항목마다 UPDATE 하면 같은 행을 N 번 쓰게 된다(섹션당 한 행이다).
personal[str(content_id)] = {
"kind": AREA_KIND.get(body["content_type"]),
"distanceMeters": body["distance_m"],
"hidden": bool(getattr(prev, "hidden", False)),
}
if prev is None or prev.body != shared_body:
changed += 1
# TourAPI 가 만들지 않은 연결(예: NAVER_CRAWL — services/local_restaurant_enrichment.py)은 이 함수가 모르는 소스다.
for key, row in existing.items():
if int(getattr(row, "source", LocalSource.TOUR_API.value)) == LocalSource.TOUR_API.value:
continue
kept_ids.add(row.local_content_id)
personal.setdefault(str(row.local_content_id), {
"kind": AREA_KIND.get(int(row.content_type)),
"distanceMeters": row.distance_m,
"hidden": bool(row.hidden),
})
# 이번 응답에 없는 **관계**만 끊는다.
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids)
)
# 개인화는 사이트 쪽에 쓴다.
await self._write_site_places(place_id, personal)
counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value,
LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)}
for b in kept:
counts[b["content_type"]] += 1
res.festivals = counts[LocalContentType.FESTIVAL.value]
res.attractions = counts[LocalContentType.ATTRACTION.value]
res.restaurants = counts[LocalContentType.RESTAURANT.value]
res.courses = counts[LocalContentType.COURSE.value]
res.changed = changed > 0 or bool(removed)
LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})")
return res
async def _own_food_class(self, client, place) -> str | None:
"""음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05)."""
category = getattr(place, "category", None)
if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value):
return None
err, links = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(), DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True),
)
for link in (links or []) if err == ErrorType.SUCCESS else []:
m = _TOUR_LINK.match(str(getattr(link, "url", "") or ""))
if not m:
continue
try:
code = await tour_api.fetch_content_class(client, m.group(2))
except TourApiRequestFailed as ex:
LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}")
code = None
if code:
return code
break
guessed = guess_food_class(getattr(place, "external_category", None))
if guessed:
return guessed
return _DEFAULT_FOOD_CLASS.get(category)
async def _geocode_and_store(self, place) -> tuple[float, float] | None:
"""주소 → 좌표(카카오)."""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return None
client = KakaoLocalClient()
if not client.enabled:
LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다")
return None
try:
found = await client.geocode_address(address)
except (KakaoNotConfigured, KakaoRequestFailed) as ex:
LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}")
return None
finally:
await client.aclose()
if found is None:
return None
lat, lng = found
company_id = getattr(place, "company_id", None)
if company_id is not None:
data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))}
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}")
else:
place.latitude, place.longitude = data["latitude"], data["longitude"]
LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}")
return lat, lng
async def _load_place(self, place_id):
"""회사 스코프 없이 사업장 1건."""
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712
),
)
return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace:
place = await self._load_place(place_id)
if place is None:
res = ResSyncPlace()
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
return await self.sync_place(place)
async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList:
res = ResPlaceContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True),
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList:
res = ResPlaceContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide:
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스)."""
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local
from services.snapshot import _local_contents
res = ResLocalGuide()
place = await self._load_place(place_id)
if place is None:
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
# 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약).
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err == ErrorType.SUCCESS and not rows:
synced = await self.sync_place(place)
if not synced.result.success:
LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}")
# 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다.
await self._ensure_region_stories(place)
snapshot_local = await _local_contents(place)
local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude))
res.attractions = local.get("attractions") or []
res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or []
res.itineraries = local.get("itineraries") or []
res.synced_at = synced_at
return res
async def _write_site_places(self, place_id, places_map: dict) -> None:
"""주변 항목의 **사이트별 값**(거리·숨김)을 `site_sections` 한 행에 쓴다."""
from services.build_service import ensure_site
try:
site = await ensure_site(str(place_id))
except Exception as ex: # noqa: BLE001 — 발행 전 업장은 사이트가 없을 수 있다
LOG.i(f"[local] place={place_id} 사이트가 없어 개인화 저장을 건너뛴다: {ex}")
return
if site is None:
return
values = {
"site_id": site.site_id,
"section_id": "local",
"data": {"kind": "local", "places": places_map},
"source_type": SourceType.API.value,
"status": FactStatus.VERIFIED.value,
"sort_order": 0,
}
err = await DB_SESSION_MNG.execute_lambda_run(
[site_sections.DBType()], [lambda s: SiteSectionCRUD().upsert(s, values)],
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] place={place_id} 사이트 개인화 저장 실패: {err.name}")
async def _ensure_region_stories(self, place) -> None:
"""지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다."""
if not perplexity.is_configured():
return
region_code = str(getattr(place, "region_code", None) or "").strip()
place_id = getattr(place, "place_id", None)
# "한 건이라도 있으면 건너뛴다" 가 아니라 **없는 것이 있으면 돈다.** 종류가 늘어난 날 기존 지역이 옛 목록에 멈추는 것을 막는다(story_service.missing_kinds).
stories_done = bool(region_code) and not await story_service.missing_kinds(region_code)
if stories_done:
from services.itinerary_llm_service import missing_durations
if place_id is None or not await missing_durations(place_id):
return
await story_service.enqueue_region_job(place)
# ── 지역 캐시(area_contents) — 운영자 수기 항목·날씨 ────────────────
async def publish(self, ids, user_id):
res = ResLocalContentList()
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
area_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id)
)
res.result.SetResult(err)
return res
async def update(self, content_id, req):
res = ResLocalContentList()
data = req.model_dump(exclude_none=True)
start, end = data.get("display_start_at"), data.get("display_end_at")
if start and end and start >= end:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "노출 종료는 시작보다 뒤여야 합니다."
return res
err, count = await DB_SESSION_MNG.execute_lambda_claim(
area_contents.DBType(), lambda s: self.crud.update(s, content_id, data)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def end(self, content_id):
res = ResLocalContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
area_contents.DBType(), lambda s: self.crud.end(s, content_id)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def get_weather(self, region_code: str, latitude: float, longitude: float):
"""Cache-aside weather lookup; stale data survives upstream failures."""
res = ResWeather()
err, cached = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.get_weather(s, region_code),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
now = datetime.now(timezone.utc)
if cached and cached.expires_at and cached.expires_at > now:
res.weather = WeatherData.model_validate(cached.body)
res.cached = True
return res
try:
weather = await fetch_current_weather(latitude, longitude)
except OpenMeteoRequestFailed as ex:
if cached:
res.weather = WeatherData.model_validate(cached.body)
res.cached = True
res.stale = True
res.msg = "최신 날씨를 가져오지 못해 직전 정보를 반환합니다."
return res
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
res.msg = str(ex)
return res
expires_at = now + timedelta(hours=1)
values = {
"region_code": region_code,
"content_type": LocalContentType.WEATHER.value,
"source": LocalSource.OPEN_METEO.value,
"external_id": None,
"title": None,
"body": weather,
"status": LocalContentStatus.PUBLISHED.value,
"collected_at": now,
"expires_at": expires_at,
"updated_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)]
)
if write_err != ErrorType.SUCCESS:
res.result.SetResult(write_err)
return res
res.weather = WeatherData.model_validate(weather)
return res