diff --git a/admin/frontend/src/pages/LocalContentPage.tsx b/admin/frontend/src/pages/LocalContentPage.tsx index d0f5965..8f53c55 100644 --- a/admin/frontend/src/pages/LocalContentPage.tsx +++ b/admin/frontend/src/pages/LocalContentPage.tsx @@ -9,9 +9,13 @@ import {customFetch} from '@/api/mutator/custom-fetch'; import {toast} from 'sonner'; type Status = 1 | 2 | 3; +// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로 +// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather). +type ContentType = 2 | 3 | 4; type LocalContent = { id: string; + contentType: ContentType; title: string; region: string; period: string; @@ -24,10 +28,15 @@ type LocalContent = { }; const STATUS_LABEL: Record = {1: '검수 대기', 2: '발행됨', 3: '종료됨'}; +const TYPE_LABEL: Record = {2: '축제', 3: '관광지', 4: '맛집'}; +const TYPE_BADGE_VARIANT: Record = { + 2: 'accent', 3: 'default', 4: 'outline', +}; type ApiContent = { local_content_id: string; region_code: string; + content_type: ContentType; external_id?: string; title?: string; body: Record; @@ -43,14 +52,17 @@ function formatDate(value: unknown) { } function toContent(item: ApiContent): LocalContent { + // 기간은 축제만 있다(eventstartdate/eventenddate) — 관광지·맛집은 상시 정보라 비어 있는 게 정상이다. const start = formatDate(item.body.eventstartdate); const end = formatDate(item.body.eventenddate); + const period = [start, end].filter(Boolean).join(' ~ ') || (item.content_type === 2 ? '기간 미정' : '상시'); return { id: item.local_content_id, + contentType: item.content_type, title: item.title || '제목 없음', region: String(item.body.addr1 || item.region_code), - period: [start, end].filter(Boolean).join(' ~ ') || '기간 미정', - source: `공공데이터포털 전국문화축제표준데이터 · ${item.external_id ?? '-'}`, + period, + source: `한국관광공사 TourAPI · ${item.external_id ?? '-'}`, status: item.status, selected: false, displayStart: item.display_start_at, @@ -95,15 +107,24 @@ export function LocalContentPage() { } catch { toast.error('발행하지 못했습니다.'); } }; const sync = async () => { - const regionCode = window.prompt('내부 지역 코드(예: gunsan)를 입력하세요.', 'gunsan')?.trim(); - if (!regionCode) return; + // ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. + // 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다. + const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim(); + if (!placeId) return; setSyncing(true); try { - const res = await customFetch<{result?: {success?: boolean}; msg?: string; collected?: number; skipped?: number}>({ - url: '/v1/admin/local-content/sync-festivals', method: 'POST', data: {region_code: regionCode}, - }); + const res = await customFetch<{ + result?: {success?: boolean}; msg?: string; + festivals?: number; attractions?: number; restaurants?: number; changed?: boolean; + }>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'}); if (res.result?.success === false) throw new Error(res.msg); - toast.success(`${res.collected ?? 0}건 수집 · ${res.skipped ?? 0}건 중복 제외`); + // ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다. + const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`; + if (!res.changed) { + toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`); + } else { + toast.success(`${summary} 반영 — 다음 빌드부터 발행본에 실립니다.`); + } await load(); } catch (error) { toast.error(error instanceof Error ? error.message : '공공데이터 수집에 실패했습니다.'); @@ -138,7 +159,7 @@ export function LocalContentPage() { return ( {syncing ? '수집 중…' : '공공데이터 수집'}} >
@@ -174,6 +195,7 @@ export function LocalContentPage() {

{item.title}

+ {TYPE_LABEL[item.contentType]} {STATUS_LABEL[item.status]}
diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 98b42e5..093edf2 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS place.places ( latitude NUMERIC(10,7) NULL, -- 위도 longitude NUMERIC(10,7) NULL, -- 경도 region_code VARCHAR(10) NULL, -- 카카오 행정구역 코드 — ★ 지역정보 캐시 키 + external_category VARCHAR(200) NULL, -- 외부 장소 DB 분류 원문("음식점 > 한식 > 육류" / "펜션") — 주변 맛집 경쟁업소 제외 기준(폴백) verified_at TIMESTAMPTZ NULL, -- ★ 동일 업소 검증 통과 시각. NULL = 수집·발행 금지 verified_by uuid NULL, -- 검증자(company.users.user_id) content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용 @@ -228,6 +229,25 @@ CREATE TABLE IF NOT EXISTS local.local_contents ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- 업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id — local_contents(행정구역 캐시)와 다르다. +-- 빌드 때마다 TourAPI locationBasedList2 로 갱신. 응답에서 사라진 행은 소프트 삭제, hidden 은 유지. +CREATE TABLE IF NOT EXISTS local.place_contents ( + place_content_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 주변 정보 식별자(PK) + place_id uuid NOT NULL, -- 사업장(place.places.place_id) + content_type SMALLINT NOT NULL, -- 종류(LocalContentType): 2=축제 3=관광지 4=맛집 5=여행코스 + external_id VARCHAR(100) NOT NULL, -- TourAPI contentid + title VARCHAR(300) NOT NULL, + body JSONB NOT NULL, -- 정규화한 TourAPI 항목(좌표·주소·사진·기간) + distance_m INTEGER NOT NULL, -- 업장 좌표에서의 거리(m). 정렬 기준 + has_image BOOLEAN NOT NULL DEFAULT FALSE, -- 상업 이용 가능한 대표사진 유무 + display_end_at TIMESTAMPTZ NULL, -- 축제 종료. 지나면 스냅샷이 거른다 + hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 숨김(재수집이 덮어쓰지 않음) + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS local.routes ( route_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 경로 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) @@ -400,6 +420,8 @@ CREATE INDEX IF NOT EXISTS idx_facts_publishable ON fact.facts (place_id, status -- local CREATE INDEX IF NOT EXISTS idx_local_contents_region ON local.local_contents (region_code); CREATE INDEX IF NOT EXISTS idx_routes_place ON local.routes (place_id); +CREATE INDEX IF NOT EXISTS idx_place_contents_place ON local.place_contents (place_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_place_contents_keyed ON local.place_contents (place_id, content_type, external_id) WHERE deleted = false; CREATE INDEX IF NOT EXISTS idx_nearby_links_place ON local.nearby_links (place_id); -- 지역 캐시 중복 방지. external_id 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다. diff --git a/solution/backend/common/category_schema/resources/lodging.json b/solution/backend/common/category_schema/resources/lodging.json index 61991af..30a5895 100644 --- a/solution/backend/common/category_schema/resources/lodging.json +++ b/solution/backend/common/category_schema/resources/lodging.json @@ -19,6 +19,10 @@ { "key": "breakfast", "label": "조식 제공", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "baby_amenities", "label": "유아용품 비치", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "pickup_service", "label": "픽업 서비스", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false }, + { "key": "facilities", "label": "부대시설", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false }, + { "key": "total_rooms", "label": "객실 수", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "실" }, + { "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" }, + { "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false }, { "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true }, { "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false }, @@ -29,6 +33,15 @@ { "key": "bathroom_count", "label": "욕실 수", "type": "number", "scope": "unit", "required": false, "critical": false, "allow_llm": false, "unit": "개" }, { "key": "has_kitchen", "label": "주방 여부", "type": "bool", "scope": "unit", "required": false, "critical": true, "allow_llm": false }, { "key": "has_aircon", "label": "에어컨", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_bathroom", "label": "욕실", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_tv", "label": "TV", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_internet", "label": "인터넷", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_refrigerator", "label": "냉장고", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_hairdryer", "label": "드라이기", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_toiletries", "label": "세면도구", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_table", "label": "테이블", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_sofa", "label": "소파", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, + { "key": "has_home_theater", "label": "홈시어터", "type": "bool", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, { "key": "view", "label": "전망", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false }, { "key": "weekday_price", "label": "주중 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" }, { "key": "weekend_price", "label": "주말 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" }, diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 00f69e4..a97eb1f 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -119,6 +119,10 @@ class places(MainTableMixin, MAIN_BASE): latitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True) region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회) + # 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다. + # ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고, + # 이 값은 그 폴백이다(services/local_content_service._own_food_class). + external_category = Column(String(200), nullable=True) verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지 verified_by = Column(UUID(as_uuid=True), nullable=True) # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — @@ -335,6 +339,42 @@ class local_contents(MainTableMixin, MAIN_BASE): expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지) +class place_contents(MainTableMixin, MAIN_BASE): + """업장 반경의 주변 정보(맛집·관광지·축제·여행코스). ★ 키는 place_id 다. + + local_contents 는 행정구역(region_code) 단위 캐시라 '그 시군구에 있는 것'을 줄 뿐 + '이 업장에서 가까운 것'을 못 준다(2026-09-04 결정, specs/2026-09-04-tourapi-radius-spike.md). + 그래서 업장 좌표로 TourAPI locationBasedList2 를 직접 물어 여기 담는다. + + ★ 빌드 때마다 갱신된다(services/local_content_service.sync_place). 응답에서 사라진 행은 + 소프트 삭제한다 — 업장 전용 데이터라 반경 밖으로 밀린 것을 남길 이유가 없다. + 단 hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다. + ★ 외부 API 실패 시 행을 지우거나 비우지 않는다 — 직전 값을 그대로 쓴다.""" + + __tablename__ = "place_contents" + __table_args__ = ( + Index( + "uq_place_contents_keyed", + "place_id", "content_type", "external_id", + unique=True, + postgresql_where=text("deleted = false"), + ), + {"schema": "local"}, + ) + + place_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + place_id = Column(UUID(as_uuid=True), nullable=False, index=True) + content_type = Column(SmallInteger, nullable=False) # LocalContentType 2~5 + external_id = Column(String(100), nullable=False) # TourAPI contentid + title = Column(String(300), nullable=False) + body = Column(JSONB, nullable=False) # 정규화한 TourAPI 항목(좌표·주소·사진·기간) + distance_m = Column(Integer, nullable=False) # 업장 좌표에서의 거리(m). 정렬 기준 + has_image = Column(Boolean, nullable=False, server_default=text("false"), default=False) # 상업 이용 가능한 대표사진이 있는가 + display_end_at = Column(DateTime(timezone=True), nullable=True) # 축제 종료(KST 자정). 지나면 스냅샷이 거른다 + hidden = Column(Boolean, nullable=False, server_default=text("false"), default=False) # 운영자 숨김 + collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) + + class routes(MainTableMixin, MAIN_BASE): """가는 길. 검증 상태(FactStatus)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다.""" diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 0c96a2d..02dd227 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -286,10 +286,12 @@ VISION_AUTO_APPROVE_CONFIDENCE = 0.7 class LocalContentType(CodeEnum): """local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류.""" - WEATHER = 1 # 날씨 (Open-Meteo) - FESTIVAL = 2 # 축제 (TourAPI 행사정보, 주 1회) - ATTRACTION = 3 # 관광지 (TourAPI 지역기반, 월 1회) - RESTAURANT = 4 # 주변 맛집 (카카오 카테고리 검색) + WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시) + # ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25 + FESTIVAL = 2 # 축제/공연/행사 (15) + ATTRACTION = 3 # 관광지 (12) + RESTAURANT = 4 # 음식점 (39) + COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 class LocalSource(CodeEnum): diff --git a/solution/backend/common/utils/geo.py b/solution/backend/common/utils/geo.py new file mode 100644 index 0000000..94630c4 --- /dev/null +++ b/solution/backend/common/utils/geo.py @@ -0,0 +1,24 @@ +"""좌표 거리 — 공용 한 벌. + +★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터) + 세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의 + 거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다. + +국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정 +(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다. +""" +import math + +EARTH_RADIUS_M = 6_371_000.0 + + +def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다.""" + p1, p2 = math.radians(lat1), math.radians(lat2) + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a)) + + +def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + return haversine_m(lat1, lng1, lat2, lng2) / 1000.0 diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 5d88970..967a0d8 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -9,7 +9,9 @@ from common.utils.gtime import GTime class LocalContentCRUD: async def list(self, db, status: int | None = None, region_code: str | None = None): - conds = [local_contents.deleted == False, local_contents.content_type == LocalContentType.FESTIVAL.value] # noqa: E712 + """축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은 + 관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨).""" + conds = [local_contents.deleted == False] # noqa: E712 if status is not None: conds.append(local_contents.status == status) if region_code: @@ -21,18 +23,6 @@ class LocalContentCRUD: async def insert(self, db, row): return await DB_SESSION_MNG.insert(db, row) - async def get_by_external_id(self, db, region_code: str, external_id: str): - err, rows = await DB_SESSION_MNG.execute( - db, - select(local_contents).where( - local_contents.region_code == region_code, - local_contents.content_type == LocalContentType.FESTIVAL.value, - local_contents.external_id == external_id, - local_contents.deleted == False, # noqa: E712 - ).limit(1), - ) - return err, rows[0] if rows else None - async def publish(self, db, ids: list, user_id): return await DB_SESSION_MNG.add_with_rowcount( db, @@ -52,6 +42,40 @@ class LocalContentCRUD: 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(local_contents).where( + local_contents.region_code == region_code, + local_contents.content_type == content_type, + local_contents.external_id.isnot(None), + local_contents.deleted == False, # noqa: E712 + ), + ) + + async def upsert_keyed(self, db, values: dict): + """외부 ID 로 식별되는 행(축제·관광지·맛집)의 삽입/갱신. + + ★ uq_local_contents_keyed 부분 유니크 인덱스에 태운다 — 같은 지역을 두 번 동기화해도 + 중복 행이 생기지 않고 기존 값만 갱신된다.""" + stmt = pg_insert(local_contents).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=[local_contents.region_code, local_contents.content_type, local_contents.external_id], + index_where=and_(local_contents.deleted == False, local_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 get_weather(self, db, region_code: str): err, rows = await DB_SESSION_MNG.execute( db, diff --git a/solution/backend/crud/place_content_crud.py b/solution/backend/crud/place_content_crud.py new file mode 100644 index 0000000..aae0148 --- /dev/null +++ b/solution/backend/crud/place_content_crud.py @@ -0,0 +1,63 @@ +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 place_contents +from common.utils.gtime import GTime + + +class PlaceContentCRUD: + """업장 반경 주변정보(place_contents). 키는 place_id 다.""" + + async def list_by_place(self, db, place_id, *, include_hidden: bool = True): + conds = [place_contents.place_id == place_id, place_contents.deleted == False] # noqa: E712 + if not include_hidden: + conds.append(place_contents.hidden == False) # noqa: E712 + return await DB_SESSION_MNG.execute( + db, + select(place_contents).where(and_(*conds)) + .order_by(place_contents.content_type.asc(), place_contents.has_image.desc(), place_contents.distance_m.asc()), + ) + + async def upsert(self, db, values: dict): + """(place_id, content_type, external_id) 로 삽입/갱신. ★ hidden 은 건드리지 않는다 — + 운영자가 숨긴 것을 재수집이 되살리면 안 된다.""" + stmt = pg_insert(place_contents).values(**values) + stmt = stmt.on_conflict_do_update( + index_elements=[place_contents.place_id, place_contents.content_type, place_contents.external_id], + index_where=(place_contents.deleted == False), # noqa: E712 + set_={ + "title": stmt.excluded.title, + "body": stmt.excluded.body, + "distance_m": stmt.excluded.distance_m, + "has_image": stmt.excluded.has_image, + "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 soft_delete_missing(self, db, place_id, keep: set[tuple[int, str]]): + """이번 응답에 없는 행을 소프트 삭제. keep = {(content_type, external_id)}.""" + err, rows = await DB_SESSION_MNG.execute( + db, + select(place_contents.place_content_id, place_contents.content_type, place_contents.external_id) + .where(place_contents.place_id == place_id, place_contents.deleted == False), # noqa: E712 + ) + gone = [r.place_content_id for r in (rows or []) if (int(r.content_type), r.external_id) not in keep] + if not gone: + return err, 0 + return await DB_SESSION_MNG.add_with_rowcount( + db, + update(place_contents).where(place_contents.place_content_id.in_(gone)) + .values(deleted=True, updated_at=GTime.UTC()), + ) + + async def set_hidden(self, db, place_content_id, hidden: bool): + return await DB_SESSION_MNG.add_with_rowcount( + db, + update(place_contents).where( + place_contents.place_content_id == place_content_id, place_contents.deleted == False # noqa: E712 + ).values(hidden=hidden, updated_at=GTime.UTC()), + ) diff --git a/solution/backend/router/v1/local/local.py b/solution/backend/router/v1/local/local.py index 7b4b870..7099753 100644 --- a/solution/backend/router/v1/local/local.py +++ b/solution/backend/router/v1/local/local.py @@ -1,10 +1,15 @@ +import uuid + from fastapi import APIRouter, Depends, Query from common.enums import LocalContentStatus from common.models.gmodel import UserInfo from router.v1.validator.dependencies import RequireOwner, RemoveNoneResponse from services.local_content_service import LocalContentService -from .protocol import ReqPublishLocalContent, ReqSyncFestivals, ReqUpdateLocalContent, ResLocalContentList, ResSyncFestivals, ResWeather +from .protocol import ( + ReqHidePlaceContent, ReqPublishLocalContent, ReqUpdateLocalContent, + ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, +) router = APIRouter(prefix="/v1/admin/local-content", tags=["LocalContent"]) weather_router = APIRouter(prefix="/v1/local", tags=["LocalContent"]) @@ -20,6 +25,35 @@ async def get_weather( return RemoveNoneResponse(await service.get_weather(region_code, latitude, longitude)) +@weather_router.get("/guide", response_model=ResLocalGuide, summary="업장 주변 가이드(맛집·명소·축제·코스) — 에디터 캔버스용") +async def get_guide( + place_id: uuid.UUID = Query(), + service: LocalContentService = Depends(), +): + """날씨와 같은 공개 조회다 — 운영자가 숨기지 않은 공공데이터만 나가므로 인증을 요구하지 않는다.""" + return RemoveNoneResponse(await service.get_guide(place_id)) + + +@router.get("/place/{place_id}", response_model=ResPlaceContentList, summary="업장 주변정보 목록 (숨김 포함)") +async def list_place_contents(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): + return RemoveNoneResponse(await service.list_place_contents(place_id)) + + +@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)") +async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): + """빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다.""" + return RemoveNoneResponse(await service.sync_place_by_id(place_id)) + + +@router.post("/place-content/{place_content_id}/hide", response_model=ResPlaceContentList, summary="주변정보 숨김/해제") +async def hide_place_content( + place_content_id: uuid.UUID, req: ReqHidePlaceContent, + service: LocalContentService = Depends(), _user=Depends(RequireOwner), +): + """숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다.""" + return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden)) + + @router.get("", response_model=ResLocalContentList) async def list_contents( status: LocalContentStatus | None = Query(None), region_code: str | None = Query(None), @@ -28,11 +62,6 @@ async def list_contents( return RemoveNoneResponse(await service.list(status.value if status else None, region_code)) -@router.post("/sync-festivals", response_model=ResSyncFestivals) -async def sync_festivals(req: ReqSyncFestivals, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): - return RemoveNoneResponse(await service.sync_festivals(req)) - - @router.post("/publish", response_model=ResLocalContentList) async def publish(req: ReqPublishLocalContent, service: LocalContentService = Depends(), user=Depends(RequireOwner)): return RemoveNoneResponse(await service.publish(req.content_ids, user.user_id)) diff --git a/solution/backend/router/v1/local/protocol.py b/solution/backend/router/v1/local/protocol.py index 9e54327..23d6519 100644 --- a/solution/backend/router/v1/local/protocol.py +++ b/solution/backend/router/v1/local/protocol.py @@ -25,10 +25,29 @@ class LocalContentData(WebPacketProtocol): display_end_at: datetime | None = None -class ReqSyncFestivals(WebPacketProtocol): - region_code: str = Field(min_length=1, max_length=10) - area_code: str | None = None - start_date: str | None = Field(default=None, pattern=r"^\d{8}$") +class PlaceContentData(WebPacketProtocol): + """업장 반경 주변정보 1건(admin 목록용).""" + model_config = ConfigDict(from_attributes=True) + + place_content_id: uuid.UUID + place_id: uuid.UUID + content_type: LocalContentType + external_id: str + title: str + body: dict[str, Any] + distance_m: int + has_image: bool + hidden: bool + display_end_at: datetime | None = None + collected_at: datetime | None = None + + +class ResPlaceContentList(Res_WebPacketProtocol): + contents: list[PlaceContentData] = [] + + +class ReqHidePlaceContent(WebPacketProtocol): + hidden: bool class ReqPublishLocalContent(WebPacketProtocol): @@ -46,9 +65,25 @@ class ResLocalContentList(Res_WebPacketProtocol): contents: list[LocalContentData] = [] -class ResSyncFestivals(Res_WebPacketProtocol): - collected: int = 0 - skipped: int = 0 +class ResSyncPlace(Res_WebPacketProtocol): + """업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지.""" + + festivals: int = 0 + attractions: int = 0 + restaurants: int = 0 + courses: int = 0 + changed: bool = False + + +class ResLocalGuide(Res_WebPacketProtocol): + """에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 + (services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다.""" + + attractions: list[dict[str, Any]] = [] + restaurants: list[dict[str, Any]] = [] + festivals: list[dict[str, Any]] = [] + courses: list[dict[str, Any]] = [] + synced_at: str | None = None class WeatherData(WebPacketProtocol): diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 2fb75ea..7d67556 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -39,6 +39,8 @@ class Req_VerifyPlace(PlaceProtocol): latitude: Optional[Decimal] = None longitude: Optional[Decimal] = None region_code: Optional[str] = None + # 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다. + category_name: Optional[str] = None class Req_VerifyPlaceByUrl(PlaceProtocol): diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 5869167..986e548 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -30,6 +30,7 @@ from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail +from services.local_content_service import LocalContentService from services.site_payload import emit_payload from services.snapshot import build_snapshot @@ -114,6 +115,17 @@ async def run_build(job: dict) -> dict: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) + + # ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 + # 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, + # place_contents 는 직전 값을 그대로 갖고 있다. + try: + synced = await LocalContentService().sync_place(place) + if not synced.result.success: + LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}") + except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다 + LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}") + snapshot = await build_snapshot(place) v_err, version_no = await DB_SESSION_MNG.execute_lambda( diff --git a/solution/backend/services/collector/tour_api_adapter.py b/solution/backend/services/collector/tour_api_adapter.py index 07f5264..650c8ba 100644 --- a/solution/backend/services/collector/tour_api_adapter.py +++ b/solution/backend/services/collector/tour_api_adapter.py @@ -284,9 +284,11 @@ class TourApiAdapter: def _lodging_facts(self, intro: dict) -> list[CollectedFact]: """숙박 detailIntro2 → 사업장 단위 fact. - ★ 스키마에 없는 값은 만들지 않는다. roomcount·scalelodging·foodplace 는 - lodging 스키마에 자리가 없어서 **일부러 버린다** — 억지로 다른 key 에 넣으면 - '식음료장 있음' 이 '조식 제공' 으로 둔갑한다. + ★ 스키마에 없는 값은 만들지 않는다. foodplace('식음료장 있음')는 lodging 스키마에 + 자리가 없어서 **일부러 버린다** — 억지로 breakfast 에 넣으면 '조식 제공' 으로 둔갑한다. + ★ roomcount·scalelodging·accomcountlodging·subfacility 는 2026-09-07 에 자리를 만들었다 + (total_rooms·building_scale·accommodation_capacity·facilities). 실측(오블로모프 3103191)에서 + TourAPI 가 준 값의 절반이 자리가 없어 버려지고 있었다. """ return self._collect([ ("check_in_time", self._plain(intro.get("checkintime"))[:40]), @@ -296,6 +298,11 @@ class TourApiAdapter: ("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))), ("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))), ("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))), + ("facilities", self._plain(intro.get("subfacility"))[:500]), + ("total_rooms", self._number(intro.get("roomcount"))), + ("accommodation_capacity", self._number(intro.get("accomcountlodging"))), + # "약 23평" · "대지 면적 11,570㎡" 처럼 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다. + ("building_scale", self._plain(intro.get("scalelodging"))[:200]), ]) def _restaurant_facts(self, intro: dict) -> list[CollectedFact]: @@ -362,6 +369,16 @@ class TourApiAdapter: ("peak_price", self._number(row.get("roompeakseasonminfee1")), name), ("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name), ("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name), + # 객실 편의시설 Y/N. ★ 빈 값은 '없음'이 아니라 '모름'이다 — _yn 이 None 을 주면 fact 를 만들지 않는다. + ("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name), + ("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name), + ("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name), + ("has_refrigerator", self._bool_str(self._yn(row.get("roomrefrigerator"))), name), + ("has_hairdryer", self._bool_str(self._yn(row.get("roomhairdryer"))), name), + ("has_toiletries", self._bool_str(self._yn(row.get("roomtoiletries"))), name), + ("has_table", self._bool_str(self._yn(row.get("roomtable"))), name), + ("has_sofa", self._bool_str(self._yn(row.get("roomsofa"))), name), + ("has_home_theater", self._bool_str(self._yn(row.get("roomhometheater"))), name), ]) return facts diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index 160d98e..5fcde48 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -43,9 +43,10 @@ _BASE_URL = "https://dapi.kakao.com" _KEYWORD_URL = f"{_BASE_URL}/v2/local/search/keyword.json" _COORD2REGION_URL = f"{_BASE_URL}/v2/local/geo/coord2regioncode.json" _CATEGORY_URL = f"{_BASE_URL}/v2/local/search/category.json" +_ADDRESS_URL = f"{_BASE_URL}/v2/local/search/address.json" # 초과 단가(원). 로그에 함께 남겨 어떤 호출이 비싼지 바로 보이게 한다. -_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5} +_UNIT_COST_KRW = {"keyword": 2.0, "category": 2.0, "coord2region": 0.5, "address": 0.5} # 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거. _CALL_COUNTS: Counter = Counter() @@ -362,6 +363,28 @@ class KakaoLocalClient: picked = next((d for d in docs if d.get("region_type") == "H"), docs[0]) return RegionCode.from_document(picked) + # ---- 2-1) 주소 → 좌표 ---- + async def geocode_address(self, address: str) -> Optional[tuple[float, float]]: + """도로명·지번 주소 → (위도, 경도). 결과가 없으면 None — 좌표를 지어내지 않는다. + + ★ 쓰는 곳: 좌표 없이 검증된 사업장의 주변 정보 수집(local_content_service.sync_place). + 동일 업소 검증(카카오 후보·네이버 상세)은 좌표를 같이 주므로 보통은 비어 있지 않다 — + 비는 건 옛 데이터나 좌표 없는 후보를 고른 경우다. 그때 주소로 한 번 더 찾는다. + 싸다(초과 시 건당 0.5원). 결과는 places 에 박제하므로 사업장당 1회다. + """ + query = (address or "").strip() + if not query: + return None + data = await self._get(_ADDRESS_URL, {"query": query, "size": "1"}, "address") + docs = data.get("documents") or [] + if not docs: + LOG.i(f"[kakao] 주소 → 좌표 결과 없음: {query[:60]}") + return None + lat, lon = _to_float(docs[0].get("y")), _to_float(docs[0].get("x")) # ★ y=위도 · x=경도 + if lat is None or lon is None: + return None + return lat, lon + # ---- 3) 주변 맛집·시설 ---- async def search_category( self, diff --git a/solution/backend/services/external/tour_api.py b/solution/backend/services/external/tour_api.py index a4102ae..e3cc4e2 100644 --- a/solution/backend/services/external/tour_api.py +++ b/solution/backend/services/external/tour_api.py @@ -1,17 +1,58 @@ -"""공공데이터포털 전국문화축제표준데이터 클라이언트. +"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트. -설정 키 이름은 기존 배포 계약을 유지하기 위해 TOUR_API_KEY를 그대로 사용한다. +collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는 +업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다. +결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다. + +★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md) + 처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지 + '이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고, + 같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고 + 거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건. + +★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제) + 2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) — + 한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다. + 여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다. + +★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체) + locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18): + 반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의 + 진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도) + 단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다. + eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직 + 진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부르고, + 이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다. + +★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙) + firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다. + 발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다. """ - -import hashlib from datetime import date -from urllib.parse import unquote +from typing import Optional +from urllib.parse import unquote, urlencode import httpx +from common.enums import LocalContentType +from common.utils.geo import haversine_m from config.server_configs import external_api_config -_URL = "https://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api" +BASE_URL = "https://apis.data.go.kr/B551011/KorService2" +REQUEST_TIMEOUT = 25 +PAGE_SIZE = 100 +# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다. +MAX_PAGES = 4 + +# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 — +# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족). +CONTENT_TYPE_MAP = { + "39": LocalContentType.RESTAURANT.value, + "12": LocalContentType.ATTRACTION.value, +} + +# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유). +_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"}) class TourApiNotConfigured(RuntimeError): @@ -22,52 +63,225 @@ class TourApiRequestFailed(RuntimeError): pass -def _normalize(item: dict) -> dict: - """표준데이터 필드명을 화면/저장소의 공통 축제 필드로 변환한다.""" - identity = "|".join(str(item.get(key) or "") for key in ("fstvlNm", "fstvlStartDate", "fstvlEndDate", "insttCode")) - return { - "contentid": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32], - "title": item.get("fstvlNm"), - "eventstartdate": str(item.get("fstvlStartDate") or "").replace("-", ""), - "eventenddate": str(item.get("fstvlEndDate") or "").replace("-", ""), - "addr1": item.get("rdnmadr") or item.get("lnmadr") or item.get("eventPlace"), - "mapx": item.get("longitude"), - "mapy": item.get("latitude"), - "tel": item.get("phoneNumber"), - "homepage": item.get("homepageUrl"), - "organizer": item.get("suprtInsttNm"), - "overview": item.get("relateInfo") or item.get("opar"), - "raw": item, - } +# ── HTTP ─────────────────────────────────────────────────────────────── -async def search_festivals(*, start_date: date, area_code: str | None = None, rows: int = 100) -> list[dict]: +def _service_key() -> str: key = (external_api_config.tour_api_key or "").strip() if not key: raise TourApiNotConfigured("TOUR_API_KEY is not configured") - params = { - # 포털의 "Encoding" 키를 넣어도 httpx가 이중 인코딩하지 않도록 원문으로 되돌린다. - "serviceKey": unquote(key), - "type": "json", - "numOfRows": max(1, min(rows, 1000)), - "pageNo": 1, - } + return key + + +async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]: + """오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다. + + ★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다 + (collector/tour_api_adapter._call 과 같은 처리). + """ + query = urlencode( + {"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai", + "_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params}, + safe="", + ) + res = await client.get(f"{BASE_URL}/{op}?{query}") + if res.status_code != 200: + raise TourApiRequestFailed(f"{op} HTTP {res.status_code}") try: - async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0)) as client: - response = await client.get(_URL, params=params) - response.raise_for_status() - payload = response.json() - response_data = payload.get("response", payload) - header = response_data.get("header", {}) - if header.get("resultCode") not in (None, "00", "0000"): - raise TourApiRequestFailed(header.get("resultMsg") or "TourAPI returned an error") - items_node = response_data.get("body", {}).get("items", []) - items = items_node.get("item", []) if isinstance(items_node, dict) else items_node - if isinstance(items, dict): - items = [items] - normalized = [_normalize(item) for item in (items or [])] - # API가 날짜 필터를 제공하지 않으므로 종료된 축제는 애플리케이션에서 제외한다. - cutoff = start_date.strftime("%Y%m%d") - return [item for item in normalized if not item["eventenddate"] or item["eventenddate"] >= cutoff] - except (httpx.HTTPError, ValueError, TypeError) as ex: - raise TourApiRequestFailed(str(ex)) from ex + payload = res.json() + except ValueError: + # 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다. + raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}") + + # 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다. + if "response" not in payload: + raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}") + header = payload["response"].get("header", {}) + code = str(header.get("resultCode") or "") + if code not in ("0000", "00"): + raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}") + + body = payload["response"].get("body", {}) or {} + items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None + if isinstance(items, dict): + items = [items] + total = int(body.get("totalCount") or 0) + return items or [], total + + +# ── 정규화 ────────────────────────────────────────────────────────────── + +def _int(value) -> Optional[int]: + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return None + + +def _normalize(item: dict) -> Optional[dict]: + """locationBasedList2 항목 1건 → place_contents.body. contentid·title·거리·종류 없으면 버린다.""" + content_id = str(item.get("contentid") or "").strip() + title = str(item.get("title") or "").strip() + kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip()) + distance = _int(item.get("dist")) + if not content_id or not title or kind is None or distance is None: + return None + + body = {"contentid": content_id, "title": title, "content_type": kind, "distance_m": distance} + # lclsSystm1~3 = 분류체계 대/중/소. ★ 중분류(lclsSystm2)가 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준이다. + for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", + "lclsSystm1", "lclsSystm2", "lclsSystm3"): + value = str(item.get(key) or "").strip() + if value: + body[key] = value + + # 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다. + image = str(item.get("firstimage") or "").strip() + license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() + if image and license_code in _COMMERCIAL_OK_LICENSES: + body["firstimage"] = image + body["license"] = license_code + thumb = str(item.get("firstimage2") or "").strip() + if thumb: + body["firstimage2"] = thumb + return body + + +# ── 공개 API ──────────────────────────────────────────────────────────── + +def make_client() -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=REQUEST_TIMEOUT) + + +async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float, + *, radius_m: int, content_type_id: str) -> list[dict]: + """업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다. + + ★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정). + """ + out: list[dict] = [] + seen: set[str] = set() + for page in range(1, MAX_PAGES + 1): + items, total = await _call( + client, "locationBasedList2", + mapX=str(longitude), mapY=str(latitude), radius=str(radius_m), + contentTypeId=content_type_id, arrange="E", pageNo=str(page), + ) + for item in items: + body = _normalize(item) + if body and body["contentid"] not in seen: + seen.add(body["contentid"]) + out.append(body) + if not items or page * PAGE_SIZE >= total: + break + out.sort(key=lambda b: b["distance_m"]) + return out + + +def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]: + """searchFestival2 항목 1건 → place_contents.body. locationBasedList2 와 달리 `dist` 를 + 안 주므로(호출측이 haversine 으로 잰 값을) 그대로 받는다. 기간은 여기 이미 있다.""" + content_id = str(item.get("contentid") or "").strip() + title = str(item.get("title") or "").strip() + if not content_id or not title: + return None + + body = {"contentid": content_id, "title": title, + "content_type": LocalContentType.FESTIVAL.value, "distance_m": distance_m} + for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd", + "lclsSystm1", "lclsSystm2", "lclsSystm3", "eventstartdate", "eventenddate"): + value = str(item.get(key) or "").strip() + if value: + body[key] = value + + image = str(item.get("firstimage") or "").strip() + license_code = str(item.get("cpyrhtDivCd") or "").strip().lower() + if image and license_code in _COMMERCIAL_OK_LICENSES: + body["firstimage"] = image + body["license"] = license_code + thumb = str(item.get("firstimage2") or "").strip() + if thumb: + body["firstimage2"] = thumb + return body + + +def _festival_not_ended(body: dict, today: date) -> bool: + """종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다. + 종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다.""" + end, start = body.get("eventenddate"), body.get("eventstartdate") + ymd = today.strftime("%Y%m%d") + if end: + return len(end) == 8 and end.isdigit() and end >= ymd + return bool(start) + + +async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float, + *, sido_code: str, today: date) -> list[dict]: + """업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다. + + ★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의 + 큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다. + (종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.) + ★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring). + eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인 + 축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다. + ★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다. + """ + start_date = date(today.year, 1, 1).strftime("%Y%m%d") + out: list[dict] = [] + seen: set[str] = set() + for page in range(1, MAX_PAGES + 1): + items, total = await _call( + client, "searchFestival2", + eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page), + ) + for item in items: + lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다 + if lnglat is None: + continue + lng, lat = lnglat + distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용 + body = _normalize_festival(item, round(distance)) + if not body or body["contentid"] in seen: + continue + if not _festival_not_ended(body, today): + continue + seen.add(body["contentid"]) + out.append(body) + if not items or page * PAGE_SIZE >= total: + break + out.sort(key=lambda b: b["distance_m"]) + return out + + +def _mapxy(item: dict) -> Optional[tuple[float, float]]: + """(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다.""" + try: + return float(item.get("mapx")), float(item.get("mapy")) + except (TypeError, ValueError): + return None + + +async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]: + """콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None. + + 업장 자신이 TourAPI 에 등록돼 있을 때(place_links 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 — + 주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다). + """ + items, _ = await _call(client, "detailCommon2", contentId=content_id) + if not items: + return None + code = str(items[0].get("lclsSystm2") or "").strip() + return code or None + + +def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool: + """종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다. + 종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다.""" + if not period: + return False + start, end = period + ymd = today.strftime("%Y%m%d") + if end: + return len(end) == 8 and end.isdigit() and end >= ymd + return bool(start) diff --git a/solution/backend/services/itinerary.py b/solution/backend/services/itinerary.py new file mode 100644 index 0000000..616baba --- /dev/null +++ b/solution/backend/services/itinerary.py @@ -0,0 +1,120 @@ +"""여행 일정(1박2일·2박3일) 생성 — 순수 함수 모듈. DB·HTTP 없음. + +빌드(payload) 시점에 그 지역의 발행된 지역정보(관광지·맛집·축제)와 업체 좌표로 +일정을 **즉석 계산**한다. 저장하지 않는 이유: 재료(local_contents)가 갱신되면 +다음 빌드에서 일정도 저절로 최신이 된다 — 따로 저장하면 그 동기화를 또 만들어야 한다. +(docs/superpowers/specs/2026-09-03-local-tourapi-sync-design.md) + +★ 지어내지 않는 규칙은 여기도 적용된다. + 재료가 부족하면 채울 수 있는 만큼만 담고, 하루도 못 채우면 일정 자체를 내지 않는다. + 좌표 없는 항목은 거리를 잴 수 없으므로 후보에서 뺀다 — 동선을 보장 못 하는 추천은 틀린 추천이다. + +하루의 뼈대: 관광지 2 + 맛집 2(점심·저녁). 진행 중 축제가 있으면 그날 관광지 한 자리를 대신한다. +일자 배분은 업체에서 가까운 순으로 후보를 끊고, 일자 안에서는 최근접 이웃 순서로 동선을 만든다. +""" +from typing import Optional + +from common.utils.geo import haversine_km + +# 하루 구성 정원. 관광지 자리는 축제가 하나 대신할 수 있다. +_SPOTS_PER_DAY = 2 +_MEALS_PER_DAY = 2 +# 후보 반경(km). 업체에서 이보다 먼 곳은 '근처'가 아니다 — 1박2일 생활권을 넘는다. +_MAX_RADIUS_KM = 30.0 + +_STOP_ATTRACTION = "attraction" +_STOP_RESTAURANT = "restaurant" +_STOP_FESTIVAL = "festival" + + +def _as_float(value) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _candidates(rows: list[dict], stop_type: str, + base_lat: float, base_lng: float) -> list[dict]: + """payload 지역정보 행 → 거리 오름차순 후보. 좌표가 없거나 반경 밖이면 뺀다. + + 행 모양은 스냅샷 local.contents 의 body(services/external/tour_api._normalize)다 — + mapx=경도, mapy=위도 (WGS84 문자열). + """ + out = [] + for row in rows: + body = row.get("body") or {} + name = str(row.get("title") or body.get("title") or "").strip() + lat, lng = _as_float(body.get("mapy")), _as_float(body.get("mapx")) + if not name or lat is None or lng is None: + continue + dist = haversine_km(base_lat, base_lng, lat, lng) + if dist > _MAX_RADIUS_KM: + continue + out.append({"type": stop_type, "name": name, "lat": lat, "lng": lng, + "distanceKm": round(dist, 1)}) + out.sort(key=lambda c: c["distanceKm"]) + return out + + +def _order_by_route(stops: list[dict], base_lat: float, base_lng: float) -> list[dict]: + """일자 안 동선: 업체에서 출발해 최근접 이웃 순으로 잇는다.""" + remaining = list(stops) + ordered: list[dict] = [] + lat, lng = base_lat, base_lng + while remaining: + nxt = min(remaining, key=lambda s: haversine_km(lat, lng, s["lat"], s["lng"])) + remaining.remove(nxt) + ordered.append(nxt) + lat, lng = nxt["lat"], nxt["lng"] + return ordered + + +def _take(pool: list[dict], count: int) -> list[dict]: + taken, pool[:] = pool[:count], pool[count:] + return taken + + +def _plan_days(days: int, attractions: list[dict], restaurants: list[dict], + festivals: list[dict], base_lat: float, base_lng: float) -> Optional[dict]: + """일자별 계획. 첫날 하루도 못 채우면 None — 반쪽짜리 일정은 내지 않는다.""" + spots = list(attractions) + meals = list(restaurants) + fests = list(festivals) + plan = [] + for day in range(1, days + 1): + day_stops = [] + # 축제는 하루 하나까지, 관광지 한 자리를 대신한다. + fest = _take(fests, 1) + day_stops += fest + day_stops += _take(spots, _SPOTS_PER_DAY - len(fest)) + day_stops += _take(meals, _MEALS_PER_DAY) + if not day_stops: + break + plan.append({"day": day, "stops": _order_by_route(day_stops, base_lat, base_lng)}) + if not plan: + return None + return {"days": days, "plan": plan} + + +def build_itineraries(base_lat: Optional[float], base_lng: Optional[float], + attractions: list[dict], restaurants: list[dict], + festivals: list[dict]) -> list[dict]: + """업체 좌표 기준 1박2일(2일)·2박3일(3일) 일정. 좌표가 없으면 빈 배열. + + 입력 행 모양은 스냅샷 local.contents 항목({title, body:{mapx, mapy, …}})이다. + """ + if base_lat is None or base_lng is None: + return [] + spot_pool = _candidates(attractions, _STOP_ATTRACTION, base_lat, base_lng) + meal_pool = _candidates(restaurants, _STOP_RESTAURANT, base_lat, base_lng) + fest_pool = _candidates(festivals, _STOP_FESTIVAL, base_lat, base_lng) + + out = [] + for days in (2, 3): + # 세트마다 독립된 풀 복사 — 1박2일이 소비한 후보가 2박3일에서 빠지면 안 된다. + built = _plan_days(days, list(spot_pool), list(meal_pool), list(fest_pool), + base_lat, base_lng) + if built: + out.append(built) + return out diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 071779d..1903457 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -1,17 +1,71 @@ -from datetime import date, datetime, timedelta, timezone +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 local_contents -from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource +from common.database.model.models import local_contents, place_contents, place_links, places +from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory +from common.logger import LOG from crud.local_content_crud import LocalContentCRUD -from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData +from crud.place_content_crud import PlaceContentCRUD +from crud.place_crud import PlaceCRUD +from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed +from services.external.naver import region_key +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, search_festivals +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). +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() @@ -22,11 +76,67 @@ class LocalContentService: res.contents = list(rows) if err == ErrorType.SUCCESS else [] return res - async def sync_festivals(self, req): - res = ResSyncFestivals() + # ── 업장 반경 주변정보 ─────────────────────────────────────────────── + + async def sync_place(self, place) -> ResSyncPlace: + """업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_contents 를 맞춘다. + 종류마다 따로 부른다(맛집 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: - start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today() - items = await search_festivals(start_date=start, area_code=req.area_code) + 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를 먼저 설정해주세요." @@ -36,35 +146,208 @@ class LocalContentService: res.msg = str(ex) return res - collected = skipped = 0 - for item in items: - external_id = str(item.get("contentid") or "") - if not external_id: - skipped += 1 + kept: list[dict] = restaurants + attractions + festivals + + err, rows = await DB_SESSION_MNG.execute_lambda( + place_contents.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 [])} + + now = datetime.now(timezone.utc) + changed = 0 + for body in kept: + key = (body["content_type"], body["contentid"]) + prev = existing.get(key) + # 원문이 그대로면 쓰지 않는다 — collected_at 만 밀리면 '갱신된 척'이 된다. + if prev is not None and prev.body == body: continue - _, existing = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, - lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid), + values = { + "place_id": place_id, + "content_type": body["content_type"], + "external_id": body["contentid"], + "title": body["title"], + "body": body, + "distance_m": body["distance_m"], + "has_image": bool(body.get("firstimage")), + "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( + [place_contents.DBType()], [lambda s, v=values: self.place_crud.upsert(s, v)] ) - if existing: - skipped += 1 - continue - row = local_contents( - region_code=req.region_code, - content_type=LocalContentType.FESTIVAL.value, - source=LocalSource.TOUR_API.value, - external_id=external_id, - title=item.get("title"), - body=item, - status=LocalContentStatus.REVIEW.value, - collected_at=datetime.now(timezone.utc), - ) - err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)]) - collected += int(err == ErrorType.SUCCESS) - skipped += int(err != ErrorType.SUCCESS) - res.collected, res.skipped = collected, skipped + if write_err == ErrorType.SUCCESS: + changed += 1 + + keep_keys = {(b["content_type"], b["contentid"]) for b in kept} + _, removed = await DB_SESSION_MNG.execute_lambda_claim( + place_contents.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, keep_keys) + ) + + 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_links 의 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_links.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_contents.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_contents.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)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다. + """ + # 순환 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_contents.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}") + + 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.synced_at = synced_at + return res + + # ── 지역 캐시(local_contents) — 운영자 수기 항목·날씨 ──────────────── + async def publish(self, ids, user_id): res = ResLocalContentList() err, _ = await DB_SESSION_MNG.execute_lambda_claim( diff --git a/solution/backend/services/place_category.py b/solution/backend/services/place_category.py index 950b662..25db0da 100644 --- a/solution/backend/services/place_category.py +++ b/solution/backend/services/place_category.py @@ -68,3 +68,39 @@ def _match(text: str) -> Optional[PlaceCategory]: if any(word in text for word in words): return category return None + + +# ── 음식 중분류(TourAPI lclsSystm2) 추정 ───────────────────────────────── +# 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정). +# TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전) +# · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집. +# +# ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 — +# 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다. +# ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 — +# "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다. +_FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ + ("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")), + ("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")), + ("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥", + "떡볶이", "간식", "토스트", "패스트푸드")), + ("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크", + "프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")), + ("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈", + "해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")), +] + + +def guess_food_class(category_name: Optional[str]) -> Optional[str]: + """외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음). + + 예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 · + 카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02 + """ + text = (category_name or "").replace(" ", "") + if not text: + return None + for code, words in _FOOD_CLASS_KEYWORDS: + if any(word in text for word in words): + return code + return None diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index ef78d95..4453cca 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -240,6 +240,8 @@ class PlaceService: phone=base.get("phone") or base.get("virtualPhone") or None, latitude=Decimal(str(coord.get("y"))) if coord.get("y") else None, longitude=Decimal(str(coord.get("x"))) if coord.get("x") else None, + # 네이버 상세의 분류("펜션"·"카페,디저트"). 주변 맛집 경쟁업소 제외의 폴백 근거(실측 2026-09-08: 있음). + category_name=str(base.get("category") or "").strip() or None, ) verified = await self.verify_place(user_info, place_id, verify_req) if not verified.result.success: @@ -318,6 +320,9 @@ class PlaceService: "verified_at": now, "verified_by": uuid.UUID(user_info.user_id), } + # ★ 값이 왔을 때만 덮는다 — 네이버 URL 재검증이 분류를 못 읽었다고 카카오가 준 값을 지우면 안 된다. + if (req.category_name or "").strip(): + data["external_category"] = req.category_name.strip()[:200] err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data), diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 1281900..22f6246 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -31,6 +31,7 @@ from common.enums import ( SourceType, ) from common.logger import LOG +from services.itinerary import build_itineraries # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 @@ -477,32 +478,77 @@ def _festival(row: dict): # 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다. if homepage.startswith("http://") or homepage.startswith("https://"): entry["officialUrl"] = homepage + # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 + # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정). + _put_distance(entry, body) + image = _text(body.get("firstimage")) + if image: + entry["imageUrl"] = image return entry def _local_place(row: dict, category: str): """LocalPlace(주변 명소·맛집). - ★ title 컬럼만 믿는다. 이 두 종류(ATTRACTION·RESTAURANT)를 채우는 수집기가 아직 없어서 - body 의 모양이 정해지지 않았다 — 있지도 않은 키를 가정해 파싱하면 수집기가 붙는 날 - 조용히 빈 값이 나간다. 지금은 테이블 스키마가 보장하는 것(title)만 쓰고, - 설명·거리는 실제 수집기가 붙을 때 그 모양을 보고 채운다.""" + body 는 TourAPI 수집기가 채운다(services/external/tour_api._normalize) — + 주소(addr1)가 있으면 위치로 싣는다. 없는 값은 만들지 않는다.""" name = _text(row.get("title")) if not name: return None - return {"name": name, "category": category, "searchQuery": name} + body = row.get("body") or {} + entry = {"name": name, "category": category, "searchQuery": name} + location = _text(body.get("addr1")) + if location: + entry["location"] = location + # 거리는 수집 시점에 업장 좌표로 잰 값(place_contents.distance_m). 지역 캐시 항목엔 없다. + # ★ 숫자(distanceMeters)도 함께 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를 계산하려면 + # "1.2km" 같은 문자열을 다시 파싱하는 것보다 원값이 안전하다. + _put_distance(entry, body) + image = _text(body.get("firstimage")) + if image: + entry["imageUrl"] = image + return entry -def _local(snapshot_local: dict) -> tuple[dict, str | None]: +def _put_distance(entry: dict, body: dict) -> None: + """distance_m → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" + distance = _distance_text(body.get("distance_m")) + if not distance: + return + entry["distanceText"] = distance + entry["distanceMeters"] = int(body.get("distance_m")) + + +def _distance_text(meters) -> str: + """850 → "850m", 1234 → "1.2km". 없으면 빈 문자열.""" + try: + m = int(meters) + except (TypeError, ValueError): + return "" + if m < 0: + return "" + if m < 1000: + return f"{m}m" + # 반올림은 '5 는 올림'으로 — f"{1.45:.1f}" 는 부동소수 탓에 1.4 가 나온다. + return f"{(m + 50) // 100 / 10:.1f}km" + + +def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]: """스냅샷의 지역 정보 → LocalContents. ★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 — fact·사진과 같은 분업이다. ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도 - payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.""" + payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다. + ★ itineraries(1박2일·2박3일 일정)는 저장하지 않고 여기서 즉석 계산한다 — + 재료가 갱신되면 다음 빌드에서 일정도 저절로 최신이 된다(services/itinerary.py). + 렌더러에 아직 이 필드의 자리가 없다 — 모르는 필드는 무시되므로 화면은 변하지 않는다.""" contents = (snapshot_local or {}).get("contents") or [] - local = {"attractions": [], "restaurants": [], "festivals": []} + # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07). + local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []} synced_at = None + # 일정 계산용 원본 행(좌표가 body 에 있다). payload 항목은 좌표를 싣지 않으므로 따로 모은다. + raw = {"attractions": [], "restaurants": [], "festivals": []} for row in contents: if not isinstance(row, dict): @@ -523,16 +569,29 @@ def _local(snapshot_local: dict) -> tuple[dict, str | None]: entry = _festival(row) if entry: local["festivals"].append(entry) + raw["festivals"].append(row) elif content_type == LocalContentType.ATTRACTION.value: entry = _local_place(row, "관광지") if entry: local["attractions"].append(entry) + raw["attractions"].append(row) elif content_type == LocalContentType.RESTAURANT.value: entry = _local_place(row, "맛집") if entry: local["restaurants"].append(entry) + raw["restaurants"].append(row) + elif content_type == LocalContentType.COURSE.value: + entry = _local_place(row, "여행코스") + if entry: + local["courses"].append(entry) # 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다. + itineraries = build_itineraries( + base_lat, base_lng, raw["attractions"], raw["restaurants"], raw["festivals"] + ) + if itineraries: + local["itineraries"] = itineraries + return local, synced_at @@ -708,7 +767,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: # ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙). # 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다. # 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다. - local, local_synced_at = _local(snapshot.get("local") or {}) + local, local_synced_at = _local( + snapshot.get("local") or {}, + _as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")), + ) if local_synced_at: local["syncedAt"] = local_synced_at diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 5cf5022..826c9ce 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -22,13 +22,14 @@ from sqlalchemy import or_, select from common.category_schema import get_schema from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import facts, faqs, local_contents, media, units +from common.database.model.models import facts, faqs, local_contents, media, place_contents, units from common.enums import ( PUBLISHABLE_FACT_STATUSES, DBWRType, ErrorType, FactStatus, LocalContentStatus, + LocalSource, MediaStatus, PlaceCategory, ) @@ -38,9 +39,9 @@ from services.external.naver import region_key _PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES) # 지역 정보를 종류별로 몇 건까지 박제할지. -# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라 -# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다. -# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다. +# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정). +# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다. +# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다. _LOCAL_MAX_PER_TYPE = 20 # 지역 원문(body)에서 스냅샷으로 옮기지 않는 키. @@ -164,7 +165,11 @@ async def build_snapshot(place) -> dict: async def _local_contents(place) -> dict: - """사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]} + """사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]} + + 두 캐시를 합친다 — + local_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목 + place_contents (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외 ★ 노출 가능 = PUBLISHED + 노출 기간 안. local_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3). @@ -192,48 +197,77 @@ async def _local_contents(place) -> dict: region_code = region_key( str(getattr(place, "road_address", None) or getattr(place, "address", None) or "") ) or "" - if not region_code: - return {"region_code": None, "contents": []} - now = datetime.now(timezone.utc) - query = ( - select(local_contents) - .where( - local_contents.region_code == region_code, - local_contents.deleted == False, # noqa: E712 - local_contents.status == LocalContentStatus.PUBLISHED.value, - or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now), - or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now), - ) - .order_by(local_contents.content_type.asc(), local_contents.collected_at.desc()) - ) - err, rows = await DB_SESSION_MNG.execute_lambda( - local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query) - ) - if err != ErrorType.SUCCESS: - # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. - # 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다. - LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}") - return {"region_code": region_code, "contents": []} + contents: list[dict] = [] + seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다 - seen: dict[int, int] = {} - contents = [] - for row in rows or []: + # ── 지역 캐시(local_contents): 날씨 + 운영자가 수기로 발행한 항목 ── + if region_code: + query = ( + select(local_contents) + .where( + local_contents.region_code == region_code, + local_contents.deleted == False, # noqa: E712 + local_contents.status == LocalContentStatus.PUBLISHED.value, + or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now), + or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now), + ) + .order_by(local_contents.content_type.asc(), local_contents.collected_at.desc()) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query) + ) + if err != ErrorType.SUCCESS: + # ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다. + LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}") + rows = [] + contents += _local_rows(rows or [], seen) + + # ── 업장 반경 캐시(place_contents): 맛집·관광지·축제·여행코스 ── + # ★ 정렬은 **사진 있는 것 우선 → 가까운 순**(2026-09-07 결정). 종류별 상한 안에 들려면 + # 사진 없는 가까운 곳보다 사진 있는 조금 먼 곳이 이긴다 — 화면이 카드라 사진이 없으면 자리가 빈다. + place_id = getattr(place, "place_id", None) + if place_id is not None: + nearby_q = ( + select(place_contents) + .where( + place_contents.place_id == place_id, + place_contents.deleted == False, # noqa: E712 + place_contents.hidden == False, # noqa: E712 + or_(place_contents.display_end_at.is_(None), place_contents.display_end_at > now), + ) + .order_by(place_contents.content_type.asc(), place_contents.has_image.desc(), place_contents.distance_m.asc()) + ) + err, rows = await DB_SESSION_MNG.execute_lambda( + place_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, nearby_q) + ) + if err != ErrorType.SUCCESS: + LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}") + rows = [] + contents += _local_rows(rows or [], seen, source=LocalSource.TOUR_API.value) + + return {"region_code": region_code or None, "contents": contents} + + +def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]: + """행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다. + seen 은 호출측이 넘겨 두 캐시에 걸쳐 누적한다.""" + out = [] + for row in rows: content_type = int(row.content_type) - # 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다. taken = seen.get(content_type, 0) if taken >= _LOCAL_MAX_PER_TYPE: continue seen[content_type] = taken + 1 body = row.body if isinstance(row.body, dict) else {} - contents.append({ + out.append({ "content_type": content_type, - "source": row.source, + "source": source if source is not None else row.source, "title": row.title, "body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP}, "collected_at": _iso(row.collected_at), }) - return {"region_code": region_code, "contents": contents} + return out def _iso(value) -> str | None: diff --git a/solution/frontend/public/robots.txt b/solution/frontend/public/robots.txt new file mode 100644 index 0000000..d7cd58c --- /dev/null +++ b/solution/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /admin/ \ No newline at end of file diff --git a/solution/frontend/public/sitemap.xml b/solution/frontend/public/sitemap.xml new file mode 100644 index 0000000..120fbd0 --- /dev/null +++ b/solution/frontend/public/sitemap.xml @@ -0,0 +1,10 @@ + + + + + https://web4ai.o2osolution.ai/ + 2026-08-12T00:00:00+09:00 + 1.0 + + + \ No newline at end of file diff --git a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx new file mode 100644 index 0000000..9e75aaf --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx @@ -0,0 +1,205 @@ +import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'; +import {ArrowUpRight, ChevronLeft, ChevronRight} from 'lucide-react'; +import type {TemplateItem} from '@o2o/shared'; +import {cn} from '@/lib/utils'; +import type {GuideCard} from '../variants/local/types'; +import {walkMinutes} from '../variants/local/walking'; + +const naverSearch = (q: string) => + `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; + +/** + * 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기". + * + * ★ 사진이 없으면 회색 판에 이름을 크게 쓴다(스크린샷의 '군산복집' 카드). 자리를 비우거나 + * 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다. + * ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다. + */ +function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) { + const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined; + return ( + e.stopPropagation()} + className="group flex w-[240px] shrink-0 snap-start flex-col overflow-hidden rounded-lg border border-stone-200/80 bg-white shadow-2xs transition-shadow hover:shadow-md sm:w-[260px]" + > +
+ {card.imageUrl ? ( + {card.name} + ) : ( +
+ {card.name} +
+ )} + {minutes !== undefined && ( + + 도보 약 {minutes}분 + {card.distanceText && {card.distanceText}} + + )} +
+ +
+ {leading} +

+ {card.name} +

+ {card.description && ( +

{card.description}

+ )} + + 검색으로 열기 + + +
+
+ ); +} + +/** + * 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "1 / N". + * + * ★ 페이지 = 한 화면에 온전히 들어가는 카드 수. 미리보기 해상도(PC/태블릿/모바일)가 바뀌면 + * ResizeObserver 가 다시 센다 — 고정 4장으로 두면 모바일에서 점이 카드 수와 어긋난다. + * ★ 화살표는 컨테이너 폭만큼 넘긴다(한 페이지). 한 장씩 넘기면 24장에 화살표 23번이다. + */ +export function PlaceCarousel({ + cards, + colors, + renderLeading, +}: { + cards: GuideCard[]; + colors: TemplateItem['colors']; + /** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */ + renderLeading?: (card: GuideCard) => ReactNode; +}) { + const trackRef = useRef(null); + const [perPage, setPerPage] = useState(1); + const [page, setPage] = useState(0); + + const pages = Math.max(1, Math.ceil(cards.length / perPage)); + + // 카드 한 장 폭(gap 포함)으로 한 화면에 몇 장 들어가는지 잰다. + const measure = useCallback(() => { + const track = trackRef.current; + const first = track?.firstElementChild as HTMLElement | null; + if (!track || !first) return; + const gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0; + const step = first.offsetWidth + gap; + const fit = Math.max(1, Math.floor((track.clientWidth + gap) / step)); + setPerPage(fit); + setPage(Math.min(Math.round(track.scrollLeft / (step * fit)), Math.max(0, Math.ceil(cards.length / fit) - 1))); + }, [cards.length]); + + useEffect(() => { + measure(); + const track = trackRef.current; + if (!track) return; + const ro = new ResizeObserver(measure); + ro.observe(track); + return () => ro.disconnect(); + }, [measure]); + + // 필터가 바뀌어 카드가 줄면 첫 페이지로. + useEffect(() => { + trackRef.current?.scrollTo({left: 0}); + setPage(0); + }, [cards]); + + const scrollToPage = (next: number) => { + const track = trackRef.current; + if (!track) return; + const clamped = Math.max(0, Math.min(pages - 1, next)); + track.scrollTo({left: clamped * track.clientWidth, behavior: 'smooth'}); + setPage(clamped); + }; + + const onScroll = () => { + const track = trackRef.current; + if (!track || track.clientWidth === 0) return; + setPage(Math.max(0, Math.min(pages - 1, Math.round(track.scrollLeft / track.clientWidth)))); + }; + + const arrow = + 'flex size-9 cursor-pointer items-center justify-center rounded-full border transition-colors disabled:cursor-default disabled:opacity-30'; + + return ( +
+
+ + +
+ +
+ {cards.map((card) => ( + + ))} +
+ + {pages > 1 && ( +
+
+ {Array.from({length: pages}, (_, i) => ( +
+ + {page + 1} / {pages} + +
+ )} +
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx b/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx new file mode 100644 index 0000000..70da7ae --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx @@ -0,0 +1,58 @@ +import type {TemplateItem} from '@o2o/shared'; +import {cn} from '@/lib/utils'; +import { + WALK_FILTERS, + matchesWalkFilter, + type WalkFilterKey, +} from '../variants/local/walking'; + +/** + * 도보 시간 필터 탭 — "전체 24 · 걸어서 5분 이내 8 · 10분 이내 19 · 10분 이상 5". + * + * ★ 구간은 누적이다(walking.ts 주석). 그래서 숫자가 서로 더해져 전체가 되지 않는다 — + * "5분 이내" ⊂ "10분 이내". 배타 구간으로 바꾸면 라벨("이내")과 숫자가 어긋난다. + * ★ 상태는 부르는 쪽(카테고리 섹션)이 든다. 섹션마다 필터가 따로 움직여야 한다. + */ +export function WalkFilterTabs({ + distances, + value, + onChange, + colors, +}: { + /** 항목별 거리(m). 모르는 항목은 undefined — '전체'에만 센다. */ + distances: (number | undefined)[]; + value: WalkFilterKey; + onChange: (key: WalkFilterKey) => void; + colors: TemplateItem['colors']; +}) { + return ( +
+ {WALK_FILTERS.map(({key, label}) => { + const count = distances.filter((m) => matchesWalkFilter(key, m)).length; + const active = key === value; + return ( + + ); + })} +
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/primitives/index.ts b/solution/frontend/src/features/builder/canvas/primitives/index.ts index 60919b2..dcff9c8 100644 --- a/solution/frontend/src/features/builder/canvas/primitives/index.ts +++ b/solution/frontend/src/features/builder/canvas/primitives/index.ts @@ -12,6 +12,8 @@ export {EmptyStateNotice} from './EmptyStateNotice'; export {PhotoTile, type TilePhoto} from './PhotoTile'; export {Lightbox} from './Lightbox'; export {PlaceRow, ListCard} from './PlaceRow'; +export {PlaceCarousel} from './PlaceCarousel'; +export {WalkFilterTabs} from './WalkFilterTabs'; export {PriceRow, type PricedItem} from './PriceRow'; export {AddressCard} from './AddressCard'; export {FeatureCard} from './FeatureCard'; diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index 2fb4c78..9e29380 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -28,9 +28,7 @@ import {PhotosWithVideos} from './variants/photos/PhotosWithVideos'; import {MapDetailed} from './variants/map/MapDetailed'; import {MapCompact} from './variants/map/MapCompact'; -import {LocalFull} from './variants/local/LocalFull'; -import {LocalTabs} from './variants/local/LocalTabs'; -import {LocalCompact} from './variants/local/LocalCompact'; +import {LocalGuide} from './variants/local/LocalGuide'; import {WeatherSection} from './variants/weather/WeatherSection'; import {FaqAccordion} from './variants/faq/FaqAccordion'; @@ -208,29 +206,17 @@ export const SECTION_VARIANTS: Record = { }, ], + // ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는 + // resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다. local: [ { - id: 'local.full', - name: '전체', - description: '날씨 + 맛집 + 명소 + 축제를 전부 세로로.', + id: 'local.guide', + name: '가이드', + description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.', thumb: 'stack', - Component: LocalFull, + Component: LocalGuide, isDefault: true, }, - { - id: 'local.tabs', - name: '탭 전환', - description: '맛집/명소/축제를 탭으로. 화면 길이를 1/3 로 줄인다.', - thumb: 'accordion', - Component: LocalTabs, - }, - { - id: 'local.compact', - name: '요약', - description: '날씨 한 줄과 추천 6곳만. 주인공이 아닐 때.', - thumb: 'compact', - Component: LocalCompact, - }, ], weather: [ diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LiveClock.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LiveClock.tsx deleted file mode 100644 index 36449c2..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/local/LiveClock.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {useEffect, useState} from 'react'; - -/** "실시간 업데이트: 오후 4:41:56" 표시. 1초마다 갱신된다. */ -export function LiveClock() { - const [clock, setClock] = useState(''); - - useEffect(() => { - const update = () => { - const now = new Date(); - const period = now.getHours() >= 12 ? '오후' : '오전'; - const hours = now.getHours() % 12 || 12; - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - setClock(`${period} ${hours}:${minutes}:${seconds}`); - }; - update(); - const timer = setInterval(update, 1000); - return () => clearInterval(timer); - }, []); - - return ( - - - 실시간 업데이트: {clock} - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx new file mode 100644 index 0000000..573bdef --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx @@ -0,0 +1,61 @@ +/** + * 지역 가이드 · 카테고리 한 묶음 — 제목(아이콘) → 도보 시간 필터 → 카드 캐러셀. + * + * 맛집·명소·축제가 전부 이 모양이다. 필터 상태는 묶음마다 따로 든다 — + * 맛집을 "5분 이내"로 걸렀는데 명소까지 줄어들면 사장님이 명소가 사라진 줄 안다. + */ +import {useState, type ReactNode} from 'react'; +import type {LucideIcon} from 'lucide-react'; +import type {TemplateItem} from '@o2o/shared'; +import {EmptyStateNotice, PlaceCarousel, WalkFilterTabs} from '../../primitives'; +import type {GuideCard} from './types'; +import {matchesWalkFilter, type WalkFilterKey} from './walking'; + +export function LocalCategorySection({ + icon: Icon, + title, + cards, + emptyText, + colors, + renderLeading, +}: { + icon: LucideIcon; + title: string; + cards: GuideCard[]; + emptyText: string; + colors: TemplateItem['colors']; + renderLeading?: (card: GuideCard) => ReactNode; +}) { + const [filter, setFilter] = useState('all'); + const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters)); + + return ( +
+

+ + {title} +

+ + {cards.length === 0 ? ( + {emptyText} + ) : ( + <> + c.distanceMeters)} + value={filter} + onChange={setFilter} + colors={colors} + /> + {visible.length === 0 ? ( + 이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요. + ) : ( + + )} + + )} +
+ ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalCompact.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalCompact.tsx deleted file mode 100644 index e85d4b2..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalCompact.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 지역 가이드 · 요약 — 날씨 한 줄과 추천 6곳만. - * 지역 정보가 주인공이 아닌 사이트에서 "있긴 하다" 정도로 둘 때. - */ -import { - EmptyStateNotice, - ListCard, - PlaceRow, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; - -const naverSearch = (q: string) => - `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; - -export function LocalCompact(props: SectionRenderProps) { - const {section, isSelected, onSelect, location, template} = props; - - // 맛집·명소를 섞어 딱 6개만. 목록이 길어지면 요약이 아니게 된다. - // ★ 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약(SectionRenderProps)에 없다. - // 시연용 목록(제주 애월)으로 자리를 메우지 않는다 — 남의 동네 맛집이 사장님 사이트에 붙는다. - const picks: {name: string; meta: string; description: string; q: string}[] = []; - - return ( - - - - {picks.length === 0 ? ( - 주변 추천은 아직 준비 중입니다. - ) : ( - - {picks.map((p) => ( - - ))} - - )} - - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalFull.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalFull.tsx deleted file mode 100644 index 5b5e5ad..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalFull.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 지역 가이드 · 전체 — 날씨 카드 + 맛집 + 명소 + 월별 축제를 세로로 전부. - * "여기 오면 근처에 뭐가 있나"를 한 화면에서 다 보여주고 싶을 때. - */ -import {Calendar, MapPin, Sparkles, Utensils} from 'lucide-react'; -import type {TemplateItem} from '@o2o/shared'; -import { - EmptyStateNotice, - ListCard, - PlaceRow, - Pill, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {LiveClock} from './LiveClock'; -import type {FestivalItem, NearbyPlace} from './types'; - -const naverSearch = (q: string) => - `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; - -function GroupHeading({ - icon: Icon, - title, - note, - colors, -}: { - icon: typeof Utensils; - title: string; - note: string; - colors: TemplateItem['colors']; -}) { - return ( -
-

- - {title} -

- {/* 오른쪽 출처 문구는 부가 정보라 중립 회색 그대로 둔다. */} - {note} -
- ); -} - -export function LocalFull(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, location, template} = props; - const isStay = industryId === 'stay'; - const locName = location.split(' ')[0] || '주변'; - // ★ 맛집·명소·축제는 전부 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다. - // (지역 큐레이션을 읽어올 백엔드 창구가 아직 없어 당분간 계속 빈다.) - // 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다. - const foods: NearbyPlace[] = []; - const spots: NearbyPlace[] = []; - const festivals: FestivalItem[] = []; - - return ( - - - } - colors={template.colors} - /> - -
- - {foods.length === 0 ? ( - 주변 맛집·카페 추천은 아직 준비 중입니다. - ) : ( - - {foods.map((place) => ( - - ))} - - )} -
- -
- - {spots.length === 0 ? ( - 주변 명소 추천은 아직 준비 중입니다. - ) : ( - - {spots.map((spot) => ( - - ))} - - )} -
- - {isStay && ( -
- - {festivals.length === 0 ? ( - 월별 축제 안내는 아직 준비 중입니다. - ) : ( - - {festivals.map((fest) => ( - - {fest.month} - {fest.isFeatured && ( - - - 대표축제 - - )} - - } - colors={template.colors} - /> - ))} - - )} -
- )} -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx new file mode 100644 index 0000000..25a6fb5 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx @@ -0,0 +1,88 @@ +/** + * 지역 가이드 — 맛집 · 명소 · 축제를 카테고리별 "도보 시간 필터 + 카드 캐러셀"로 세로 나열. + * + * ★ 2026-09-07 결정으로 전체/탭/요약 세 배리에이션을 이 하나로 통일했다. + * 데이터가 이미 카테고리별로 나뉘어 있어 "탭으로 갈아끼우기"라는 구분이 캐러셀 구조에서는 + * 의미가 없어졌고, 요약(6곳)은 필터가 그 역할을 대신한다. + * ★ 값은 서버(local.place_contents)가 소유한다 — 업장 좌표 반경으로 받은 것만 그린다. + * 시연용 목록으로 자리를 메우지 않는다(남의 동네 맛집이 사장님 사이트에 붙는다). + */ +import {Calendar, MapPin, Utensils} from 'lucide-react'; +import {useLocalGuide} from '@/hooks/useLocalGuide'; +import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import type {FestivalCard, GuideCard} from './types'; +import {LocalCategorySection} from './LocalCategorySection'; +import {WALK_DISCLAIMER} from './walking'; + +/** "2026년 9월 3일 갱신" — 서버 수집 시각(ISO)에서. 없으면 비운다(지어내지 않는다). */ +function syncedLabel(iso?: string): string | undefined { + if (!iso) return undefined; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return undefined; + return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 갱신`; +} + +/** "전북특별자치도 군산시 …" → "군산시". 시군구 토큰이 없으면 첫 토큰. */ +function regionLabel(location: string): string { + const tokens = location.split(' ').filter(Boolean); + return tokens[1] ?? tokens[0] ?? '주변'; +} + +export function LocalGuide(props: SectionRenderProps) { + const {section, isSelected, onSelect, industryId, location, template} = props; + const {foods, spots, festivals, syncedAt} = useLocalGuide(); + const colors = template.colors; + const isStay = industryId === 'stay'; + const synced = syncedLabel(syncedAt); + + return ( + + + {synced}
} + colors={colors} + /> + + + + + + {isStay && ( + { + const fest = card as FestivalCard; + return ( +
+ {fest.month && {fest.month}} + {fest.period && {fest.period}} +
+ ); + }} + /> + )} + +

{WALK_DISCLAIMER}

+ + + ); +} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalTabs.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalTabs.tsx deleted file mode 100644 index 2312369..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalTabs.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 지역 가이드 · 탭 — 맛집/명소/축제를 탭으로 갈아 끼운다. - * 목록이 길어 스크롤이 부담스러울 때 화면 길이를 1/3 로 줄인다. - */ -import {useState} from 'react'; -import {Calendar, MapPin, Utensils} from 'lucide-react'; -import {cn} from '@/lib/utils'; -import { - EmptyStateNotice, - ListCard, - Pill, - PlaceRow, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import type {FestivalItem, NearbyPlace} from './types'; - -const naverSearch = (q: string) => - `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; - -type TabKey = 'food' | 'spots' | 'festivals'; - -const TABS: {key: TabKey; label: string; icon: typeof Utensils}[] = [ - {key: 'food', label: '맛집 · 카페', icon: Utensils}, - {key: 'spots', label: '명소', icon: MapPin}, - {key: 'festivals', label: '축제', icon: Calendar}, -]; - -export function LocalTabs(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, location, template} = props; - const [tab, setTab] = useState('food'); - const isStay = industryId === 'stay'; - const tabs = isStay ? TABS : TABS.filter((t) => t.key !== 'festivals'); - const locName = location.split(' ')[0] || '주변'; - // ★ 세 탭 모두 제주 애월 시연용 목록이다 — 실사업장은 빈 목록으로 떨어진다. - // 지역 정보는 서버(local.local_contents)가 소유하고 캔버스 계약에 없다 — 시연용 목록을 깔지 않는다. - const foods: NearbyPlace[] = []; - const spots: NearbyPlace[] = []; - const festivals: FestivalItem[] = []; - // 탭을 눌렀는데 아무것도 없으면 섹션이 사라진 것처럼 보인다 — 자리는 지키고 상태만 알린다. - const isEmpty = (tab === 'food' ? foods : tab === 'spots' ? spots : festivals).length === 0; - - return ( - - - - -
- {tabs.map(({key, label, icon: Icon}) => ( - - ))} -
- - {isEmpty ? ( - 주변 정보는 아직 준비 중입니다. - ) : ( - - {tab === 'food' && - foods.map((place) => ( - - ))} - - {tab === 'spots' && - spots.map((spot) => ( - - ))} - - {tab === 'festivals' && - festivals.map((fest) => ( - {fest.month}} - colors={template.colors} - /> - ))} - - )} -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/types.ts b/solution/frontend/src/features/builder/canvas/variants/local/types.ts index f4e2dad..032f1ee 100644 --- a/solution/frontend/src/features/builder/canvas/variants/local/types.ts +++ b/solution/frontend/src/features/builder/canvas/variants/local/types.ts @@ -1,32 +1,26 @@ /** - * 지역 정보 항목의 모양. + * 지역 가이드 카드 한 장의 모양(맛집·명소·축제 공통). * - * ★ 원래 이 타입들은 시연용 데이터 파일(data/stayData.ts)이 소유했다. 그 파일을 지우면서 - * 실제로 쓰는 쪽인 여기로 옮겼다 — 타입이 데이터 시드에 매여 있을 이유가 없다. - * - * ★ 값은 서버(local.local_contents)가 소유한다. 지금은 캔버스 계약(SectionRenderProps)에 - * 지역 정보가 없어서 배리에이션들이 빈 목록을 그린다. 흘려보낼 창구가 생기면 - * 이 모양에 맞춰 매핑하면 된다. + * ★ 값은 서버(local.place_contents)가 소유한다. hooks/useLocalGuide 가 GET /v1/local/guide + * 응답(발행 payload 와 같은 모양)을 이 타입으로 매핑한다. + * ★ 도보 시간 배지·필터는 distanceMeters 로 계산한다(walking.ts::walkMinutes, 분속 80m). + * distanceMeters 가 없으면(지역 캐시에서 온 수기 항목 등) 배지·필터 대상에서 빠진다 — + * 업장 기준 거리를 모르는 값으로 "도보 N분"을 지어내지 않는다. */ -export interface NearbyPlace { +export interface GuideCard { name: string; - category: string; - /** 거리 표기("차로 5분", "1.2km") — 원문 그대로 옮긴다. */ - distance: string; - duration: string; description: string; /** 상세 페이지 대신 검색으로 보낸다 — 없는 주소를 지어내지 않기 위해서다. */ searchQuery: string; - tag: string; + imageUrl?: string; + distanceMeters?: number; + /** 화면 배지에 그대로 쓰는 거리 문자열("850m"/"1.2km"). */ + distanceText?: string; } -export interface FestivalItem { +/** 축제는 기간·월 배지가 더 붙는다. */ +export interface FestivalCard extends GuideCard { month: string; - name: string; period: string; - location: string; - description: string; - isFeatured?: boolean; officialUrl?: string; - searchQuery: string; } diff --git a/solution/frontend/src/features/builder/canvas/variants/local/walking.ts b/solution/frontend/src/features/builder/canvas/variants/local/walking.ts new file mode 100644 index 0000000..5d9a508 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/local/walking.ts @@ -0,0 +1,36 @@ +/** + * 도보 시간 환산 — 직선거리(m) → 분. + * + * ★ 분속 80m 로 나눈다(보통 걸음 4.8km/h). 스크린샷 문구와 같은 기준이며 화면 하단에도 그대로 적는다: + * "도보 시간은 숙소에서 잰 직선거리를 분속 80m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다." + * ★ 실측 검증(2026-09-07): 135m→2분 · 297m→4분 · 305m→4분 · 566m→7분 · 13m→1분 — 반올림 + 최소 1분. + */ +export const WALK_METERS_PER_MINUTE = 80; + +export const WALK_DISCLAIMER = + `도보 시간은 숙소에서 잰 직선거리를 분속 ${WALK_METERS_PER_MINUTE}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다.`; + +export function walkMinutes(meters: number): number { + return Math.max(1, Math.round(meters / WALK_METERS_PER_MINUTE)); +} + +/** + * 도보 시간 필터. ★ 구간은 배타적이 아니라 **누적**이다 — "10분 이내"는 "5분 이내"를 포함한다. + * (스크린샷 수치 8·19·5 → 19+5=24=전체 로 확인. "5분 이내"는 "10분 이내"의 부분집합.) + */ +export type WalkFilterKey = 'all' | 'within5' | 'within10' | 'over10'; + +export const WALK_FILTERS: {key: WalkFilterKey; label: string; test: (minutes: number) => boolean}[] = [ + {key: 'all', label: '전체', test: () => true}, + {key: 'within5', label: '걸어서 5분 이내', test: (m) => m <= 5}, + {key: 'within10', label: '걸어서 10분 이내', test: (m) => m <= 10}, + {key: 'over10', label: '걸어서 10분 이상', test: (m) => m > 10}, +]; + +/** 거리를 모르는 항목은 '전체'에만 들어간다 — 모르는 값으로 구간을 정하지 않는다. */ +export function matchesWalkFilter(key: WalkFilterKey, meters: number | undefined): boolean { + if (key === 'all') return true; + if (meters === undefined) return false; + const filter = WALK_FILTERS.find((f) => f.key === key); + return filter ? filter.test(walkMinutes(meters)) : true; +} diff --git a/solution/frontend/src/features/dev/tokenGroups.ts b/solution/frontend/src/features/dev/tokenGroups.ts index eb1ad62..712fbff 100644 --- a/solution/frontend/src/features/dev/tokenGroups.ts +++ b/solution/frontend/src/features/dev/tokenGroups.ts @@ -315,9 +315,7 @@ export const TOKEN_AWARE_VARIANT_IDS = new Set([ 'photos.with-videos', 'map.detailed', 'map.compact', - 'local.full', - 'local.tabs', - 'local.compact', + 'local.guide', 'faq.accordion', 'faq.open-list', 'faq.two-column', diff --git a/solution/frontend/src/hooks/useLocalGuide.ts b/solution/frontend/src/hooks/useLocalGuide.ts new file mode 100644 index 0000000..eacd6fe --- /dev/null +++ b/solution/frontend/src/hooks/useLocalGuide.ts @@ -0,0 +1,113 @@ +import {useEffect, useState} from 'react'; +import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types'; +import {useBuilderStore} from '@/stores/builder'; + +export interface LocalGuideData { + foods: GuideCard[]; + spots: GuideCard[]; + festivals: FestivalCard[]; + /** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */ + syncedAt?: string; +} + +const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []}; +// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다. +const MAX_PER_TYPE = 20; + +/** 서버 응답 항목 — 발행 payload 의 LocalPlace / FestivalEntry 와 같은 모양(백엔드 ResLocalGuide 주석). */ +interface GuidePlace { + name: string; + category: string; + searchQuery: string; + distanceText?: string; + distanceMeters?: number; + imageUrl?: string; + location?: string; + description?: string; +} +interface GuideFestival extends GuidePlace { + month: string; + period?: string; + officialUrl?: string; +} + +function toCard(p: GuidePlace): GuideCard { + return { + name: p.name, + // 설명이 없으면 주소로 대신한다 — 카드에 이름만 덜렁 있는 것보다 어디인지가 낫다. + description: p.description ?? p.location ?? '', + searchQuery: p.searchQuery, + imageUrl: p.imageUrl, + distanceMeters: typeof p.distanceMeters === 'number' ? p.distanceMeters : undefined, + distanceText: p.distanceText, + }; +} + +function toFestival(f: GuideFestival): FestivalCard { + return { + ...toCard(f), + month: f.month, + period: f.period ?? '', + officialUrl: f.officialUrl, + }; +} + +// 사업장 하나당 한 번만 받는다 — 필터·캐러셀 조작마다 API 를 다시 때리지 않는다. +const cache = new Map(); + +/** + * 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 — + * 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다. + * + * ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다. + * 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다. + */ +export function useLocalGuide(): LocalGuideData { + const placeId = useBuilderStore((s) => s.placeId); + const [guide, setGuide] = useState(() => (placeId && cache.get(placeId)) || EMPTY); + + useEffect(() => { + if (!placeId) { + setGuide(EMPTY); + return; + } + const cached = cache.get(placeId); + if (cached) { + setGuide(cached); + return; + } + let alive = true; + const controller = new AbortController(); + + async function fetchGuide() { + try { + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'; + const query = new URLSearchParams({place_id: placeId!}); + const res = await fetch(`${baseUrl}/v1/local/guide?${query}`, {signal: controller.signal}); + if (!res.ok) return; + const data = await res.json(); + if (!data?.result?.success || !alive) return; + const next: LocalGuideData = { + foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), + spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), + festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival), + syncedAt: data.synced_at ?? undefined, + }; + // ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면 + // 수집이 끝나도 새로고침 전까지 계속 "준비 중"으로 보인다. + if (next.foods.length || next.spots.length || next.festivals.length) cache.set(placeId!, next); + setGuide(next); + } catch { + // 빈 목록 유지 — 화면에 에러를 띄우지 않는다. + } + } + + void fetchGuide(); + return () => { + alive = false; + controller.abort(); + }; + }, [placeId]); + + return guide; +} diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index ccd217c..e1331a7 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -189,6 +189,9 @@ export interface LocalPlace { description?: string; /** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */ searchQuery: string; + imageUrl?: string; + /** 업장 좌표 기준 거리(m). 도보 시간 필터 계산용 원값 — distanceText 는 이걸 사람이 읽게 바꾼 것. */ + distanceMeters?: number; } export interface FestivalEntry { @@ -199,6 +202,10 @@ export interface FestivalEntry { description?: string; officialUrl?: string; searchQuery: string; + /** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */ + distanceText?: string; + distanceMeters?: number; + imageUrl?: string; } export interface RouteEntry {