Compare commits

...

2 Commits

Author SHA1 Message Date
3b43446fe6 Merge branch 'main' into feat-tourApi 2026-09-08 13:09:31 +09:00
01824a7a91 [feat] solution,admin,postgres-init: 주변정보를 업장 좌표 기준으로 — 시군구 캐시는 "가까운 곳"을 못 준다
지역 정보(맛집·관광지·축제)는 행정구역 코드(local_contents.region_code) 단위로 캐시돼
"그 시군구에 있는 것"을 줬다. 양양군 업장 옆 5km 속초 관광지는 빠지고 같은 군 반대편
30km 맛집이 붙는 구조라, 캔버스의 지역 정보 섹션은 늘 "준비 중"이었다. 업장 좌표로
TourAPI 를 직접 물어 업장 단위(place_contents)에 담고, 캔버스는 스크린샷으로 받은
형식(도보 시간 필터 + 카드 캐러셀)으로 통일했다.
2026-09-08 11:46:03 +09:00
38 changed files with 1850 additions and 577 deletions

View File

@ -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">

View File

@ -84,6 +84,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, -- ★ 노출값이 마지막으로 바뀐 시각 — 개별 재빌드 대상 판별용
@ -211,6 +212,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)
@ -379,6 +399,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 가 있는 항목(축제·관광지·맛집)과 없는 항목(날씨)을 나눠 건다.

View File

@ -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": "원" },

View File

@ -99,6 +99,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)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
@ -315,6 +319,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)를 그대로 쓴다 — 틀린 경로 안내도 헛걸음을 만든다."""

View File

@ -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):

View 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

View File

@ -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,

View 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()),
)

View File

@ -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))

View File

@ -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):

View File

@ -40,6 +40,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):

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -1,17 +1,58 @@
"""공공데이터포털 전국문화축제표준데이터 클라이언트.
"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트.
설정 이름은 기존 배포 계약을 유지하기 위해 TOUR_API_KEY를 그대로 사용한다.
collector/tour_api_adapter.py '사업장 1곳' fact 캐는 쪽이라면, 여기는
업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스) 긁는 쪽이다.
결과는 place_contents(업장 단위 캐시) 들어가 발행본·캔버스의 지역 정보 섹션이 된다.
행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md)
처음엔 법정동 코드로 areaBasedList2 불렀다. 그건 '그 시군구에 있는 것'이지
'이 업장에서 가까운 것' 아니다 양양군 업장 5km 속초 관광지가 빠지고,
같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 좌표+반경으로 묻고
거리(dist)까지 준다. 실측(군산 절골길 18): 10km 133, 5km 100.
호출 업장당 종류별 1 (맛집·관광지·축제)
2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음)
걸음에 맛집과 타고 축제를 같은 반경으로 재는 맞지 않았다.
여행코스(25) 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3) 뺐다.
축제는 locationBasedList2 아니라 searchFestival2 쓴다 (2026-09-08 교체)
locationBasedList2(contentTypeId=15) 위치 색인은 믿는다 실측(군산 절골길 18):
반경 20km 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m
진행 예정 축제(군산시간여행축제 ) 끝내 잡혔다. searchFestival2 법정동(시도)
단위로 묻지만 정확하고 기간까지 함께 준다 그래서 시도 전체를 받아 우리가 거리로 거른다.
eventStartDate 파라미터로 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
진행 중인 행사는 잡히지 않는다 실측). 그래서 항상 ** 1 1** 고정해 부르고,
이미 끝난 행사(eventenddate < 오늘) 우리가 거른다.
이미지 저작권 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 같은 규칙)
firstimage 공공누리 Type1(출처표시)·Type3(출처표시+변경금지) 남긴다.
발행본은 상업적 이용이라 Type2·Type4 싣지 못한다. 유형을 모르면 버린다.
"""
import hashlib
from datetime import date
from urllib.parse import unquote
from typing import Optional
from urllib.parse import unquote, urlencode
import httpx
from common.enums import LocalContentType
from common.utils.geo import haversine_m
from config.server_configs import external_api_config
_URL = "https://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api"
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
REQUEST_TIMEOUT = 25
PAGE_SIZE = 100
# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다.
MAX_PAGES = 4
# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 —
# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족).
CONTENT_TYPE_MAP = {
"39": LocalContentType.RESTAURANT.value,
"12": LocalContentType.ATTRACTION.value,
}
# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유).
_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
class TourApiNotConfigured(RuntimeError):
@ -22,52 +63,225 @@ class TourApiRequestFailed(RuntimeError):
pass
def _normalize(item: dict) -> dict:
"""표준데이터 필드명을 화면/저장소의 공통 축제 필드로 변환한다."""
identity = "|".join(str(item.get(key) or "") for key in ("fstvlNm", "fstvlStartDate", "fstvlEndDate", "insttCode"))
return {
"contentid": hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32],
"title": item.get("fstvlNm"),
"eventstartdate": str(item.get("fstvlStartDate") or "").replace("-", ""),
"eventenddate": str(item.get("fstvlEndDate") or "").replace("-", ""),
"addr1": item.get("rdnmadr") or item.get("lnmadr") or item.get("eventPlace"),
"mapx": item.get("longitude"),
"mapy": item.get("latitude"),
"tel": item.get("phoneNumber"),
"homepage": item.get("homepageUrl"),
"organizer": item.get("suprtInsttNm"),
"overview": item.get("relateInfo") or item.get("opar"),
"raw": item,
}
# ── HTTP ───────────────────────────────────────────────────────────────
async def search_festivals(*, start_date: date, area_code: str | None = None, rows: int = 100) -> list[dict]:
def _service_key() -> str:
key = (external_api_config.tour_api_key or "").strip()
if not key:
raise TourApiNotConfigured("TOUR_API_KEY is not configured")
params = {
# 포털의 "Encoding" 키를 넣어도 httpx가 이중 인코딩하지 않도록 원문으로 되돌린다.
"serviceKey": unquote(key),
"type": "json",
"numOfRows": max(1, min(rows, 1000)),
"pageNo": 1,
}
return key
async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]:
"""오퍼레이션 1회 → (항목, totalCount). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다.
포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다
(collector/tour_api_adapter._call 같은 처리).
"""
query = urlencode(
{"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
"_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params},
safe="",
)
res = await client.get(f"{BASE_URL}/{op}?{query}")
if res.status_code != 200:
raise TourApiRequestFailed(f"{op} HTTP {res.status_code}")
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=5.0)) as client:
response = await client.get(_URL, params=params)
response.raise_for_status()
payload = response.json()
response_data = payload.get("response", payload)
header = response_data.get("header", {})
if header.get("resultCode") not in (None, "00", "0000"):
raise TourApiRequestFailed(header.get("resultMsg") or "TourAPI returned an error")
items_node = response_data.get("body", {}).get("items", [])
items = items_node.get("item", []) if isinstance(items_node, dict) else items_node
if isinstance(items, dict):
items = [items]
normalized = [_normalize(item) for item in (items or [])]
# API가 날짜 필터를 제공하지 않으므로 종료된 축제는 애플리케이션에서 제외한다.
cutoff = start_date.strftime("%Y%m%d")
return [item for item in normalized if not item["eventenddate"] or item["eventenddate"] >= cutoff]
except (httpx.HTTPError, ValueError, TypeError) as ex:
raise TourApiRequestFailed(str(ex)) from ex
payload = res.json()
except ValueError:
# 인증 실패·쿼터 초과는 XML 로 온다. 본문 앞부분을 그대로 올려 원인을 감추지 않는다.
raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
# 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다. 그것도 원인을 드러낸다.
if "response" not in payload:
raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}")
header = payload["response"].get("header", {})
code = str(header.get("resultCode") or "")
if code not in ("0000", "00"):
raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}")
body = payload["response"].get("body", {}) or {}
items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None
if isinstance(items, dict):
items = [items]
total = int(body.get("totalCount") or 0)
return items or [], total
# ── 정규화 ──────────────────────────────────────────────────────────────
def _int(value) -> Optional[int]:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def _normalize(item: dict) -> Optional[dict]:
"""locationBasedList2 항목 1건 → place_contents.body. contentid·title·거리·종류 없으면 버린다."""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip())
distance = _int(item.get("dist"))
if not content_id or not title or kind is None or distance is None:
return None
body = {"contentid": content_id, "title": title, "content_type": kind, "distance_m": distance}
# lclsSystm1~3 = 분류체계 대/중/소. ★ 중분류(lclsSystm2)가 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준이다.
for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd",
"lclsSystm1", "lclsSystm2", "lclsSystm3"):
value = str(item.get(key) or "").strip()
if value:
body[key] = value
# 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다. 유형을 모르면 버린다.
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
body["firstimage"] = image
body["license"] = license_code
thumb = str(item.get("firstimage2") or "").strip()
if thumb:
body["firstimage2"] = thumb
return body
# ── 공개 API ────────────────────────────────────────────────────────────
def make_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
*, radius_m: int, content_type_id: str) -> list[dict]:
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정).
"""
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "locationBasedList2",
mapX=str(longitude), mapY=str(latitude), radius=str(radius_m),
contentTypeId=content_type_id, arrange="E", pageNo=str(page),
)
for item in items:
body = _normalize(item)
if body and body["contentid"] not in seen:
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
"""searchFestival2 항목 1건 → place_contents.body. locationBasedList2 와 달리 `dist` 를
주므로(호출측이 haversine 으로 값을) 그대로 받는다. 기간은 여기 이미 있다."""
content_id = str(item.get("contentid") or "").strip()
title = str(item.get("title") or "").strip()
if not content_id or not title:
return None
body = {"contentid": content_id, "title": title,
"content_type": LocalContentType.FESTIVAL.value, "distance_m": distance_m}
for key in ("addr1", "addr2", "tel", "mapx", "mapy", "lDongRegnCd", "lDongSignguCd",
"lclsSystm1", "lclsSystm2", "lclsSystm3", "eventstartdate", "eventenddate"):
value = str(item.get(key) or "").strip()
if value:
body[key] = value
image = str(item.get("firstimage") or "").strip()
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
if image and license_code in _COMMERCIAL_OK_LICENSES:
body["firstimage"] = image
body["license"] = license_code
thumb = str(item.get("firstimage2") or "").strip()
if thumb:
body["firstimage2"] = thumb
return body
def _festival_not_ended(body: dict, today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 끝났다는 증거가 없다."""
end, start = body.get("eventenddate"), body.get("eventstartdate")
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
*, sido_code: str, today: date) -> list[dict]:
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다.
반경을 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 자르면 시도 안의
축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
(종류별 노출 상한은 스냅샷이 20건으로 자른다 사진 있는 우선 가까운 .)
locationBasedList2 위치 색인은 믿어서 searchFestival2 쓴다( 모듈 docstring).
eventStartDate 1 1일로 **고정** "오늘" 넣으면 이전에 시작해 아직 진행 중인
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
좌표 없는 항목은 뺀다 distance_m NOT NULL 이고, 거리 없는 카드는 도보 필터에 얹는다.
"""
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
out: list[dict] = []
seen: set[str] = set()
for page in range(1, MAX_PAGES + 1):
items, total = await _call(
client, "searchFestival2",
eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page),
)
for item in items:
lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다
if lnglat is None:
continue
lng, lat = lnglat
distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용
body = _normalize_festival(item, round(distance))
if not body or body["contentid"] in seen:
continue
if not _festival_not_ended(body, today):
continue
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
break
out.sort(key=lambda b: b["distance_m"])
return out
def _mapxy(item: dict) -> Optional[tuple[float, float]]:
"""(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다."""
try:
return float(item.get("mapx")), float(item.get("mapy"))
except (TypeError, ValueError):
return None
async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]:
"""콘텐츠 1건의 중분류 코드(lclsSystm2). 못 구하면 None.
업장 자신이 TourAPI 등록돼 있을 (place_links tour:// 링크) 업장의 업태를 여기서 읽는다
주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 값이 우선이다(같은 체계라 오차가 없다).
"""
items, _ = await _call(client, "detailCommon2", contentId=content_id)
if not items:
return None
code = str(items[0].get("lclsSystm2") or "").strip()
return code or None
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 끝났다는 증거가 없다."""
if not period:
return False
start, end = period
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)

View 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

View File

@ -1,17 +1,71 @@
from datetime import date, datetime, timedelta, timezone
import re
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import local_contents
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource
from common.database.model.models import local_contents, place_contents, place_links, places
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory
from common.logger import LOG
from crud.local_content_crud import LocalContentCRUD
from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData
from crud.place_content_crud import PlaceContentCRUD
from crud.place_crud import PlaceCRUD
from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed
from services.external.naver import region_key
from router.v1.local.protocol import (
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData,
)
from services.external import tour_api
from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed, search_festivals
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed
from services.place_category import guess_food_class
# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId)
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다.
# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다).
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다.
_KST = timezone(timedelta(hours=9))
# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md
# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다.
# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다.
# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다.
# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido).
RESTAURANT_RADIUS_M = 5_000
ATTRACTION_RADIUS_M = 10_000
def _festival_display_end(body: dict) -> datetime | None:
"""eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC.
값이 있어야 끝난 축제가 발행본에서 저절로 빠진다
스냅샷의 노출창 필터(snapshot._local_contents) display_end_at 본다."""
raw = str(body.get("eventenddate") or "").strip()
if len(raw) != 8 or not raw.isdigit():
return None
try:
end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST)
except ValueError:
return None
return (end_day + timedelta(days=1)).astimezone(timezone.utc)
def _as_float(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
class LocalContentService:
def __init__(self):
self.crud = LocalContentCRUD()
self.place_crud = PlaceContentCRUD()
async def list(self, status=None, region_code=None):
res = ResLocalContentList()
@ -22,11 +76,67 @@ class LocalContentService:
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def sync_festivals(self, req):
res = ResSyncFestivals()
# ── 업장 반경 주변정보 ───────────────────────────────────────────────
async def sync_place(self, place) -> ResSyncPlace:
"""업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_contents 를 맞춘다.
종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08).
여행코스(25) 뺐다 반경을 넓혀도 데이터가 거의 없다(전북 전체 3 실측).
빌드가 매번 부른다(services/build_service.run_build) 발행본은 정적이라 이때 채운 값이 실린다.
실패해도 기존 행을 지우지 않는다 직전 유지가 캐시의 규약이다(모델 주석).
응답에 없는 행은 소프트 삭제한다 반경 밖으로 밀렸거나 TourAPI 내린 것이다.
hidden(운영자 숨김) 재수집이 덮어쓰지 않는다(crud.upsert). 변경으로 기존에 저장된
여행코스 행도 다음 재수집 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로).
공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다.
"""
res = ResSyncPlace()
place_id = getattr(place, "place_id", None)
if place_id is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
if lat is None or lng is None:
# ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가
# 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다.
found = await self._geocode_and_store(place)
if found is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)."
return res
lat, lng = found
try:
start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today()
items = await search_festivals(start_date=start, area_code=req.area_code)
async with tour_api.make_client() as client:
# 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다.
restaurants = await tour_api.fetch_nearby(
client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39")
# ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 —
# 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
# 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다.
own_class = await self._own_food_class(client, place)
if own_class:
before = len(restaurants)
restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class]
LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외")
attractions = await tour_api.fetch_nearby(
client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12")
# ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) —
# 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고,
# 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다.
sido_code = str(getattr(place, "region_code", None) or "")[:2] or None
if not sido_code:
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
derived = region_key(address)
sido_code = derived[:2] if derived else None
today = datetime.now(_KST).date()
if sido_code:
festivals = await tour_api.fetch_festivals_in_sido(
client, lat, lng, sido_code=sido_code, today=today)
else:
LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀")
festivals = []
except TourApiNotConfigured:
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
res.msg = "TOUR_API_KEY를 먼저 설정해주세요."
@ -36,35 +146,208 @@ class LocalContentService:
res.msg = str(ex)
return res
collected = skipped = 0
for item in items:
external_id = str(item.get("contentid") or "")
if not external_id:
skipped += 1
kept: list[dict] = restaurants + attractions + festivals
err, rows = await DB_SESSION_MNG.execute_lambda(
place_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
res.result.SetResult(err)
return res
existing = {(int(r.content_type), r.external_id): r for r in (rows or [])}
now = datetime.now(timezone.utc)
changed = 0
for body in kept:
key = (body["content_type"], body["contentid"])
prev = existing.get(key)
# 원문이 그대로면 쓰지 않는다 — collected_at 만 밀리면 '갱신된 척'이 된다.
if prev is not None and prev.body == body:
continue
_, existing = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value,
lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid),
values = {
"place_id": place_id,
"content_type": body["content_type"],
"external_id": body["contentid"],
"title": body["title"],
"body": body,
"distance_m": body["distance_m"],
"has_image": bool(body.get("firstimage")),
"display_end_at": (
_festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None
),
"collected_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[place_contents.DBType()], [lambda s, v=values: self.place_crud.upsert(s, v)]
)
if existing:
skipped += 1
continue
row = local_contents(
region_code=req.region_code,
content_type=LocalContentType.FESTIVAL.value,
source=LocalSource.TOUR_API.value,
external_id=external_id,
title=item.get("title"),
body=item,
status=LocalContentStatus.REVIEW.value,
collected_at=datetime.now(timezone.utc),
)
err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)])
collected += int(err == ErrorType.SUCCESS)
skipped += int(err != ErrorType.SUCCESS)
res.collected, res.skipped = collected, skipped
if write_err == ErrorType.SUCCESS:
changed += 1
keep_keys = {(b["content_type"], b["contentid"]) for b in kept}
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
place_contents.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, keep_keys)
)
counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value,
LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)}
for b in kept:
counts[b["content_type"]] += 1
res.festivals = counts[LocalContentType.FESTIVAL.value]
res.attractions = counts[LocalContentType.ATTRACTION.value]
res.restaurants = counts[LocalContentType.RESTAURANT.value]
res.courses = counts[LocalContentType.COURSE.value]
res.changed = changed > 0 or bool(removed)
LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})")
return res
async def _own_food_class(self, client, place) -> str | None:
"""음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다).
우선순위 정확한 쪽부터:
1. 업장이 TourAPI 등록돼 있으면(place_links tour:// 링크) 콘텐츠의 lclsSystm2.
주변 항목과 **같은 체계** 오차가 없다. 링크는 수집(COLLECT) 상호+좌표 검증을 거쳐 붙인다.
2. 검증 박제한 외부 분류 문자열(places.external_category) 키워드로 매핑.
3. 그래도 모르면 업종 기본값 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다).
실패는 전부 '제외 없음'으로 떨어진다 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다.
"""
category = getattr(place, "category", None)
if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value):
return None
err, links = await DB_SESSION_MNG.execute_lambda(
place_links.DBType(), DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True),
)
for link in (links or []) if err == ErrorType.SUCCESS else []:
m = _TOUR_LINK.match(str(getattr(link, "url", "") or ""))
if not m:
continue
try:
code = await tour_api.fetch_content_class(client, m.group(2))
except TourApiRequestFailed as ex:
LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}")
code = None
if code:
return code
break
guessed = guess_food_class(getattr(place, "external_category", None))
if guessed:
return guessed
return _DEFAULT_FOOD_CLASS.get(category)
async def _geocode_and_store(self, place) -> tuple[float, float] | None:
"""주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None."""
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
if not address:
return None
client = KakaoLocalClient()
if not client.enabled:
LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다")
return None
try:
found = await client.geocode_address(address)
except (KakaoNotConfigured, KakaoRequestFailed) as ex:
LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}")
return None
finally:
await client.aclose()
if found is None:
return None
lat, lng = found
company_id = getattr(place, "company_id", None)
if company_id is not None:
data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))}
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}")
else:
place.latitude, place.longitude = data["latitude"], data["longitude"]
LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}")
return lat, lng
async def _load_place(self, place_id):
"""회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다."""
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(
s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712
),
)
return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace:
place = await self._load_place(place_id)
if place is None:
res = ResSyncPlace()
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
return await self.sync_place(place)
async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList:
res = ResPlaceContentList()
err, rows = await DB_SESSION_MNG.execute_lambda(
place_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True),
)
res.result.SetResult(err)
res.contents = list(rows) if err == ErrorType.SUCCESS else []
return res
async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList:
res = ResPlaceContentList()
err, count = await DB_SESSION_MNG.execute_lambda_claim(
place_contents.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden)
)
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
return res
async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide:
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).
스냅샷 필터(숨김·노출창) payload 변환을 **그대로 재사용**한다
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다" 읽는다.
그래서 여기서 DB 따로 읽지 않고 발행 파이프라인의 함수를 잇기만 한다.
일정(itineraries) payload 만들어도 여기선 내려보내지 않는다 캔버스에 그릴 자리가 아직 없다.
"""
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local
from services.snapshot import _local_contents
res = ResLocalGuide()
place = await self._load_place(place_id)
if place is None:
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
return res
# ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약).
# 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측).
# 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다.
err, rows = await DB_SESSION_MNG.execute_lambda(
place_contents.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.list_by_place(s, place_id),
)
if err == ErrorType.SUCCESS and not rows:
synced = await self.sync_place(place)
if not synced.result.success:
LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}")
snapshot_local = await _local_contents(place)
local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude))
res.attractions = local.get("attractions") or []
res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or []
res.synced_at = synced_at
return res
# ── 지역 캐시(local_contents) — 운영자 수기 항목·날씨 ────────────────
async def publish(self, ids, user_id):
res = ResLocalContentList()
err, _ = await DB_SESSION_MNG.execute_lambda_claim(

View File

@ -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

View File

@ -243,6 +243,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:
@ -321,6 +323,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, uid, uuid.UUID(place_id), data),

View File

@ -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

View File

@ -22,13 +22,14 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import facts, faqs, local_contents, media, units
from common.database.model.models import facts, faqs, local_contents, media, place_contents, units
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
LocalSource,
MediaStatus,
PlaceCategory,
)
@ -38,9 +39,9 @@ from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 스냅샷은 site_versions.snapshot 에 통째로 들어간다. 지역 캐시는 region_code 단위 공용이라
# 한 지역에 수백 건이 쌓일 수 있고, 그걸 다 박제하면 버전 행마다 그만큼이 복사된다.
# 화면(LocalGuideSection)도 그만큼 보여주지 않는다 — 최근 수집분 위주로 자른다.
# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
@ -164,7 +165,11 @@ async def build_snapshot(place) -> dict:
async def _local_contents(place) -> dict:
"""사업장 지역의 노출 가능한 지역 정보. {"region_code", "contents":[...]}
"""사업장의 노출 가능한 지역·주변 정보. {"region_code", "contents":[...]}
캐시를 합친다
local_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
place_contents (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 제외
노출 가능 = PUBLISHED + 노출 기간 .
local_contents.status 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
@ -192,48 +197,77 @@ async def _local_contents(place) -> dict:
region_code = region_key(
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
) or ""
if not region_code:
return {"region_code": None, "contents": []}
now = datetime.now(timezone.utc)
query = (
select(local_contents)
.where(
local_contents.region_code == region_code,
local_contents.deleted == False, # noqa: E712
local_contents.status == LocalContentStatus.PUBLISHED.value,
or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now),
or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now),
)
.order_by(local_contents.content_type.asc(), local_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
# 빈 채로 나가면 렌더러가 그 섹션을 아예 그리지 않는다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
return {"region_code": region_code, "contents": []}
contents: list[dict] = []
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
seen: dict[int, int] = {}
contents = []
for row in rows or []:
# ── 지역 캐시(local_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
if region_code:
query = (
select(local_contents)
.where(
local_contents.region_code == region_code,
local_contents.deleted == False, # noqa: E712
local_contents.status == LocalContentStatus.PUBLISHED.value,
or_(local_contents.display_start_at.is_(None), local_contents.display_start_at <= now),
or_(local_contents.display_end_at.is_(None), local_contents.display_end_at > now),
)
.order_by(local_contents.content_type.asc(), local_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
local_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query)
)
if err != ErrorType.SUCCESS:
# ★ 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen)
# ── 업장 반경 캐시(place_contents): 맛집·관광지·축제·여행코스 ──
# ★ 정렬은 **사진 있는 것 우선 → 가까운 순**(2026-09-07 결정). 종류별 상한 안에 들려면
# 사진 없는 가까운 곳보다 사진 있는 조금 먼 곳이 이긴다 — 화면이 카드라 사진이 없으면 자리가 빈다.
place_id = getattr(place, "place_id", None)
if place_id is not None:
nearby_q = (
select(place_contents)
.where(
place_contents.place_id == place_id,
place_contents.deleted == False, # noqa: E712
place_contents.hidden == False, # noqa: E712
or_(place_contents.display_end_at.is_(None), place_contents.display_end_at > now),
)
.order_by(place_contents.content_type.asc(), place_contents.has_image.desc(), place_contents.distance_m.asc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
place_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, nearby_q)
)
if err != ErrorType.SUCCESS:
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen, source=LocalSource.TOUR_API.value)
return {"region_code": region_code or None, "contents": contents}
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
"""행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다.
seen 호출측이 넘겨 캐시에 걸쳐 누적한다."""
out = []
for row in rows:
content_type = int(row.content_type)
# 종류별 상한. 위 order_by 가 collected_at 내림차순이라 최근 수집분이 남는다.
taken = seen.get(content_type, 0)
if taken >= _LOCAL_MAX_PER_TYPE:
continue
seen[content_type] = taken + 1
body = row.body if isinstance(row.body, dict) else {}
contents.append({
out.append({
"content_type": content_type,
"source": row.source,
"source": source if source is not None else row.source,
"title": row.title,
"body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP},
"collected_at": _iso(row.collected_at),
})
return {"region_code": region_code, "contents": contents}
return out
def _iso(value) -> str | None:

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /admin/

View 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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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';

View File

@ -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: [

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,36 @@
/**
* (m) .
*
* 80m ( 4.8km/h). :
* "도보 시간은 숙소에서 잰 직선거리를 분속 80m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다."
* (2026-09-07): 135m2 · 297m4 · 305m4 · 566m7 · 13m1 + 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;
}

View File

@ -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',

View 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;
}

View File

@ -207,7 +207,9 @@ export interface FestivalEntry {
description?: string;
officialUrl?: string;
searchQuery: string;
/** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */
/** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */
distanceText?: string;
distanceMeters?: number;
imageUrl?: string;
}