[feat] solution,admin,postgres-init: 주변정보를 업장 좌표 기준으로 — 시군구 캐시는 "가까운 곳"을 못 준다
지역 정보(맛집·관광지·축제)는 행정구역 코드(local_contents.region_code) 단위로 캐시돼 "그 시군구에 있는 것"을 줬다. 양양군 업장 옆 5km 속초 관광지는 빠지고 같은 군 반대편 30km 맛집이 붙는 구조라, 캔버스의 지역 정보 섹션은 늘 "준비 중"이었다. 업장 좌표로 TourAPI 를 직접 물어 업장 단위(place_contents)에 담고, 캔버스는 스크린샷으로 받은 형식(도보 시간 필터 + 카드 캐러셀)으로 통일했다.
This commit is contained in:
parent
238d4c25c4
commit
01824a7a91
@ -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<Status, string> = {1: '검수 대기', 2: '발행됨', 3: '종료됨'};
|
||||
const TYPE_LABEL: Record<ContentType, string> = {2: '축제', 3: '관광지', 4: '맛집'};
|
||||
const TYPE_BADGE_VARIANT: Record<ContentType, 'accent' | 'default' | 'outline'> = {
|
||||
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<string, unknown>;
|
||||
@ -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 (
|
||||
<PageContainer
|
||||
title="지역 콘텐츠"
|
||||
description="공공데이터에서 지역 축제를 가져와 검수한 뒤 사장님에게 발행합니다."
|
||||
description="한국관광공사 TourAPI 에서 지역 축제·관광지·맛집을 가져와 자동 발행합니다. 필요하면 여기서 수정하거나 발행을 종료할 수 있습니다."
|
||||
actions={<Button onClick={sync} disabled={syncing}><Download className="size-4" />{syncing ? '수집 중…' : '공공데이터 수집'}</Button>}
|
||||
>
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-3">
|
||||
@ -174,6 +195,7 @@ export function LocalContentPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">{item.title}</h2>
|
||||
<Badge variant={TYPE_BADGE_VARIANT[item.contentType]}>{TYPE_LABEL[item.contentType]}</Badge>
|
||||
<Badge variant={item.status === 2 ? 'success' : 'warning'}>{STATUS_LABEL[item.status]}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
|
||||
@ -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 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다.
|
||||
|
||||
@ -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": "원" },
|
||||
|
||||
@ -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)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다."""
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
24
solution/backend/common/utils/geo.py
Normal file
24
solution/backend/common/utils/geo.py
Normal file
@ -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
|
||||
@ -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,
|
||||
|
||||
63
solution/backend/crud/place_content_crud.py
Normal file
63
solution/backend/crud/place_content_crud.py
Normal file
@ -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()),
|
||||
)
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
25
solution/backend/services/external/kakao.py
vendored
25
solution/backend/services/external/kakao.py
vendored
@ -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,
|
||||
|
||||
308
solution/backend/services/external/tour_api.py
vendored
308
solution/backend/services/external/tour_api.py
vendored
@ -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
|
||||
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]
|
||||
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
|
||||
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)
|
||||
|
||||
120
solution/backend/services/itinerary.py
Normal file
120
solution/backend/services/itinerary.py
Normal file
@ -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
|
||||
@ -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,34 +146,207 @@ 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
|
||||
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),
|
||||
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 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 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
|
||||
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 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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,10 +197,12 @@ 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)
|
||||
contents: list[dict] = []
|
||||
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
|
||||
|
||||
# ── 지역 캐시(local_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
|
||||
if region_code:
|
||||
query = (
|
||||
select(local_contents)
|
||||
.where(
|
||||
@ -212,28 +219,55 @@ async def _local_contents(place) -> dict:
|
||||
)
|
||||
if err != ErrorType.SUCCESS:
|
||||
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
|
||||
# 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다.
|
||||
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
|
||||
return {"region_code": region_code, "contents": []}
|
||||
rows = []
|
||||
contents += _local_rows(rows or [], seen)
|
||||
|
||||
seen: dict[int, int] = {}
|
||||
contents = []
|
||||
for row in rows or []:
|
||||
# ── 업장 반경 캐시(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:
|
||||
|
||||
2
solution/frontend/public/robots.txt
Normal file
2
solution/frontend/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
10
solution/frontend/public/sitemap.xml
Normal file
10
solution/frontend/public/sitemap.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
|
||||
<url>
|
||||
<loc>https://web4ai.o2osolution.ai/</loc>
|
||||
<lastmod>2026-08-12T00:00:00+09:00</lastmod>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
|
||||
</urlset>
|
||||
@ -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 (
|
||||
<a
|
||||
href={naverSearch(card.searchQuery)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => 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]"
|
||||
>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden bg-stone-200/70">
|
||||
{card.imageUrl ? (
|
||||
<img
|
||||
src={card.imageUrl}
|
||||
alt={card.name}
|
||||
loading="lazy"
|
||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center px-4 text-center">
|
||||
<span className="serif-title text-base font-bold text-stone-700">{card.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{minutes !== undefined && (
|
||||
<span className="absolute bottom-2.5 left-2.5 inline-flex items-center gap-1.5 rounded bg-stone-900/80 px-2 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
|
||||
<span>도보 약 {minutes}분</span>
|
||||
{card.distanceText && <span className="font-mono font-normal text-white/75">{card.distanceText}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3.5">
|
||||
{leading}
|
||||
<h4 className="text-sm font-bold text-stone-900" style={{color: colors.text}}>
|
||||
{card.name}
|
||||
</h4>
|
||||
{card.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-stone-500">{card.description}</p>
|
||||
)}
|
||||
<span className="mt-auto flex items-center gap-0.5 pt-1 text-[11px] font-medium text-stone-400 group-hover:text-stone-900">
|
||||
검색으로 열기
|
||||
<ArrowUpRight className="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "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<HTMLDivElement>(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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전"
|
||||
disabled={page === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
scrollToPage(page - 1);
|
||||
}}
|
||||
className={cn(arrow, 'border-stone-300/70 bg-white/70 text-stone-500 hover:bg-white')}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음"
|
||||
disabled={page >= pages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
scrollToPage(page + 1);
|
||||
}}
|
||||
style={{borderColor: colors.text, color: colors.text}}
|
||||
className={cn(arrow, 'bg-white hover:bg-stone-50')}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
onScroll={onScroll}
|
||||
className="scrollbar-none flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1"
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<GuideCardView
|
||||
key={`${card.name}-${card.distanceMeters ?? ''}`}
|
||||
card={card}
|
||||
colors={colors}
|
||||
leading={renderLeading?.(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({length: pages}, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label={`${i + 1}페이지`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
scrollToPage(i);
|
||||
}}
|
||||
className={cn(
|
||||
'h-1.5 cursor-pointer rounded-full transition-all',
|
||||
i === page ? 'w-5' : 'w-1.5 bg-stone-300 hover:bg-stone-400',
|
||||
)}
|
||||
style={i === page ? {backgroundColor: colors.text} : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-stone-400">
|
||||
{page + 1} / {pages}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="도보 시간 필터">
|
||||
{WALK_FILTERS.map(({key, label}) => {
|
||||
const count = distances.filter((m) => matchesWalkFilter(key, m)).length;
|
||||
const active = key === value;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(key);
|
||||
}}
|
||||
style={active ? {backgroundColor: colors.text, borderColor: colors.text} : undefined}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors',
|
||||
active
|
||||
? 'text-white'
|
||||
: 'border-stone-300/80 bg-white/70 text-stone-700 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className={cn('font-mono text-[11px]', active ? 'text-white/80' : 'text-stone-400')}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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<string, SectionVariant[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
// ★ 전체/탭/요약 세 개를 하나로 통일했다(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: [
|
||||
|
||||
@ -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 (
|
||||
<span className="flex items-center gap-1.5 rounded-full border border-stone-200/80 bg-white px-2.5 py-1 text-[11px] text-stone-500 shadow-2xs">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
<span>실시간 업데이트: {clock}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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<WalkFilterKey>('all');
|
||||
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h3
|
||||
className="flex items-center gap-2 text-sm font-bold text-stone-900"
|
||||
style={{color: colors.text}}
|
||||
>
|
||||
<Icon className="size-4" style={{color: colors.primary}} />
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<EmptyStateNotice>{emptyText}</EmptyStateNotice>
|
||||
) : (
|
||||
<>
|
||||
<WalkFilterTabs
|
||||
distances={cards.map((c) => c.distanceMeters)}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
colors={colors}
|
||||
/>
|
||||
{visible.length === 0 ? (
|
||||
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
|
||||
) : (
|
||||
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||||
<SectionBody width="narrow">
|
||||
<SectionHeading variant="minimal" title="주변 안내" colors={template.colors} />
|
||||
{picks.length === 0 ? (
|
||||
<EmptyStateNotice>주변 추천은 아직 준비 중입니다.</EmptyStateNotice>
|
||||
) : (
|
||||
<ListCard>
|
||||
{picks.map((p) => (
|
||||
<PlaceRow
|
||||
key={p.name}
|
||||
name={p.name}
|
||||
meta={p.meta}
|
||||
description={p.description}
|
||||
href={naverSearch(p.q)}
|
||||
actionLabel="보기"
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
</ListCard>
|
||||
)}
|
||||
</SectionBody>
|
||||
</SectionFrame>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
className="serif-title flex items-center gap-2 text-base font-bold text-stone-900 sm:text-lg"
|
||||
style={{color: colors.text}}
|
||||
>
|
||||
<Icon className="size-4 text-stone-600" style={{color: colors.primary}} />
|
||||
<span>{title}</span>
|
||||
</h3>
|
||||
{/* 오른쪽 출처 문구는 부가 정보라 중립 회색 그대로 둔다. */}
|
||||
<span className="text-[11px] text-stone-400">{note}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||||
<SectionBody className="space-y-8">
|
||||
<SectionHeading
|
||||
variant="eyebrow"
|
||||
eyebrow="AI Local Guide"
|
||||
title={`AI ${locName} 실시간 가이드`}
|
||||
subtitle="실시간 날씨와 인근 미식·명소·축제를 AI가 큐레이션합니다"
|
||||
trailing={<LiveClock />}
|
||||
colors={template.colors}
|
||||
/>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<GroupHeading
|
||||
icon={Utensils}
|
||||
title="숙소 인근 엄선 맛집 & 카페"
|
||||
note="호스트 & 네이버 플레이스 연동"
|
||||
colors={template.colors}
|
||||
/>
|
||||
{foods.length === 0 ? (
|
||||
<EmptyStateNotice>주변 맛집·카페 추천은 아직 준비 중입니다.</EmptyStateNotice>
|
||||
) : (
|
||||
<ListCard>
|
||||
{foods.map((place) => (
|
||||
<PlaceRow
|
||||
key={place.name}
|
||||
name={place.name}
|
||||
meta={place.distance}
|
||||
description={place.description}
|
||||
href={naverSearch(place.searchQuery)}
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
</ListCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<GroupHeading
|
||||
icon={MapPin}
|
||||
title="주변 힐링 명소 & 해변"
|
||||
note="차량 5~20분 거리"
|
||||
colors={template.colors}
|
||||
/>
|
||||
{spots.length === 0 ? (
|
||||
<EmptyStateNotice>주변 명소 추천은 아직 준비 중입니다.</EmptyStateNotice>
|
||||
) : (
|
||||
<ListCard>
|
||||
{spots.map((spot) => (
|
||||
<PlaceRow
|
||||
key={spot.name}
|
||||
name={spot.name}
|
||||
meta={spot.duration}
|
||||
description={spot.description}
|
||||
href={naverSearch(spot.searchQuery)}
|
||||
actionLabel="상세정보"
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
</ListCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStay && (
|
||||
<div className="space-y-3.5">
|
||||
<GroupHeading
|
||||
icon={Calendar}
|
||||
title="월별 축제 & 문화 행사"
|
||||
note="사계절 캘린더"
|
||||
colors={template.colors}
|
||||
/>
|
||||
{festivals.length === 0 ? (
|
||||
<EmptyStateNotice>월별 축제 안내는 아직 준비 중입니다.</EmptyStateNotice>
|
||||
) : (
|
||||
<ListCard>
|
||||
{festivals.map((fest) => (
|
||||
<PlaceRow
|
||||
key={fest.name}
|
||||
name={fest.name}
|
||||
description={fest.description || fest.period}
|
||||
href={naverSearch(fest.searchQuery)}
|
||||
actionLabel="검색"
|
||||
leading={
|
||||
<>
|
||||
<Pill tone="accent">{fest.month}</Pill>
|
||||
{fest.isFeatured && (
|
||||
<Pill tone="good">
|
||||
<Sparkles className="size-2.5" />
|
||||
대표축제
|
||||
</Pill>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
</ListCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBody>
|
||||
</SectionFrame>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||||
<SectionBody width="wide" className="space-y-10">
|
||||
<SectionHeading
|
||||
title={section.name || '주변 안내'}
|
||||
subtitle={`${regionLabel(location)} 지역의 맛집 · 명소 안내입니다.`}
|
||||
trailing={synced && <span className="text-xs text-stone-400">{synced}</span>}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
<LocalCategorySection
|
||||
icon={Utensils}
|
||||
title="주변 맛집"
|
||||
cards={foods}
|
||||
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
<LocalCategorySection
|
||||
icon={MapPin}
|
||||
title="주변 명소"
|
||||
cards={spots}
|
||||
emptyText="주변 명소 추천은 아직 준비 중입니다."
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
{isStay && (
|
||||
<LocalCategorySection
|
||||
icon={Calendar}
|
||||
title="주변 축제 & 문화 행사"
|
||||
cards={festivals}
|
||||
emptyText="주변 축제 안내는 아직 준비 중입니다."
|
||||
colors={colors}
|
||||
renderLeading={(card: GuideCard) => {
|
||||
const fest = card as FestivalCard;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{fest.month && <Pill tone="accent">{fest.month}</Pill>}
|
||||
{fest.period && <Pill mono>{fest.period}</Pill>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-stone-400">{WALK_DISCLAIMER}</p>
|
||||
</SectionBody>
|
||||
</SectionFrame>
|
||||
);
|
||||
}
|
||||
@ -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<TabKey>('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 (
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||||
<SectionBody>
|
||||
<SectionHeading
|
||||
variant="eyebrow"
|
||||
eyebrow="AI Local Guide"
|
||||
title={`AI ${locName} 실시간 가이드`}
|
||||
subtitle="탭을 눌러 원하는 정보만 보세요"
|
||||
colors={template.colors}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-xl border border-stone-200/80 bg-white p-1">
|
||||
{tabs.map(({key, label, icon: Icon}) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setTab(key);
|
||||
}}
|
||||
aria-pressed={tab === key}
|
||||
style={tab === key ? {backgroundColor: template.colors.primary} : undefined}
|
||||
className={cn(
|
||||
'flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors',
|
||||
tab === key ? 'text-white' : 'text-stone-500 hover:bg-stone-100',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyStateNotice>주변 정보는 아직 준비 중입니다.</EmptyStateNotice>
|
||||
) : (
|
||||
<ListCard>
|
||||
{tab === 'food' &&
|
||||
foods.map((place) => (
|
||||
<PlaceRow
|
||||
key={place.name}
|
||||
name={place.name}
|
||||
meta={place.distance}
|
||||
description={place.description}
|
||||
href={naverSearch(place.searchQuery)}
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tab === 'spots' &&
|
||||
spots.map((spot) => (
|
||||
<PlaceRow
|
||||
key={spot.name}
|
||||
name={spot.name}
|
||||
meta={spot.duration}
|
||||
description={spot.description}
|
||||
href={naverSearch(spot.searchQuery)}
|
||||
actionLabel="상세정보"
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tab === 'festivals' &&
|
||||
festivals.map((fest) => (
|
||||
<PlaceRow
|
||||
key={fest.name}
|
||||
name={fest.name}
|
||||
description={fest.description || fest.period}
|
||||
href={naverSearch(fest.searchQuery)}
|
||||
actionLabel="검색"
|
||||
leading={<Pill tone="accent">{fest.month}</Pill>}
|
||||
colors={template.colors}
|
||||
/>
|
||||
))}
|
||||
</ListCard>
|
||||
)}
|
||||
</SectionBody>
|
||||
</SectionFrame>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
113
solution/frontend/src/hooks/useLocalGuide.ts
Normal file
113
solution/frontend/src/hooks/useLocalGuide.ts
Normal file
@ -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<string, LocalGuideData>();
|
||||
|
||||
/**
|
||||
* 업장 주변 가이드(맛집·명소·축제). 날씨(useWeather)와 같은 규약이다 —
|
||||
* 실패해도 에러를 띄우지 않고 빈 목록을 유지한다. 부가 정보라 사장님 작업을 막을 이유가 없다.
|
||||
*
|
||||
* ★ 값은 서버(local.place_contents)가 소유하고, 발행본과 같은 필터·모양으로 온다.
|
||||
* 캔버스가 보여주는 목록 = 발행 사이트에 나갈 목록. 첫 조회 때 서버가 채우므로 비어 있는 건 잠깐이다.
|
||||
*/
|
||||
export function useLocalGuide(): LocalGuideData {
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const [guide, setGuide] = useState<LocalGuideData>(() => (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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user