공식채널 단일화, 한일옥 거리 반영 날씨 조건을 7종으로 세분화, 축제 종료 여부와 무관하게 상시 노출, '지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
549 lines
30 KiB
Python
549 lines
30 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 링크. (contentTypeId, contentId)
|
|
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
|
|
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다.
|
|
# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다).
|
|
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
|
|
|
|
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다.
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md
|
|
# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다.
|
|
# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다.
|
|
# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다.
|
|
# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido).
|
|
# 공용 실체(area_contents.body)에 넣지 않는 키. 컬럼이나 사이트 쪽에 이미 자리가 있는 것들이다.
|
|
_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 를 맞춘다.
|
|
종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08).
|
|
여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측).
|
|
|
|
빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다.
|
|
★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석).
|
|
★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다.
|
|
hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된
|
|
여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로).
|
|
★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다.
|
|
"""
|
|
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:
|
|
# ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가
|
|
# 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다.
|
|
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")
|
|
# ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 —
|
|
# 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
|
|
# 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다.
|
|
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")
|
|
|
|
# ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) —
|
|
# 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고,
|
|
# 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다.
|
|
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)
|
|
# 공용 콘텐츠에 실어 둘 지역. 유일성의 근거는 external_id 이고 이건 조회 편의다 —
|
|
# 없으면 NULL 로 둔다(지어내지 않는다).
|
|
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)
|
|
# ★ 실체는 전국 공용이다 — 다른 업장이 이미 넣어 뒀으면 그 행을 그대로 쓴다.
|
|
# (source, external_id) 로 upsert 하고 돌려받은 id 로 관계만 잇는다.
|
|
# ★ 공용 실체에는 **렌더러가 읽는 것만** 담는다(2026-09-09). 거리는 사이트마다 다르고,
|
|
# 좌표·외부 id 는 컬럼이 이미 그 자리다 — body 에 또 두면 한쪽만 갱신되는 날이 온다.
|
|
# `lclsSystm2` 만 예외로 남긴다: 렌더러는 안 쓰지만 주변 맛집에서 같은 업태를 뺄 때 쓴다.
|
|
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,
|
|
# ★ 축제도 종료일과 무관하게 노출한다(2026-09-17 결정) — 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)은
|
|
# 이 함수가 모르는 소스다. "이번 응답에 없으니 끊는다"를 그대로 적용하면 다른 파이프라인이
|
|
# 붙여 둔 것까지 매 재수집마다 지웠다가 그쪽이 다시 채우는 낭비가 생긴다(2026-09-14 실측:
|
|
# 네이버로 크롤링해 둔 맛집이 다음 TourAPI 재수집 때마다 끊겼다 붙었다 했다).
|
|
# 그래서 TourAPI 소스가 아닌 기존 연결은 항상 kept_ids/personal 에 그대로 얹어 보존한다.
|
|
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). 숙박·병원은 None(제외할 게 없다).
|
|
|
|
우선순위 — 정확한 쪽부터:
|
|
1. 업장이 TourAPI 에 등록돼 있으면(place_channels 의 tour:// 링크) 그 콘텐츠의 lclsSystm2.
|
|
주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다.
|
|
2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑.
|
|
3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다).
|
|
실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다.
|
|
"""
|
|
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:
|
|
"""주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 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건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다."""
|
|
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:
|
|
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).
|
|
|
|
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
|
|
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
|
|
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
|
|
★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때
|
|
이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다.
|
|
"""
|
|
# 순환 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 — 날씨와 같은 규약).
|
|
# 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측).
|
|
# 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다.
|
|
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}")
|
|
|
|
# ★ 지역 이야기(가요·인물·연표·엽서·퀴즈)도 같은 규약으로 채운다 — 다만 **잡으로** 돌린다.
|
|
# 위 TourAPI 는 수 초면 끝나지만 이건 검색을 동반한 LLM 호출 다섯이라 분 단위다.
|
|
# 에디터를 여는 요청을 그만큼 붙잡아 두면 사장님에게는 화면이 멈춘 것으로 보인다.
|
|
# 이번 응답에는 안 실리고, 다음에 열 때(또는 발행 빌드 때) 들어온다.
|
|
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` 한 행에 쓴다.
|
|
|
|
★ 배열이 아니라 ref → 값 **맵**이다. 화면에 순서대로 서는 항목(songs·people…)이 아니라
|
|
"공용 항목 하나에 이 사이트가 덧붙인 값" 조회표라, 읽을 때마다 훑을 이유가 없다.
|
|
정렬 기준(가까운 순·사진 있는 것 먼저)은 읽는 쪽이 갖는다.
|
|
★ 사이트가 없으면 만들지 않고 건너뛴다 — 사이트를 세우는 건 발행 쪽 결정이다
|
|
(`build_service.ensure_site`). 다음 수집이 사이트가 생긴 뒤 다시 쓴다.
|
|
"""
|
|
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:
|
|
"""지역 데이터·일정이 비어 있으면 잡을 하나 넣는다 — **보험 경로**다.
|
|
|
|
★ 정규 경로는 위저드다: 수집이 끝날 때(collect_service)와 생성 단계(place_service)가
|
|
같은 잡을 걸고, 사장님은 **에디터에 들어가기 전에** 다 채워진 화면을 본다.
|
|
여기는 그 경로를 안 거친 업장(옛 데이터·수집을 건너뛴 경우)을 위한 자리다.
|
|
★ 잡으로 돌린다. 이 함수는 에디터가 화면을 그리려고 부른 요청 안에 있어서,
|
|
여기서 1분을 붙잡으면 사장님에게는 화면이 멈춘 것으로 보인다.
|
|
"""
|
|
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).
|
|
# ★ 일정도 같은 잡이 채운다(2026-09-11). 이야기만 보면, 이야기가 이미 다 찬 업장은
|
|
# 일정을 영영 못 받는다 — daily 가 안 들어가던 것과 똑같은 함정이다.
|
|
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
|
|
|