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 _festival_display_end(body: dict) -> datetime | None: """eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC. ★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 — 스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다.""" raw = str(body.get("eventenddate") or "").strip() if len(raw) != 8 or not raw.isdigit(): return None try: end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST) except ValueError: return None return (end_day + timedelta(days=1)).astimezone(timezone.utc) 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, "display_end_at": ( _festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else 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 # 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다. _, 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