2168 lines
64 KiB
Markdown
2168 lines
64 KiB
Markdown
# 축제-펜션 연동 시스템 설계 문서
|
|
|
|
> CastAD 플랫폼에 전국 축제 정보와 펜션 데이터를 연동하여, 축제 기반 숙박 마케팅 플랫폼으로 확장하는 기능 설계서
|
|
|
|
**작성일**: 2024-12-08
|
|
**버전**: 1.0
|
|
**상태**: 설계 단계
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [개요](#1-개요)
|
|
2. [핵심 기능](#2-핵심-기능)
|
|
3. [외부 API 연동](#3-외부-api-연동)
|
|
4. [데이터베이스 설계](#4-데이터베이스-설계)
|
|
5. [백엔드 서비스 설계](#5-백엔드-서비스-설계)
|
|
6. [API 엔드포인트](#6-api-엔드포인트)
|
|
7. [프론트엔드 설계](#7-프론트엔드-설계)
|
|
8. [AI 콘텐츠 융합](#8-ai-콘텐츠-융합)
|
|
9. [구현 로드맵](#9-구현-로드맵)
|
|
10. [기술 스택](#10-기술-스택)
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 배경
|
|
|
|
현재 CastAD는 펜션 홍보 영상 생성에 특화된 플랫폼입니다. 여기에 전국 축제 정보를 연동하면:
|
|
|
|
- **펜션 주인**: 인근 축제와 연계한 마케팅 콘텐츠 자동 생성
|
|
- **여행객**: 축제 + 숙소를 한 번에 찾는 원스톱 서비스
|
|
- **플랫폼**: 축제 시즌 트래픽 증가, 펜션 가입 유도
|
|
|
|
### 1.2 목표
|
|
|
|
1. 전국 축제 데이터 실시간 연동 (TourAPI)
|
|
2. 전국 펜션 마스터 DB 구축 (~10,000개)
|
|
3. 위치 기반 축제-펜션 자동 매칭
|
|
4. 랜딩페이지에서 축제/펜션 노출로 가입 유도
|
|
5. AI 콘텐츠(가사, 영상)에 축제 정보 융합
|
|
|
|
### 1.3 사용자 시나리오
|
|
|
|
```
|
|
[시나리오 A: 축제 → 펜션]
|
|
1. 방문자가 "보령 머드축제" 검색
|
|
2. 축제 상세페이지에서 "주변 숙소" 탭 확인
|
|
3. 차량 30분 거리 펜션 목록 표시
|
|
4. 펜션 클릭 → 홍보 영상 시청 → 예약 링크 이동
|
|
|
|
[시나리오 B: 펜션 → 축제]
|
|
1. 펜션 주인이 "가평 OO펜션" 등록
|
|
2. 시스템이 자동으로 인근 축제 탐지
|
|
3. "자라섬 재즈페스티벌" (10월) 매칭
|
|
4. 홍보 영상에 "재즈도 즐기고 펜션에서 힐링~" 자동 생성
|
|
|
|
[시나리오 C: 랜딩페이지 가입 유도]
|
|
1. 첫 방문자가 랜딩페이지 접속
|
|
2. "이번 달 전국 축제" 섹션 확인
|
|
3. 관심 지역 클릭 → 해당 지역 펜션 목록
|
|
4. "우리 펜션도 등록하기" CTA → 회원가입 유도
|
|
```
|
|
|
|
---
|
|
|
|
## 2. 핵심 기능
|
|
|
|
### 2.1 기능 목록
|
|
|
|
| 기능 | 설명 | 우선순위 |
|
|
|-----|------|---------|
|
|
| 축제 데이터 동기화 | TourAPI에서 전국 축제 정보 수집/갱신 | P0 |
|
|
| 펜션 마스터 DB | 공공데이터 기반 전국 펜션 DB 구축 | P0 |
|
|
| 위치 기반 매칭 | GPS 좌표로 축제-펜션 거리 계산 및 매칭 | P0 |
|
|
| 지역별 펜션 목록 | 시/도/군별 펜션 검색 및 표시 | P1 |
|
|
| 축제 캘린더 | 월별/지역별 축제 일정 표시 | P1 |
|
|
| 펜션 클레임 | 펜션 주인이 본인 펜션 인증/등록 | P1 |
|
|
| AI 콘텐츠 융합 | 축제 정보를 가사/영상에 자동 반영 | P2 |
|
|
| 축제 검색 | 키워드/지역/기간별 축제 검색 | P2 |
|
|
|
|
### 2.2 매칭 기준
|
|
|
|
```
|
|
[동일 지역 매칭 - same_region]
|
|
- 같은 시/도 (예: 경기도 가평군 ↔ 경기도 가평군)
|
|
- 같은 시/군/구
|
|
|
|
[근거리 매칭 - nearby]
|
|
- 0~15km: "차량 15분 거리" ⭐⭐⭐ (최우선 추천)
|
|
- 15~30km: "차량 30분 거리" ⭐⭐
|
|
- 30~50km: "차량 50분 거리" ⭐
|
|
- 50~80km: "차량 1시간 거리"
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 외부 API 연동
|
|
|
|
### 3.1 필요한 API 키
|
|
|
|
| 서비스 | 용도 | 발급처 | 비용 |
|
|
|-------|------|-------|------|
|
|
| TourAPI 4.0 | 축제/숙박 데이터 | [공공데이터포털](https://www.data.go.kr/data/15101578/openapi.do) | 무료 |
|
|
| 카카오 Local API | 주소 → 좌표 변환 | [카카오 개발자](https://developers.kakao.com/) | 무료 (일 30만건) |
|
|
| 카카오맵 API | 지도 표시 | 위와 동일 | 무료 |
|
|
|
|
### 3.2 TourAPI 엔드포인트
|
|
|
|
```
|
|
Base URL: http://apis.data.go.kr/B551011/KorService1
|
|
|
|
[축제/행사 관련]
|
|
GET /searchFestival - 축제 목록 조회
|
|
GET /detailCommon - 축제 상세 정보
|
|
GET /detailIntro - 축제 소개 정보
|
|
GET /detailImage - 축제 이미지
|
|
|
|
[숙박 관련]
|
|
GET /searchStay - 숙박시설 조회
|
|
GET /areaBasedList - 지역기반 관광정보
|
|
GET /locationBasedList - 위치기반 관광정보
|
|
|
|
[코드 조회]
|
|
GET /areaCode - 지역코드 조회
|
|
GET /sigunguCode - 시군구코드 조회
|
|
```
|
|
|
|
### 3.3 TourAPI 호출 예시
|
|
|
|
```javascript
|
|
// 축제 조회
|
|
const festivalParams = {
|
|
serviceKey: process.env.TOURAPI_KEY,
|
|
numOfRows: 100,
|
|
pageNo: 1,
|
|
MobileOS: 'ETC',
|
|
MobileApp: 'CastAD',
|
|
_type: 'json',
|
|
listYN: 'Y',
|
|
arrange: 'A',
|
|
eventStartDate: '20241201', // YYYYMMDD
|
|
eventEndDate: '20241231',
|
|
areaCode: '31', // 경기도
|
|
};
|
|
|
|
// 숙박 조회 (펜션)
|
|
const stayParams = {
|
|
...commonParams,
|
|
contentTypeId: '32', // 숙박
|
|
cat3: 'B02010700', // 펜션
|
|
};
|
|
```
|
|
|
|
### 3.4 카카오 Geocoding API
|
|
|
|
```javascript
|
|
// 주소 → 좌표 변환
|
|
const response = await axios.get(
|
|
'https://dapi.kakao.com/v2/local/search/address.json',
|
|
{
|
|
params: { query: '경기도 가평군 청평면 호반로 1234' },
|
|
headers: { Authorization: `KakaoAK ${KAKAO_REST_KEY}` }
|
|
}
|
|
);
|
|
|
|
// 응답: { x: 127.123, y: 37.456 } (경도, 위도)
|
|
```
|
|
|
|
### 3.5 공공데이터 - 전국 펜션 데이터
|
|
|
|
| 데이터셋 | URL | 형태 |
|
|
|---------|-----|------|
|
|
| 전국관광펜션업소표준데이터 | [링크](https://www.data.go.kr/data/15013105/standard.do) | CSV |
|
|
| 행정안전부 숙박업 | [링크](https://www.data.go.kr/data/15044968/fileData.do) | CSV |
|
|
| LOCALDATA | [링크](https://www.localdata.go.kr) | API |
|
|
|
|
---
|
|
|
|
## 4. 데이터베이스 설계
|
|
|
|
### 4.1 ERD 개요
|
|
|
|
```
|
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
│ festivals │ │ pension_festival │ │ public_pensions │
|
|
│ │ │ _matches │ │ │
|
|
│ id (PK) │◄────│ festival_id │ │ id (PK) │
|
|
│ content_id │ │ pension_id │────►│ source │
|
|
│ title │ │ distance_km │ │ name │
|
|
│ addr1 │ │ match_type │ │ address │
|
|
│ mapx, mapy │ │ travel_time_min │ │ mapx, mapy │
|
|
│ event_start │ └─────────────────┘ │ sido, sigungu │
|
|
│ event_end │ │ is_claimed │
|
|
│ ... │ ┌─────────────────┐ │ claimed_by │
|
|
└─────────────────┘ │ pension_profiles │ └─────────────────┘
|
|
│ │ ▲
|
|
│ id (PK) │ │
|
|
│ user_id │──────────────┘
|
|
│ public_pension │ (연결)
|
|
│ _id (FK) │
|
|
│ brand_name │
|
|
│ address │
|
|
│ mapx, mapy │
|
|
└─────────────────┘
|
|
```
|
|
|
|
### 4.2 테이블 정의
|
|
|
|
#### 4.2.1 festivals (축제 정보)
|
|
|
|
```sql
|
|
CREATE TABLE festivals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
-- TourAPI 식별자
|
|
content_id TEXT UNIQUE NOT NULL,
|
|
content_type_id TEXT DEFAULT '15', -- 15: 축제/행사
|
|
|
|
-- 기본 정보
|
|
title TEXT NOT NULL, -- 축제명
|
|
overview TEXT, -- 축제 소개
|
|
|
|
-- 주소 정보
|
|
addr1 TEXT, -- 주소
|
|
addr2 TEXT, -- 상세주소
|
|
zipcode TEXT, -- 우편번호
|
|
|
|
-- 지역 코드
|
|
area_code TEXT, -- 시도 코드
|
|
sigungu_code TEXT, -- 시군구 코드
|
|
sido TEXT, -- 시도명
|
|
sigungu TEXT, -- 시군구명
|
|
|
|
-- 좌표
|
|
mapx REAL, -- 경도 (longitude)
|
|
mapy REAL, -- 위도 (latitude)
|
|
|
|
-- 기간
|
|
event_start_date TEXT, -- 시작일 (YYYYMMDD)
|
|
event_end_date TEXT, -- 종료일 (YYYYMMDD)
|
|
|
|
-- 이미지
|
|
first_image TEXT, -- 대표이미지
|
|
first_image2 TEXT, -- 썸네일
|
|
|
|
-- 연락처
|
|
tel TEXT, -- 전화번호
|
|
homepage TEXT, -- 홈페이지
|
|
|
|
-- 상세 정보
|
|
place TEXT, -- 개최장소
|
|
place_info TEXT, -- 장소안내
|
|
play_time TEXT, -- 공연시간
|
|
program TEXT, -- 행사프로그램
|
|
use_fee TEXT, -- 이용요금
|
|
age_limit TEXT, -- 관람가능연령
|
|
|
|
-- 주최/주관
|
|
sponsor1 TEXT, -- 주최자
|
|
sponsor1_tel TEXT, -- 주최자 연락처
|
|
sponsor2 TEXT, -- 주관사
|
|
sponsor2_tel TEXT, -- 주관사 연락처
|
|
|
|
-- 부대정보
|
|
sub_event TEXT, -- 부대행사
|
|
booking_place TEXT, -- 예매처
|
|
|
|
-- 시스템
|
|
is_active INTEGER DEFAULT 1, -- 활성화 여부
|
|
view_count INTEGER DEFAULT 0, -- 조회수
|
|
last_synced_at TEXT, -- 마지막 동기화
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX idx_festivals_area ON festivals(area_code, sigungu_code);
|
|
CREATE INDEX idx_festivals_date ON festivals(event_start_date, event_end_date);
|
|
CREATE INDEX idx_festivals_coords ON festivals(mapx, mapy);
|
|
CREATE INDEX idx_festivals_active ON festivals(is_active, event_end_date);
|
|
```
|
|
|
|
#### 4.2.2 public_pensions (전국 펜션 마스터)
|
|
|
|
```sql
|
|
CREATE TABLE public_pensions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
-- 데이터 출처
|
|
source TEXT NOT NULL, -- 'PENSION_STD' | 'TOURAPI' | 'LOCALDATA'
|
|
source_id TEXT, -- 원본 데이터 ID
|
|
content_id TEXT, -- TourAPI contentid
|
|
|
|
-- 기본 정보
|
|
name TEXT NOT NULL, -- 펜션명
|
|
name_normalized TEXT, -- 검색용 정규화 이름
|
|
|
|
-- 주소 정보
|
|
address TEXT, -- 전체 주소
|
|
road_address TEXT, -- 도로명 주소
|
|
jibun_address TEXT, -- 지번 주소
|
|
zipcode TEXT, -- 우편번호
|
|
|
|
-- 지역 코드
|
|
sido TEXT, -- 시도 (서울특별시, 경기도 등)
|
|
sigungu TEXT, -- 시군구
|
|
eupmyeondong TEXT, -- 읍면동
|
|
area_code TEXT, -- TourAPI 지역코드
|
|
sigungu_code TEXT, -- TourAPI 시군구코드
|
|
|
|
-- 좌표
|
|
mapx REAL, -- 경도 (longitude)
|
|
mapy REAL, -- 위도 (latitude)
|
|
|
|
-- 연락처
|
|
tel TEXT, -- 전화번호
|
|
homepage TEXT, -- 홈페이지
|
|
|
|
-- 이미지
|
|
thumbnail TEXT, -- 대표이미지 URL
|
|
images TEXT, -- 추가이미지 JSON 배열
|
|
|
|
-- 숙박 상세 (TourAPI)
|
|
checkin_time TEXT, -- 체크인 시간
|
|
checkout_time TEXT, -- 체크아웃 시간
|
|
room_count INTEGER, -- 객실 수
|
|
room_type TEXT, -- 객실유형
|
|
facilities TEXT, -- 부대시설 JSON
|
|
parking TEXT, -- 주차정보
|
|
reservation_url TEXT, -- 예약 URL
|
|
|
|
-- 특성
|
|
pet_allowed INTEGER DEFAULT 0, -- 반려동물 가능
|
|
pickup_available INTEGER DEFAULT 0, -- 픽업 가능
|
|
cooking_available INTEGER DEFAULT 0, -- 취사 가능
|
|
barbecue_available INTEGER DEFAULT 0, -- 바베큐 가능
|
|
|
|
-- 영업 상태 (LOCALDATA)
|
|
business_status TEXT DEFAULT '영업중', -- 영업중, 폐업, 휴업
|
|
license_date TEXT, -- 인허가일
|
|
closure_date TEXT, -- 폐업일
|
|
|
|
-- 소유권
|
|
is_verified INTEGER DEFAULT 0, -- 정보 검증 완료
|
|
is_claimed INTEGER DEFAULT 0, -- 소유자 인증 완료
|
|
claimed_by INTEGER, -- 인증한 user_id
|
|
claimed_at TEXT, -- 인증 일시
|
|
|
|
-- 통계
|
|
view_count INTEGER DEFAULT 0, -- 조회수
|
|
|
|
-- 타임스탬프
|
|
last_synced_at TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
FOREIGN KEY (claimed_by) REFERENCES users(id)
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX idx_public_pensions_sido ON public_pensions(sido);
|
|
CREATE INDEX idx_public_pensions_sigungu ON public_pensions(sido, sigungu);
|
|
CREATE INDEX idx_public_pensions_coords ON public_pensions(mapx, mapy);
|
|
CREATE INDEX idx_public_pensions_name ON public_pensions(name_normalized);
|
|
CREATE INDEX idx_public_pensions_source ON public_pensions(source, source_id);
|
|
CREATE INDEX idx_public_pensions_claimed ON public_pensions(is_claimed, claimed_by);
|
|
CREATE UNIQUE INDEX idx_public_pensions_unique ON public_pensions(source, source_id);
|
|
```
|
|
|
|
#### 4.2.3 pension_festival_matches (펜션-축제 매칭)
|
|
|
|
```sql
|
|
CREATE TABLE pension_festival_matches (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
-- 연결
|
|
pension_id INTEGER NOT NULL, -- public_pensions.id 또는 pension_profiles.id
|
|
pension_type TEXT DEFAULT 'public', -- 'public' | 'profile'
|
|
festival_id INTEGER NOT NULL,
|
|
|
|
-- 거리 정보
|
|
distance_km REAL, -- 직선 거리 (km)
|
|
travel_time_min INTEGER, -- 예상 이동시간 (분)
|
|
|
|
-- 매칭 유형
|
|
match_type TEXT, -- 'same_region' | 'nearby'
|
|
match_score INTEGER DEFAULT 0, -- 매칭 점수 (0-100)
|
|
|
|
-- 추천
|
|
is_featured INTEGER DEFAULT 0, -- 추천 여부
|
|
|
|
-- 타임스탬프
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
FOREIGN KEY (festival_id) REFERENCES festivals(id),
|
|
UNIQUE(pension_id, pension_type, festival_id)
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX idx_matches_pension ON pension_festival_matches(pension_id, pension_type);
|
|
CREATE INDEX idx_matches_festival ON pension_festival_matches(festival_id);
|
|
CREATE INDEX idx_matches_distance ON pension_festival_matches(distance_km);
|
|
```
|
|
|
|
#### 4.2.4 area_codes (지역 코드)
|
|
|
|
```sql
|
|
-- TourAPI 지역 코드
|
|
CREATE TABLE area_codes (
|
|
code TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL, -- 서울특별시, 경기도 등
|
|
name_short TEXT, -- 서울, 경기 등
|
|
name_en TEXT -- Seoul, Gyeonggi 등
|
|
);
|
|
|
|
-- 시군구 코드
|
|
CREATE TABLE sigungu_codes (
|
|
area_code TEXT NOT NULL,
|
|
sigungu_code TEXT NOT NULL,
|
|
name TEXT NOT NULL, -- 강남구, 수원시 등
|
|
name_en TEXT,
|
|
PRIMARY KEY (area_code, sigungu_code),
|
|
FOREIGN KEY (area_code) REFERENCES area_codes(code)
|
|
);
|
|
```
|
|
|
|
#### 4.2.5 pension_claims (펜션 인증 요청)
|
|
|
|
```sql
|
|
CREATE TABLE pension_claims (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
public_pension_id INTEGER NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
|
|
-- 인증 정보
|
|
status TEXT DEFAULT 'pending', -- pending, approved, rejected
|
|
verification_method TEXT, -- phone, email, document
|
|
verification_data TEXT, -- JSON (전화번호, 사업자등록증 등)
|
|
|
|
-- 처리 정보
|
|
reviewed_by INTEGER, -- 처리한 관리자
|
|
reviewed_at TEXT,
|
|
reject_reason TEXT,
|
|
|
|
-- 타임스탬프
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
FOREIGN KEY (public_pension_id) REFERENCES public_pensions(id),
|
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
|
FOREIGN KEY (reviewed_by) REFERENCES users(id)
|
|
);
|
|
```
|
|
|
|
#### 4.2.6 pension_profiles 테이블 수정
|
|
|
|
```sql
|
|
-- 기존 pension_profiles에 컬럼 추가
|
|
ALTER TABLE pension_profiles ADD COLUMN mapx REAL;
|
|
ALTER TABLE pension_profiles ADD COLUMN mapy REAL;
|
|
ALTER TABLE pension_profiles ADD COLUMN area_code TEXT;
|
|
ALTER TABLE pension_profiles ADD COLUMN sigungu_code TEXT;
|
|
ALTER TABLE pension_profiles ADD COLUMN public_pension_id INTEGER
|
|
REFERENCES public_pensions(id);
|
|
```
|
|
|
|
### 4.3 지역 코드 초기 데이터
|
|
|
|
```sql
|
|
INSERT INTO area_codes (code, name, name_short, name_en) VALUES
|
|
('1', '서울특별시', '서울', 'Seoul'),
|
|
('2', '인천광역시', '인천', 'Incheon'),
|
|
('3', '대전광역시', '대전', 'Daejeon'),
|
|
('4', '대구광역시', '대구', 'Daegu'),
|
|
('5', '광주광역시', '광주', 'Gwangju'),
|
|
('6', '부산광역시', '부산', 'Busan'),
|
|
('7', '울산광역시', '울산', 'Ulsan'),
|
|
('8', '세종특별자치시', '세종', 'Sejong'),
|
|
('31', '경기도', '경기', 'Gyeonggi'),
|
|
('32', '강원특별자치도', '강원', 'Gangwon'),
|
|
('33', '충청북도', '충북', 'Chungbuk'),
|
|
('34', '충청남도', '충남', 'Chungnam'),
|
|
('35', '경상북도', '경북', 'Gyeongbuk'),
|
|
('36', '경상남도', '경남', 'Gyeongnam'),
|
|
('37', '전북특별자치도', '전북', 'Jeonbuk'),
|
|
('38', '전라남도', '전남', 'Jeonnam'),
|
|
('39', '제주특별자치도', '제주', 'Jeju');
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 백엔드 서비스 설계
|
|
|
|
### 5.1 파일 구조
|
|
|
|
```
|
|
server/
|
|
├── services/
|
|
│ ├── festivalService.js # 축제 API 연동
|
|
│ ├── pensionDataService.js # 펜션 데이터 수집
|
|
│ ├── matchingService.js # 축제-펜션 매칭
|
|
│ ├── geocodingService.js # 주소-좌표 변환
|
|
│ └── tourApiClient.js # TourAPI 클라이언트
|
|
├── scripts/
|
|
│ ├── syncFestivals.js # 축제 동기화 스크립트
|
|
│ ├── syncPensions.js # 펜션 동기화 스크립트
|
|
│ └── initAreaCodes.js # 지역코드 초기화
|
|
├── routes/
|
|
│ ├── festivalRoutes.js # 축제 API 라우트
|
|
│ └── publicPensionRoutes.js # 공개 펜션 API 라우트
|
|
└── cron/
|
|
└── syncJob.js # 정기 동기화 작업
|
|
```
|
|
|
|
### 5.2 핵심 서비스
|
|
|
|
#### 5.2.1 TourAPI 클라이언트
|
|
|
|
```javascript
|
|
// server/services/tourApiClient.js
|
|
|
|
const axios = require('axios');
|
|
|
|
const BASE_URL = 'http://apis.data.go.kr/B551011/KorService1';
|
|
|
|
class TourApiClient {
|
|
constructor(serviceKey) {
|
|
this.serviceKey = serviceKey;
|
|
this.client = axios.create({
|
|
baseURL: BASE_URL,
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
async request(endpoint, params = {}) {
|
|
const response = await this.client.get(endpoint, {
|
|
params: {
|
|
serviceKey: this.serviceKey,
|
|
MobileOS: 'ETC',
|
|
MobileApp: 'CastAD',
|
|
_type: 'json',
|
|
...params,
|
|
},
|
|
});
|
|
|
|
const body = response.data.response?.body;
|
|
if (!body) {
|
|
throw new Error('Invalid API response');
|
|
}
|
|
|
|
return {
|
|
items: body.items?.item || [],
|
|
totalCount: body.totalCount,
|
|
pageNo: body.pageNo,
|
|
numOfRows: body.numOfRows,
|
|
};
|
|
}
|
|
|
|
// 축제 목록 조회
|
|
async searchFestival(params) {
|
|
return this.request('/searchFestival', {
|
|
listYN: 'Y',
|
|
arrange: 'A',
|
|
...params,
|
|
});
|
|
}
|
|
|
|
// 숙박시설 조회
|
|
async searchStay(params) {
|
|
return this.request('/searchStay', {
|
|
listYN: 'Y',
|
|
arrange: 'A',
|
|
...params,
|
|
});
|
|
}
|
|
|
|
// 위치기반 조회
|
|
async locationBasedList(mapX, mapY, radius = 10000) {
|
|
return this.request('/locationBasedList', {
|
|
mapX,
|
|
mapY,
|
|
radius, // 미터 단위
|
|
listYN: 'Y',
|
|
arrange: 'E', // 거리순
|
|
});
|
|
}
|
|
|
|
// 상세 정보 조회
|
|
async detailCommon(contentId) {
|
|
return this.request('/detailCommon', {
|
|
contentId,
|
|
defaultYN: 'Y',
|
|
firstImageYN: 'Y',
|
|
addrinfoYN: 'Y',
|
|
mapinfoYN: 'Y',
|
|
overviewYN: 'Y',
|
|
});
|
|
}
|
|
|
|
// 소개 정보 조회 (축제 상세)
|
|
async detailIntro(contentId, contentTypeId = '15') {
|
|
return this.request('/detailIntro', {
|
|
contentId,
|
|
contentTypeId,
|
|
});
|
|
}
|
|
|
|
// 지역코드 조회
|
|
async getAreaCodes() {
|
|
return this.request('/areaCode', { numOfRows: 50 });
|
|
}
|
|
|
|
// 시군구코드 조회
|
|
async getSigunguCodes(areaCode) {
|
|
return this.request('/areaCode', {
|
|
areaCode,
|
|
numOfRows: 100,
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = TourApiClient;
|
|
```
|
|
|
|
#### 5.2.2 축제 서비스
|
|
|
|
```javascript
|
|
// server/services/festivalService.js
|
|
|
|
const TourApiClient = require('./tourApiClient');
|
|
const db = require('../db');
|
|
|
|
class FestivalService {
|
|
constructor() {
|
|
this.tourApi = new TourApiClient(process.env.TOURAPI_KEY);
|
|
}
|
|
|
|
// 축제 데이터 동기화
|
|
async syncFestivals(options = {}) {
|
|
const {
|
|
areaCode = null,
|
|
startDate = this.getTodayString(),
|
|
endDate = this.getDateAfterMonths(6),
|
|
} = options;
|
|
|
|
console.log(`축제 동기화 시작: ${startDate} ~ ${endDate}`);
|
|
|
|
let pageNo = 1;
|
|
let totalSynced = 0;
|
|
|
|
while (true) {
|
|
const params = {
|
|
numOfRows: 100,
|
|
pageNo,
|
|
eventStartDate: startDate,
|
|
eventEndDate: endDate,
|
|
};
|
|
|
|
if (areaCode) {
|
|
params.areaCode = areaCode;
|
|
}
|
|
|
|
const result = await this.tourApi.searchFestival(params);
|
|
|
|
if (!result.items.length) break;
|
|
|
|
for (const item of result.items) {
|
|
await this.upsertFestival(item);
|
|
totalSynced++;
|
|
}
|
|
|
|
if (result.items.length < 100) break;
|
|
pageNo++;
|
|
|
|
// API 호출 제한 대응
|
|
await this.sleep(100);
|
|
}
|
|
|
|
console.log(`축제 동기화 완료: ${totalSynced}건`);
|
|
return totalSynced;
|
|
}
|
|
|
|
// 축제 데이터 저장/업데이트
|
|
async upsertFestival(item) {
|
|
const exists = await db.get(
|
|
'SELECT id FROM festivals WHERE content_id = ?',
|
|
[item.contentid]
|
|
);
|
|
|
|
const data = {
|
|
content_id: item.contentid,
|
|
content_type_id: item.contenttypeid || '15',
|
|
title: item.title,
|
|
addr1: item.addr1,
|
|
addr2: item.addr2,
|
|
area_code: item.areacode,
|
|
sigungu_code: item.sigungucode,
|
|
mapx: parseFloat(item.mapx) || null,
|
|
mapy: parseFloat(item.mapy) || null,
|
|
event_start_date: item.eventstartdate,
|
|
event_end_date: item.eventenddate,
|
|
first_image: item.firstimage,
|
|
first_image2: item.firstimage2,
|
|
tel: item.tel,
|
|
last_synced_at: new Date().toISOString(),
|
|
};
|
|
|
|
if (exists) {
|
|
await db.run(`
|
|
UPDATE festivals SET
|
|
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?,
|
|
mapx = ?, mapy = ?, event_start_date = ?, event_end_date = ?,
|
|
first_image = ?, first_image2 = ?, tel = ?, last_synced_at = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE content_id = ?
|
|
`, [
|
|
data.title, data.addr1, data.addr2, data.area_code, data.sigungu_code,
|
|
data.mapx, data.mapy, data.event_start_date, data.event_end_date,
|
|
data.first_image, data.first_image2, data.tel, data.last_synced_at,
|
|
data.content_id
|
|
]);
|
|
} else {
|
|
await db.run(`
|
|
INSERT INTO festivals (
|
|
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code,
|
|
mapx, mapy, event_start_date, event_end_date, first_image, first_image2,
|
|
tel, last_synced_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
data.content_id, data.content_type_id, data.title, data.addr1, data.addr2,
|
|
data.area_code, data.sigungu_code, data.mapx, data.mapy, data.event_start_date,
|
|
data.event_end_date, data.first_image, data.first_image2, data.tel,
|
|
data.last_synced_at
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 축제 상세 정보 가져오기
|
|
async fetchFestivalDetail(contentId) {
|
|
const [common, intro] = await Promise.all([
|
|
this.tourApi.detailCommon(contentId),
|
|
this.tourApi.detailIntro(contentId, '15'),
|
|
]);
|
|
|
|
const commonItem = common.items[0] || {};
|
|
const introItem = intro.items[0] || {};
|
|
|
|
await db.run(`
|
|
UPDATE festivals SET
|
|
overview = ?, homepage = ?, place = ?, place_info = ?,
|
|
play_time = ?, program = ?, use_fee = ?, age_limit = ?,
|
|
sponsor1 = ?, sponsor1_tel = ?, sponsor2 = ?, sponsor2_tel = ?,
|
|
sub_event = ?, booking_place = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE content_id = ?
|
|
`, [
|
|
commonItem.overview,
|
|
commonItem.homepage,
|
|
introItem.eventplace,
|
|
introItem.placeinfo,
|
|
introItem.playtime,
|
|
introItem.program,
|
|
introItem.usetimefestival,
|
|
introItem.agelimit,
|
|
introItem.sponsor1,
|
|
introItem.sponsor1tel,
|
|
introItem.sponsor2,
|
|
introItem.sponsor2tel,
|
|
introItem.subevent,
|
|
introItem.bookingplace,
|
|
contentId
|
|
]);
|
|
}
|
|
|
|
// 활성 축제 조회
|
|
async getActiveFestivals(options = {}) {
|
|
const {
|
|
areaCode = null,
|
|
limit = 20,
|
|
offset = 0,
|
|
} = options;
|
|
|
|
const today = this.getTodayString();
|
|
|
|
let query = `
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_end_date >= ?
|
|
`;
|
|
const params = [today];
|
|
|
|
if (areaCode) {
|
|
query += ' AND area_code = ?';
|
|
params.push(areaCode);
|
|
}
|
|
|
|
query += ' ORDER BY event_start_date ASC LIMIT ? OFFSET ?';
|
|
params.push(limit, offset);
|
|
|
|
return db.all(query, params);
|
|
}
|
|
|
|
// 진행중인 축제 조회
|
|
async getOngoingFestivals(areaCode = null) {
|
|
const today = this.getTodayString();
|
|
|
|
let query = `
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_start_date <= ?
|
|
AND event_end_date >= ?
|
|
`;
|
|
const params = [today, today];
|
|
|
|
if (areaCode) {
|
|
query += ' AND area_code = ?';
|
|
params.push(areaCode);
|
|
}
|
|
|
|
query += ' ORDER BY event_end_date ASC';
|
|
|
|
return db.all(query, params);
|
|
}
|
|
|
|
// 유틸리티 함수
|
|
getTodayString() {
|
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
}
|
|
|
|
getDateAfterMonths(months) {
|
|
const date = new Date();
|
|
date.setMonth(date.getMonth() + months);
|
|
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
|
}
|
|
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|
|
|
|
module.exports = new FestivalService();
|
|
```
|
|
|
|
#### 5.2.3 매칭 서비스
|
|
|
|
```javascript
|
|
// server/services/matchingService.js
|
|
|
|
const db = require('../db');
|
|
|
|
class MatchingService {
|
|
// Haversine 공식으로 두 좌표 간 거리 계산 (km)
|
|
calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371; // 지구 반경 (km)
|
|
const dLat = this.toRad(lat2 - lat1);
|
|
const dLon = this.toRad(lon2 - lon1);
|
|
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
toRad(deg) {
|
|
return deg * (Math.PI / 180);
|
|
}
|
|
|
|
// 거리 → 예상 이동시간 (분) 변환
|
|
estimateTravelTime(distanceKm) {
|
|
// 평균 시속 40km 가정 (국도/지방도 기준)
|
|
const hours = distanceKm / 40;
|
|
return Math.round(hours * 60);
|
|
}
|
|
|
|
// 매칭 점수 계산 (0-100)
|
|
calculateMatchScore(distanceKm, isSameRegion) {
|
|
let score = 100;
|
|
|
|
// 거리에 따른 감점
|
|
if (distanceKm <= 10) score -= 0;
|
|
else if (distanceKm <= 20) score -= 10;
|
|
else if (distanceKm <= 30) score -= 20;
|
|
else if (distanceKm <= 50) score -= 35;
|
|
else if (distanceKm <= 80) score -= 50;
|
|
else score -= 70;
|
|
|
|
// 같은 지역이면 보너스
|
|
if (isSameRegion) score += 10;
|
|
|
|
return Math.max(0, Math.min(100, score));
|
|
}
|
|
|
|
// 펜션과 축제 매칭
|
|
async matchPensionToFestivals(pensionId, pensionType = 'public') {
|
|
// 펜션 정보 조회
|
|
const pensionTable = pensionType === 'public' ? 'public_pensions' : 'pension_profiles';
|
|
const pension = await db.get(`SELECT * FROM ${pensionTable} WHERE id = ?`, [pensionId]);
|
|
|
|
if (!pension || !pension.mapx || !pension.mapy) {
|
|
console.log(`펜션 ID ${pensionId}: 좌표 정보 없음`);
|
|
return [];
|
|
}
|
|
|
|
// 활성 축제 조회 (6개월 이내)
|
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
const festivals = await db.all(`
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_end_date >= ?
|
|
AND mapx IS NOT NULL
|
|
AND mapy IS NOT NULL
|
|
`, [today]);
|
|
|
|
const matches = [];
|
|
|
|
for (const festival of festivals) {
|
|
const distance = this.calculateDistance(
|
|
pension.mapy, pension.mapx, // 펜션 위도, 경도
|
|
festival.mapy, festival.mapx // 축제 위도, 경도
|
|
);
|
|
|
|
// 80km 이내만 매칭
|
|
if (distance > 80) continue;
|
|
|
|
const isSameRegion = pension.area_code === festival.area_code;
|
|
const matchType = isSameRegion ? 'same_region' : 'nearby';
|
|
const travelTime = this.estimateTravelTime(distance);
|
|
const matchScore = this.calculateMatchScore(distance, isSameRegion);
|
|
|
|
// 매칭 저장
|
|
await db.run(`
|
|
INSERT OR REPLACE INTO pension_festival_matches
|
|
(pension_id, pension_type, festival_id, distance_km, travel_time_min, match_type, match_score)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, [pensionId, pensionType, festival.id, distance.toFixed(2), travelTime, matchType, matchScore]);
|
|
|
|
matches.push({
|
|
festival,
|
|
distance: distance.toFixed(2),
|
|
travelTime,
|
|
matchType,
|
|
matchScore,
|
|
});
|
|
}
|
|
|
|
return matches.sort((a, b) => a.distance - b.distance);
|
|
}
|
|
|
|
// 축제 주변 펜션 조회
|
|
async getPensionsNearFestival(festivalId, options = {}) {
|
|
const { maxDistance = 50, limit = 20 } = options;
|
|
|
|
const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [festivalId]);
|
|
|
|
if (!festival || !festival.mapx || !festival.mapy) {
|
|
return [];
|
|
}
|
|
|
|
// 이미 계산된 매칭이 있으면 사용
|
|
const cached = await db.all(`
|
|
SELECT pp.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score
|
|
FROM pension_festival_matches m
|
|
JOIN public_pensions pp ON m.pension_id = pp.id AND m.pension_type = 'public'
|
|
WHERE m.festival_id = ? AND m.distance_km <= ?
|
|
ORDER BY m.match_score DESC, m.distance_km ASC
|
|
LIMIT ?
|
|
`, [festivalId, maxDistance, limit]);
|
|
|
|
if (cached.length > 0) {
|
|
return cached;
|
|
}
|
|
|
|
// 캐시 없으면 실시간 계산
|
|
const pensions = await db.all(`
|
|
SELECT * FROM public_pensions
|
|
WHERE mapx IS NOT NULL AND mapy IS NOT NULL
|
|
AND business_status = '영업중'
|
|
`);
|
|
|
|
const results = [];
|
|
|
|
for (const pension of pensions) {
|
|
const distance = this.calculateDistance(
|
|
festival.mapy, festival.mapx,
|
|
pension.mapy, pension.mapx
|
|
);
|
|
|
|
if (distance <= maxDistance) {
|
|
results.push({
|
|
...pension,
|
|
distance_km: distance.toFixed(2),
|
|
travel_time_min: this.estimateTravelTime(distance),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results
|
|
.sort((a, b) => a.distance_km - b.distance_km)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
// 전체 매칭 갱신 (배치)
|
|
async refreshAllMatches() {
|
|
console.log('전체 매칭 갱신 시작...');
|
|
|
|
const pensions = await db.all(`
|
|
SELECT id FROM public_pensions
|
|
WHERE mapx IS NOT NULL AND mapy IS NOT NULL
|
|
`);
|
|
|
|
let processed = 0;
|
|
for (const pension of pensions) {
|
|
await this.matchPensionToFestivals(pension.id, 'public');
|
|
processed++;
|
|
|
|
if (processed % 100 === 0) {
|
|
console.log(`매칭 진행: ${processed}/${pensions.length}`);
|
|
}
|
|
}
|
|
|
|
console.log(`전체 매칭 갱신 완료: ${processed}건`);
|
|
}
|
|
}
|
|
|
|
module.exports = new MatchingService();
|
|
```
|
|
|
|
#### 5.2.4 Geocoding 서비스
|
|
|
|
```javascript
|
|
// server/services/geocodingService.js
|
|
|
|
const axios = require('axios');
|
|
|
|
class GeocodingService {
|
|
constructor() {
|
|
this.kakaoClient = axios.create({
|
|
baseURL: 'https://dapi.kakao.com/v2/local',
|
|
headers: {
|
|
Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// 주소 → 좌표 변환
|
|
async geocode(address) {
|
|
try {
|
|
const response = await this.kakaoClient.get('/search/address.json', {
|
|
params: { query: address },
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents.length) {
|
|
return null;
|
|
}
|
|
|
|
const result = documents[0];
|
|
return {
|
|
mapx: parseFloat(result.x), // 경도
|
|
mapy: parseFloat(result.y), // 위도
|
|
address: result.address_name,
|
|
roadAddress: result.road_address?.address_name,
|
|
};
|
|
} catch (error) {
|
|
console.error('Geocoding 실패:', address, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 좌표 → 주소 변환 (역지오코딩)
|
|
async reverseGeocode(mapx, mapy) {
|
|
try {
|
|
const response = await this.kakaoClient.get('/geo/coord2address.json', {
|
|
params: { x: mapx, y: mapy },
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents.length) {
|
|
return null;
|
|
}
|
|
|
|
const result = documents[0];
|
|
return {
|
|
address: result.address?.address_name,
|
|
roadAddress: result.road_address?.address_name,
|
|
region1: result.address?.region_1depth_name, // 시도
|
|
region2: result.address?.region_2depth_name, // 시군구
|
|
region3: result.address?.region_3depth_name, // 읍면동
|
|
};
|
|
} catch (error) {
|
|
console.error('역지오코딩 실패:', mapx, mapy, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 좌표 → 지역코드 매핑
|
|
async getAreaCodeFromCoords(mapx, mapy) {
|
|
const region = await this.reverseGeocode(mapx, mapy);
|
|
if (!region) return null;
|
|
|
|
// 시도명 → 지역코드 매핑
|
|
const areaCodeMap = {
|
|
'서울': '1', '서울특별시': '1',
|
|
'인천': '2', '인천광역시': '2',
|
|
'대전': '3', '대전광역시': '3',
|
|
'대구': '4', '대구광역시': '4',
|
|
'광주': '5', '광주광역시': '5',
|
|
'부산': '6', '부산광역시': '6',
|
|
'울산': '7', '울산광역시': '7',
|
|
'세종': '8', '세종특별자치시': '8',
|
|
'경기': '31', '경기도': '31',
|
|
'강원': '32', '강원도': '32', '강원특별자치도': '32',
|
|
'충북': '33', '충청북도': '33',
|
|
'충남': '34', '충청남도': '34',
|
|
'경북': '35', '경상북도': '35',
|
|
'경남': '36', '경상남도': '36',
|
|
'전북': '37', '전라북도': '37', '전북특별자치도': '37',
|
|
'전남': '38', '전라남도': '38',
|
|
'제주': '39', '제주특별자치도': '39',
|
|
};
|
|
|
|
return {
|
|
areaCode: areaCodeMap[region.region1] || null,
|
|
sido: region.region1,
|
|
sigungu: region.region2,
|
|
};
|
|
}
|
|
|
|
// 주소에서 시도/시군구 파싱
|
|
parseAddress(address) {
|
|
if (!address) return { sido: null, sigungu: null };
|
|
|
|
const sidoPatterns = [
|
|
/^(서울특별시|서울)/,
|
|
/^(부산광역시|부산)/,
|
|
/^(대구광역시|대구)/,
|
|
/^(인천광역시|인천)/,
|
|
/^(광주광역시|광주)/,
|
|
/^(대전광역시|대전)/,
|
|
/^(울산광역시|울산)/,
|
|
/^(세종특별자치시|세종)/,
|
|
/^(경기도|경기)/,
|
|
/^(강원특별자치도|강원도|강원)/,
|
|
/^(충청북도|충북)/,
|
|
/^(충청남도|충남)/,
|
|
/^(전북특별자치도|전라북도|전북)/,
|
|
/^(전라남도|전남)/,
|
|
/^(경상북도|경북)/,
|
|
/^(경상남도|경남)/,
|
|
/^(제주특별자치도|제주)/,
|
|
];
|
|
|
|
let sido = null;
|
|
let sigungu = null;
|
|
|
|
for (const pattern of sidoPatterns) {
|
|
const match = address.match(pattern);
|
|
if (match) {
|
|
sido = match[1];
|
|
|
|
// 시군구 추출
|
|
const remaining = address.slice(match[0].length).trim();
|
|
const sigunguMatch = remaining.match(/^([가-힣]+[시군구])/);
|
|
if (sigunguMatch) {
|
|
sigungu = sigunguMatch[1];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { sido, sigungu };
|
|
}
|
|
}
|
|
|
|
module.exports = new GeocodingService();
|
|
```
|
|
|
|
---
|
|
|
|
## 6. API 엔드포인트
|
|
|
|
### 6.1 축제 API
|
|
|
|
```javascript
|
|
// server/routes/festivalRoutes.js
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const festivalService = require('../services/festivalService');
|
|
const matchingService = require('../services/matchingService');
|
|
|
|
// 축제 목록 조회
|
|
// GET /api/festivals?areaCode=31&status=ongoing&page=1&limit=20
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const { areaCode, status, page = 1, limit = 20 } = req.query;
|
|
const offset = (page - 1) * limit;
|
|
|
|
let festivals;
|
|
if (status === 'ongoing') {
|
|
festivals = await festivalService.getOngoingFestivals(areaCode);
|
|
} else {
|
|
festivals = await festivalService.getActiveFestivals({
|
|
areaCode,
|
|
limit: parseInt(limit),
|
|
offset,
|
|
});
|
|
}
|
|
|
|
res.json({ success: true, data: festivals });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 축제 상세 조회
|
|
// GET /api/festivals/:id
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]);
|
|
|
|
if (!festival) {
|
|
return res.status(404).json({ success: false, error: '축제를 찾을 수 없습니다' });
|
|
}
|
|
|
|
// 상세 정보 없으면 가져오기
|
|
if (!festival.overview) {
|
|
await festivalService.fetchFestivalDetail(festival.content_id);
|
|
festival = await db.get('SELECT * FROM festivals WHERE id = ?', [req.params.id]);
|
|
}
|
|
|
|
res.json({ success: true, data: festival });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 축제 주변 펜션 조회
|
|
// GET /api/festivals/:id/pensions?maxDistance=50&limit=20
|
|
router.get('/:id/pensions', async (req, res) => {
|
|
try {
|
|
const { maxDistance = 50, limit = 20 } = req.query;
|
|
|
|
const pensions = await matchingService.getPensionsNearFestival(
|
|
req.params.id,
|
|
{ maxDistance: parseFloat(maxDistance), limit: parseInt(limit) }
|
|
);
|
|
|
|
res.json({ success: true, data: pensions });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 월별 축제 조회
|
|
// GET /api/festivals/calendar/:year/:month
|
|
router.get('/calendar/:year/:month', async (req, res) => {
|
|
try {
|
|
const { year, month } = req.params;
|
|
const startDate = `${year}${month.padStart(2, '0')}01`;
|
|
const endDate = `${year}${month.padStart(2, '0')}31`;
|
|
|
|
const festivals = await db.all(`
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_start_date <= ?
|
|
AND event_end_date >= ?
|
|
ORDER BY event_start_date ASC
|
|
`, [endDate, startDate]);
|
|
|
|
res.json({ success: true, data: festivals });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 지역별 축제 통계
|
|
// GET /api/festivals/stats/by-region
|
|
router.get('/stats/by-region', async (req, res) => {
|
|
try {
|
|
const stats = await db.all(`
|
|
SELECT
|
|
area_code,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN event_start_date <= date('now') AND event_end_date >= date('now') THEN 1 ELSE 0 END) as ongoing
|
|
FROM festivals
|
|
WHERE is_active = 1
|
|
GROUP BY area_code
|
|
`);
|
|
|
|
res.json({ success: true, data: stats });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 관리자: 축제 동기화
|
|
// POST /api/admin/festivals/sync
|
|
router.post('/admin/sync', requireAdmin, async (req, res) => {
|
|
try {
|
|
const count = await festivalService.syncFestivals(req.body);
|
|
res.json({ success: true, message: `${count}건 동기화 완료` });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
```
|
|
|
|
### 6.2 공개 펜션 API
|
|
|
|
```javascript
|
|
// server/routes/publicPensionRoutes.js
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const matchingService = require('../services/matchingService');
|
|
|
|
// 지역별 펜션 목록
|
|
// GET /api/public/pensions?sido=경기&sigungu=가평군&page=1&limit=20
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const { sido, sigungu, page = 1, limit = 20 } = req.query;
|
|
const offset = (page - 1) * limit;
|
|
|
|
let query = `
|
|
SELECT * FROM public_pensions
|
|
WHERE business_status = '영업중'
|
|
`;
|
|
const params = [];
|
|
|
|
if (sido) {
|
|
query += ' AND sido LIKE ?';
|
|
params.push(`%${sido}%`);
|
|
}
|
|
|
|
if (sigungu) {
|
|
query += ' AND sigungu LIKE ?';
|
|
params.push(`%${sigungu}%`);
|
|
}
|
|
|
|
query += ' ORDER BY view_count DESC, name ASC LIMIT ? OFFSET ?';
|
|
params.push(parseInt(limit), offset);
|
|
|
|
const pensions = await db.all(query, params);
|
|
|
|
// 총 개수
|
|
let countQuery = `SELECT COUNT(*) as total FROM public_pensions WHERE business_status = '영업중'`;
|
|
if (sido) countQuery += ` AND sido LIKE '%${sido}%'`;
|
|
if (sigungu) countQuery += ` AND sigungu LIKE '%${sigungu}%'`;
|
|
const { total } = await db.get(countQuery);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: pensions,
|
|
pagination: {
|
|
page: parseInt(page),
|
|
limit: parseInt(limit),
|
|
total,
|
|
totalPages: Math.ceil(total / limit)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 펜션 상세 조회
|
|
// GET /api/public/pensions/:id
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const pension = await db.get(
|
|
'SELECT * FROM public_pensions WHERE id = ?',
|
|
[req.params.id]
|
|
);
|
|
|
|
if (!pension) {
|
|
return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' });
|
|
}
|
|
|
|
// 조회수 증가
|
|
await db.run(
|
|
'UPDATE public_pensions SET view_count = view_count + 1 WHERE id = ?',
|
|
[req.params.id]
|
|
);
|
|
|
|
res.json({ success: true, data: pension });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 펜션 검색
|
|
// GET /api/public/pensions/search?q=가평펜션
|
|
router.get('/search', async (req, res) => {
|
|
try {
|
|
const { q, limit = 20 } = req.query;
|
|
|
|
if (!q || q.length < 2) {
|
|
return res.status(400).json({ success: false, error: '검색어는 2자 이상 입력하세요' });
|
|
}
|
|
|
|
const pensions = await db.all(`
|
|
SELECT * FROM public_pensions
|
|
WHERE business_status = '영업중'
|
|
AND (name LIKE ? OR address LIKE ?)
|
|
ORDER BY view_count DESC
|
|
LIMIT ?
|
|
`, [`%${q}%`, `%${q}%`, parseInt(limit)]);
|
|
|
|
res.json({ success: true, data: pensions });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 근처 펜션 조회 (좌표 기반)
|
|
// GET /api/public/pensions/nearby?lat=37.5&lng=127.5&radius=30
|
|
router.get('/nearby', async (req, res) => {
|
|
try {
|
|
const { lat, lng, radius = 30, limit = 20 } = req.query;
|
|
|
|
if (!lat || !lng) {
|
|
return res.status(400).json({ success: false, error: '좌표를 입력하세요' });
|
|
}
|
|
|
|
// 간단한 경계 박스 필터링 (정확한 거리 계산 전 1차 필터)
|
|
const latRange = parseFloat(radius) / 111; // 위도 1도 ≈ 111km
|
|
const lngRange = parseFloat(radius) / 88; // 경도 1도 ≈ 88km (한국 기준)
|
|
|
|
const pensions = await db.all(`
|
|
SELECT * FROM public_pensions
|
|
WHERE business_status = '영업중'
|
|
AND mapy BETWEEN ? AND ?
|
|
AND mapx BETWEEN ? AND ?
|
|
`, [
|
|
parseFloat(lat) - latRange,
|
|
parseFloat(lat) + latRange,
|
|
parseFloat(lng) - lngRange,
|
|
parseFloat(lng) + lngRange,
|
|
]);
|
|
|
|
// 정확한 거리 계산 및 필터링
|
|
const results = pensions
|
|
.map(pension => ({
|
|
...pension,
|
|
distance: matchingService.calculateDistance(
|
|
parseFloat(lat), parseFloat(lng),
|
|
pension.mapy, pension.mapx
|
|
)
|
|
}))
|
|
.filter(p => p.distance <= parseFloat(radius))
|
|
.sort((a, b) => a.distance - b.distance)
|
|
.slice(0, parseInt(limit));
|
|
|
|
res.json({ success: true, data: results });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 펜션 주변 축제 조회
|
|
// GET /api/public/pensions/:id/festivals
|
|
router.get('/:id/festivals', async (req, res) => {
|
|
try {
|
|
const matches = await db.all(`
|
|
SELECT f.*, m.distance_km, m.travel_time_min, m.match_type, m.match_score
|
|
FROM pension_festival_matches m
|
|
JOIN festivals f ON m.festival_id = f.id
|
|
WHERE m.pension_id = ? AND m.pension_type = 'public'
|
|
AND f.event_end_date >= date('now')
|
|
ORDER BY m.match_score DESC, f.event_start_date ASC
|
|
LIMIT 10
|
|
`, [req.params.id]);
|
|
|
|
res.json({ success: true, data: matches });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 지역별 통계
|
|
// GET /api/public/pensions/stats/by-region
|
|
router.get('/stats/by-region', async (req, res) => {
|
|
try {
|
|
const stats = await db.all(`
|
|
SELECT
|
|
sido,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN is_claimed = 1 THEN 1 ELSE 0 END) as claimed
|
|
FROM public_pensions
|
|
WHERE business_status = '영업중'
|
|
GROUP BY sido
|
|
ORDER BY total DESC
|
|
`);
|
|
|
|
res.json({ success: true, data: stats });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
```
|
|
|
|
### 6.3 펜션 클레임 API
|
|
|
|
```javascript
|
|
// server/routes/claimRoutes.js
|
|
|
|
// 내 펜션 찾기 (이름/주소로 검색)
|
|
// GET /api/claim/search?q=OO펜션
|
|
router.get('/search', authenticateToken, async (req, res) => {
|
|
const { q } = req.query;
|
|
|
|
const pensions = await db.all(`
|
|
SELECT id, name, address, thumbnail, is_claimed, claimed_by
|
|
FROM public_pensions
|
|
WHERE business_status = '영업중'
|
|
AND (name LIKE ? OR address LIKE ?)
|
|
LIMIT 20
|
|
`, [`%${q}%`, `%${q}%`]);
|
|
|
|
res.json({ success: true, data: pensions });
|
|
});
|
|
|
|
// 펜션 클레임 신청
|
|
// POST /api/claim
|
|
router.post('/', authenticateToken, async (req, res) => {
|
|
const { public_pension_id, verification_method, verification_data } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
// 이미 클레임된 펜션인지 확인
|
|
const pension = await db.get(
|
|
'SELECT * FROM public_pensions WHERE id = ?',
|
|
[public_pension_id]
|
|
);
|
|
|
|
if (!pension) {
|
|
return res.status(404).json({ success: false, error: '펜션을 찾을 수 없습니다' });
|
|
}
|
|
|
|
if (pension.is_claimed) {
|
|
return res.status(400).json({ success: false, error: '이미 인증된 펜션입니다' });
|
|
}
|
|
|
|
// 중복 신청 확인
|
|
const existingClaim = await db.get(`
|
|
SELECT * FROM pension_claims
|
|
WHERE public_pension_id = ? AND user_id = ? AND status = 'pending'
|
|
`, [public_pension_id, userId]);
|
|
|
|
if (existingClaim) {
|
|
return res.status(400).json({ success: false, error: '이미 신청 중입니다' });
|
|
}
|
|
|
|
// 클레임 신청 저장
|
|
await db.run(`
|
|
INSERT INTO pension_claims (public_pension_id, user_id, verification_method, verification_data)
|
|
VALUES (?, ?, ?, ?)
|
|
`, [public_pension_id, userId, verification_method, JSON.stringify(verification_data)]);
|
|
|
|
res.json({ success: true, message: '인증 신청이 완료되었습니다' });
|
|
});
|
|
|
|
// 클레임 승인 (관리자)
|
|
// POST /api/admin/claims/:id/approve
|
|
router.post('/admin/claims/:id/approve', requireAdmin, async (req, res) => {
|
|
const claim = await db.get('SELECT * FROM pension_claims WHERE id = ?', [req.params.id]);
|
|
|
|
if (!claim) {
|
|
return res.status(404).json({ success: false, error: '신청을 찾을 수 없습니다' });
|
|
}
|
|
|
|
// 클레임 승인
|
|
await db.run(`
|
|
UPDATE pension_claims
|
|
SET status = 'approved', reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`, [req.user.id, req.params.id]);
|
|
|
|
// 펜션 소유권 설정
|
|
await db.run(`
|
|
UPDATE public_pensions
|
|
SET is_claimed = 1, claimed_by = ?, claimed_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`, [claim.user_id, claim.public_pension_id]);
|
|
|
|
// 사용자 pension_profiles와 연결
|
|
await db.run(`
|
|
UPDATE pension_profiles
|
|
SET public_pension_id = ?
|
|
WHERE user_id = ? AND is_default = 1
|
|
`, [claim.public_pension_id, claim.user_id]);
|
|
|
|
res.json({ success: true, message: '승인 완료' });
|
|
});
|
|
```
|
|
|
|
### 6.4 API 엔드포인트 요약
|
|
|
|
| 메서드 | 엔드포인트 | 설명 | 인증 |
|
|
|-------|-----------|------|------|
|
|
| **축제** |
|
|
| GET | `/api/festivals` | 축제 목록 | - |
|
|
| GET | `/api/festivals/:id` | 축제 상세 | - |
|
|
| GET | `/api/festivals/:id/pensions` | 축제 주변 펜션 | - |
|
|
| GET | `/api/festivals/calendar/:year/:month` | 월별 축제 | - |
|
|
| GET | `/api/festivals/stats/by-region` | 지역별 통계 | - |
|
|
| POST | `/api/admin/festivals/sync` | 축제 동기화 | 관리자 |
|
|
| **펜션** |
|
|
| GET | `/api/public/pensions` | 펜션 목록 | - |
|
|
| GET | `/api/public/pensions/:id` | 펜션 상세 | - |
|
|
| GET | `/api/public/pensions/search` | 펜션 검색 | - |
|
|
| GET | `/api/public/pensions/nearby` | 근처 펜션 | - |
|
|
| GET | `/api/public/pensions/:id/festivals` | 펜션 주변 축제 | - |
|
|
| GET | `/api/public/pensions/stats/by-region` | 지역별 통계 | - |
|
|
| **클레임** |
|
|
| GET | `/api/claim/search` | 내 펜션 찾기 | 로그인 |
|
|
| POST | `/api/claim` | 클레임 신청 | 로그인 |
|
|
| POST | `/api/admin/claims/:id/approve` | 클레임 승인 | 관리자 |
|
|
|
|
---
|
|
|
|
## 7. 프론트엔드 설계
|
|
|
|
### 7.1 새로운 페이지 구조
|
|
|
|
```
|
|
src/pages/
|
|
├── FestivalsPage.tsx # 축제 검색/목록
|
|
├── FestivalDetailPage.tsx # 축제 상세 + 주변 펜션
|
|
├── RegionalPensionsPage.tsx # 지역별 펜션 목록
|
|
├── PensionClaimPage.tsx # 펜션 인증 신청
|
|
└── LandingPage.tsx # (수정) 축제/펜션 섹션 추가
|
|
|
|
components/
|
|
├── festival/
|
|
│ ├── FestivalCard.tsx # 축제 카드
|
|
│ ├── FestivalCalendar.tsx # 월별 축제 캘린더
|
|
│ ├── FestivalFilter.tsx # 지역/기간 필터
|
|
│ └── FestivalMap.tsx # 축제 지도 표시
|
|
├── pension/
|
|
│ ├── PublicPensionCard.tsx # 공개 펜션 카드
|
|
│ ├── PensionSearchBar.tsx # 펜션 검색
|
|
│ ├── RegionalPensionGrid.tsx # 지역별 펜션 그리드
|
|
│ └── DistanceBadge.tsx # "차량 30분" 배지
|
|
├── match/
|
|
│ ├── FestivalPensionMatch.tsx # 축제-펜션 매칭 표시
|
|
│ └── NearbyFestivals.tsx # 펜션 상세 내 축제 목록
|
|
└── landing/
|
|
├── FestivalCarousel.tsx # 랜딩 축제 캐러셀
|
|
└── RegionalSection.tsx # 랜딩 지역별 섹션
|
|
```
|
|
|
|
### 7.2 랜딩페이지 수정
|
|
|
|
```tsx
|
|
// src/pages/LandingPage.tsx - 추가 섹션
|
|
|
|
// 실시간 축제 섹션
|
|
<section className="festivals-section">
|
|
<h2>🎪 이번 달 전국 축제</h2>
|
|
<p>축제도 보고, 펜션에서 쉬어가세요!</p>
|
|
|
|
<FestivalCarousel festivals={ongoingFestivals} />
|
|
|
|
<div className="festival-cta">
|
|
<Link to="/festivals">모든 축제 보기 →</Link>
|
|
</div>
|
|
</section>
|
|
|
|
// 지역별 펜션 섹션
|
|
<section className="regional-pensions-section">
|
|
<h2>🏡 전국 {totalPensionCount.toLocaleString()}개 펜션</h2>
|
|
|
|
<div className="region-grid">
|
|
{regionStats.map(region => (
|
|
<RegionCard
|
|
key={region.sido}
|
|
sido={region.sido}
|
|
pensionCount={region.total}
|
|
festivalCount={region.festivalCount}
|
|
thumbnail={region.thumbnail}
|
|
onClick={() => navigate(`/pensions?sido=${region.sido}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="owner-cta">
|
|
<h3>우리 펜션도 등록하고 싶으신가요?</h3>
|
|
<p>전국 축제와 연계한 마케팅 영상을 무료로 만들어보세요!</p>
|
|
<Link to="/register" className="cta-button">
|
|
무료로 시작하기
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
```
|
|
|
|
### 7.3 축제 상세 페이지
|
|
|
|
```tsx
|
|
// src/pages/FestivalDetailPage.tsx
|
|
|
|
const FestivalDetailPage = () => {
|
|
const { id } = useParams();
|
|
const [festival, setFestival] = useState(null);
|
|
const [nearbyPensions, setNearbyPensions] = useState([]);
|
|
const [activeTab, setActiveTab] = useState('info'); // info | pensions | map
|
|
|
|
useEffect(() => {
|
|
fetchFestival(id);
|
|
fetchNearbyPensions(id);
|
|
}, [id]);
|
|
|
|
return (
|
|
<div className="festival-detail">
|
|
{/* 헤더 */}
|
|
<div className="festival-header" style={{ backgroundImage: `url(${festival.first_image})` }}>
|
|
<div className="overlay">
|
|
<h1>{festival.title}</h1>
|
|
<div className="meta">
|
|
<span className="date">
|
|
📅 {formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)}
|
|
</span>
|
|
<span className="location">📍 {festival.addr1}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 네비게이션 */}
|
|
<div className="tabs">
|
|
<button onClick={() => setActiveTab('info')}>축제 정보</button>
|
|
<button onClick={() => setActiveTab('pensions')}>
|
|
주변 숙소 <span className="badge">{nearbyPensions.length}</span>
|
|
</button>
|
|
<button onClick={() => setActiveTab('map')}>지도</button>
|
|
</div>
|
|
|
|
{/* 탭 콘텐츠 */}
|
|
{activeTab === 'info' && (
|
|
<div className="tab-content">
|
|
<section className="overview">
|
|
<h2>축제 소개</h2>
|
|
<p>{festival.overview}</p>
|
|
</section>
|
|
|
|
<section className="details">
|
|
<div className="detail-item">
|
|
<label>개최장소</label>
|
|
<span>{festival.place}</span>
|
|
</div>
|
|
<div className="detail-item">
|
|
<label>이용요금</label>
|
|
<span>{festival.use_fee || '무료'}</span>
|
|
</div>
|
|
<div className="detail-item">
|
|
<label>문의</label>
|
|
<span>{festival.tel}</span>
|
|
</div>
|
|
{festival.homepage && (
|
|
<div className="detail-item">
|
|
<label>홈페이지</label>
|
|
<a href={festival.homepage} target="_blank">바로가기</a>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'pensions' && (
|
|
<div className="tab-content">
|
|
<h2>축제장 근처 펜션</h2>
|
|
<p>축제 후 편하게 쉴 수 있는 주변 숙소입니다</p>
|
|
|
|
<div className="pension-grid">
|
|
{nearbyPensions.map(pension => (
|
|
<PublicPensionCard
|
|
key={pension.id}
|
|
pension={pension}
|
|
distance={pension.distance_km}
|
|
travelTime={pension.travel_time_min}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'map' && (
|
|
<div className="tab-content">
|
|
<FestivalMap
|
|
festival={festival}
|
|
pensions={nearbyPensions}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 7.4 컴포넌트 설계
|
|
|
|
```tsx
|
|
// components/festival/FestivalCard.tsx
|
|
interface FestivalCardProps {
|
|
festival: Festival;
|
|
variant?: 'default' | 'compact' | 'featured';
|
|
}
|
|
|
|
const FestivalCard = ({ festival, variant = 'default' }: FestivalCardProps) => {
|
|
const isOngoing = isFestivalOngoing(festival);
|
|
|
|
return (
|
|
<div className={`festival-card ${variant}`}>
|
|
<div className="thumbnail">
|
|
<img src={festival.first_image || '/default-festival.jpg'} alt={festival.title} />
|
|
{isOngoing && <span className="badge ongoing">진행중</span>}
|
|
</div>
|
|
<div className="content">
|
|
<h3>{festival.title}</h3>
|
|
<p className="date">
|
|
{formatDate(festival.event_start_date)} ~ {formatDate(festival.event_end_date)}
|
|
</p>
|
|
<p className="location">{festival.addr1}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// components/pension/DistanceBadge.tsx
|
|
interface DistanceBadgeProps {
|
|
distanceKm: number;
|
|
travelTimeMin: number;
|
|
}
|
|
|
|
const DistanceBadge = ({ distanceKm, travelTimeMin }: DistanceBadgeProps) => {
|
|
let variant = 'far';
|
|
if (distanceKm <= 15) variant = 'very-close';
|
|
else if (distanceKm <= 30) variant = 'close';
|
|
else if (distanceKm <= 50) variant = 'medium';
|
|
|
|
return (
|
|
<span className={`distance-badge ${variant}`}>
|
|
🚗 차량 {travelTimeMin}분 ({distanceKm}km)
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// components/match/FestivalPensionMatch.tsx
|
|
const FestivalPensionMatch = ({ festival, pension, distance }) => {
|
|
return (
|
|
<div className="match-card">
|
|
<div className="match-header">
|
|
<span className="match-type">🎪 축제 연계 숙소</span>
|
|
<DistanceBadge distanceKm={distance.km} travelTimeMin={distance.time} />
|
|
</div>
|
|
<div className="match-content">
|
|
<div className="festival-info">
|
|
<h4>{festival.title}</h4>
|
|
<p>{formatDateRange(festival.event_start_date, festival.event_end_date)}</p>
|
|
</div>
|
|
<div className="pension-info">
|
|
<h4>{pension.name}</h4>
|
|
<p>{pension.address}</p>
|
|
</div>
|
|
</div>
|
|
<div className="match-cta">
|
|
<p>축제도 즐기고, 펜션에서 편하게 쉬어가세요!</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 8. AI 콘텐츠 융합
|
|
|
|
### 8.1 Gemini 프롬프트 확장
|
|
|
|
```javascript
|
|
// server/geminiBackendService.js
|
|
|
|
async function generateContentWithFestival(pensionInfo, nearbyFestivals) {
|
|
// 축제 정보 컨텍스트 생성
|
|
const festivalContext = nearbyFestivals.length > 0 ? `
|
|
|
|
🎪 인근에서 열리는 축제 정보:
|
|
${nearbyFestivals.slice(0, 3).map(f => `
|
|
- 축제명: ${f.festival.title}
|
|
- 기간: ${formatDate(f.festival.event_start_date)} ~ ${formatDate(f.festival.event_end_date)}
|
|
- 장소: ${f.festival.addr1}
|
|
- 거리: 차량 약 ${f.travel_time_min}분 (${f.distance_km}km)
|
|
`).join('\n')}
|
|
|
|
위 축제와 연계하여 "축제도 즐기고 펜션에서 힐링"하는 컨셉을 자연스럽게 녹여주세요.
|
|
` : '';
|
|
|
|
// 확장된 프롬프트
|
|
const prompt = `
|
|
당신은 감성적인 펜션 홍보 콘텐츠를 만드는 전문 카피라이터입니다.
|
|
|
|
📍 펜션 정보:
|
|
- 이름: ${pensionInfo.name}
|
|
- 위치: ${pensionInfo.address}
|
|
- 특징: ${pensionInfo.features?.join(', ') || '자연 속 힐링'}
|
|
- 타겟: ${pensionInfo.targetCustomers?.join(', ') || '가족, 커플'}
|
|
|
|
${festivalContext}
|
|
|
|
위 정보를 바탕으로 다음을 생성해주세요:
|
|
|
|
1. 홍보 문구 (30자 이내)
|
|
2. 상세 설명 (100자 이내)
|
|
3. 해시태그 5개
|
|
|
|
JSON 형식으로 응답해주세요.
|
|
`;
|
|
|
|
return await gemini.generateContent(prompt);
|
|
}
|
|
```
|
|
|
|
### 8.2 로고송 가사에 축제 반영
|
|
|
|
```javascript
|
|
// 축제 연계 가사 생성
|
|
async function generateLyricsWithFestival(pensionInfo, festival) {
|
|
const prompt = `
|
|
🎵 펜션 로고송 가사 작성
|
|
|
|
펜션: ${pensionInfo.name}
|
|
위치: ${pensionInfo.address}
|
|
|
|
🎪 인근 축제: ${festival.title}
|
|
축제 기간: ${festival.event_start_date} ~ ${festival.event_end_date}
|
|
|
|
요청사항:
|
|
1. 펜션의 매력을 살리면서
|
|
2. 인근 축제와 연계한 즐거움을 담아
|
|
3. "축제 보러 왔다가 펜션에서 쉬어가요" 느낌의
|
|
4. 밝고 경쾌한 로고송 가사를 작성해주세요
|
|
|
|
가사 형식:
|
|
- 1절: 펜션 소개 + 축제 언급
|
|
- 후렴: 기억에 남는 펜션 이름 반복
|
|
- 2절: 축제의 즐거움 + 펜션에서의 휴식
|
|
|
|
총 150자 내외로 작성해주세요.
|
|
`;
|
|
|
|
return await gemini.generateContent(prompt);
|
|
}
|
|
```
|
|
|
|
### 8.3 영상 내 축제 정보 표시
|
|
|
|
```javascript
|
|
// 영상 렌더링 시 축제 정보 오버레이
|
|
const videoConfig = {
|
|
// ... 기존 설정
|
|
|
|
festivalOverlay: nearbyFestival ? {
|
|
enabled: true,
|
|
position: 'bottom-right',
|
|
showAt: 45, // 45초 지점
|
|
duration: 5, // 5초간 표시
|
|
content: {
|
|
badge: '🎪 인근 축제',
|
|
title: nearbyFestival.title,
|
|
date: `${nearbyFestival.event_start_date} ~ ${nearbyFestival.event_end_date}`,
|
|
distance: `차량 ${nearbyFestival.travel_time_min}분`,
|
|
},
|
|
} : null,
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 구현 로드맵
|
|
|
|
### Phase 1: 기반 구축 (1주)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| API 키 발급 | TourAPI, 카카오 Local API | - |
|
|
| DB 스키마 생성 | 테이블 생성, 인덱스, 초기 데이터 | Backend |
|
|
| TourAPI 클라이언트 | 기본 API 연동 | Backend |
|
|
| Geocoding 서비스 | 주소 → 좌표 변환 | Backend |
|
|
|
|
**산출물:**
|
|
- [ ] `server/services/tourApiClient.js`
|
|
- [ ] `server/services/geocodingService.js`
|
|
- [ ] DB 마이그레이션 스크립트
|
|
|
|
### Phase 2: 데이터 수집 (1주)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 축제 동기화 | TourAPI → festivals 테이블 | Backend |
|
|
| 펜션 데이터 수집 | 공공데이터 → public_pensions | Backend |
|
|
| 데이터 정제 | 중복 제거, 좌표 보정 | Backend |
|
|
| 정기 동기화 설정 | Cron job 설정 | DevOps |
|
|
|
|
**산출물:**
|
|
- [ ] `server/services/festivalService.js`
|
|
- [ ] `server/services/pensionDataService.js`
|
|
- [ ] `server/scripts/syncFestivals.js`
|
|
- [ ] `server/scripts/syncPensions.js`
|
|
|
|
### Phase 3: 매칭 시스템 (1주)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 거리 계산 서비스 | Haversine 공식 구현 | Backend |
|
|
| 매칭 로직 구현 | 펜션-축제 자동 매칭 | Backend |
|
|
| 매칭 API | 엔드포인트 구현 | Backend |
|
|
| 배치 매칭 | 전체 데이터 매칭 실행 | Backend |
|
|
|
|
**산출물:**
|
|
- [ ] `server/services/matchingService.js`
|
|
- [ ] `server/routes/festivalRoutes.js`
|
|
- [ ] `server/routes/publicPensionRoutes.js`
|
|
|
|
### Phase 4: 프론트엔드 - 기본 (1주)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 축제 목록 페이지 | 검색, 필터, 목록 | Frontend |
|
|
| 축제 상세 페이지 | 정보, 주변 펜션 | Frontend |
|
|
| 펜션 목록 페이지 | 지역별 필터 | Frontend |
|
|
| 공통 컴포넌트 | 카드, 배지, 필터 | Frontend |
|
|
|
|
**산출물:**
|
|
- [ ] `src/pages/FestivalsPage.tsx`
|
|
- [ ] `src/pages/FestivalDetailPage.tsx`
|
|
- [ ] `src/pages/RegionalPensionsPage.tsx`
|
|
- [ ] `components/festival/*`
|
|
- [ ] `components/pension/*`
|
|
|
|
### Phase 5: 프론트엔드 - 랜딩페이지 (3일)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 축제 섹션 추가 | 캐러셀, 진행중 축제 | Frontend |
|
|
| 지역별 펜션 섹션 | 지역 그리드, 통계 | Frontend |
|
|
| 가입 유도 CTA | 펜션 주인 타겟 | Frontend |
|
|
|
|
**산출물:**
|
|
- [ ] `components/landing/FestivalCarousel.tsx`
|
|
- [ ] `components/landing/RegionalSection.tsx`
|
|
- [ ] LandingPage.tsx 수정
|
|
|
|
### Phase 6: 펜션 클레임 시스템 (3일)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 클레임 API | 신청, 승인 API | Backend |
|
|
| 클레임 페이지 | 펜션 검색, 인증 신청 | Frontend |
|
|
| 관리자 페이지 | 클레임 승인 관리 | Frontend |
|
|
|
|
**산출물:**
|
|
- [ ] `server/routes/claimRoutes.js`
|
|
- [ ] `src/pages/PensionClaimPage.tsx`
|
|
- [ ] AdminDashboard 클레임 관리 탭
|
|
|
|
### Phase 7: AI 콘텐츠 융합 (3일)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| Gemini 프롬프트 확장 | 축제 정보 포함 | Backend |
|
|
| 로고송 연동 | 축제 반영 가사 | Backend |
|
|
| 영상 오버레이 | 축제 정보 표시 | Backend |
|
|
|
|
**산출물:**
|
|
- [ ] geminiBackendService.js 수정
|
|
- [ ] 영상 템플릿 수정
|
|
|
|
### Phase 8: 테스트 및 최적화 (3일)
|
|
|
|
| 작업 | 상세 | 담당 |
|
|
|-----|------|------|
|
|
| 통합 테스트 | E2E 테스트 | QA |
|
|
| 성능 최적화 | DB 쿼리, 캐싱 | Backend |
|
|
| 모바일 대응 | 반응형 점검 | Frontend |
|
|
| 배포 | 프로덕션 배포 | DevOps |
|
|
|
|
---
|
|
|
|
## 10. 기술 스택
|
|
|
|
### 10.1 추가 기술 스택
|
|
|
|
| 구분 | 기술 | 용도 |
|
|
|-----|------|------|
|
|
| 외부 API | TourAPI 4.0 | 축제/숙박 데이터 |
|
|
| 외부 API | 카카오 Local API | Geocoding |
|
|
| 외부 API | 카카오맵 SDK | 지도 표시 |
|
|
| 스케줄러 | node-cron | 정기 동기화 |
|
|
| 캐싱 | node-cache | API 응답 캐싱 |
|
|
|
|
### 10.2 환경 변수 추가
|
|
|
|
```env
|
|
# .env 추가 항목
|
|
|
|
# TourAPI (공공데이터포털)
|
|
TOURAPI_KEY=발급받은_서비스키
|
|
|
|
# 카카오 API
|
|
KAKAO_REST_KEY=카카오_REST_API_키
|
|
KAKAO_JS_KEY=카카오_JavaScript_키
|
|
|
|
# 동기화 설정
|
|
FESTIVAL_SYNC_ENABLED=true
|
|
FESTIVAL_SYNC_CRON=0 3 * * 0 # 매주 일요일 새벽 3시
|
|
PENSION_SYNC_ENABLED=true
|
|
PENSION_SYNC_CRON=0 4 1 * * # 매월 1일 새벽 4시
|
|
```
|
|
|
|
### 10.3 NPM 패키지 추가
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"node-cron": "^3.0.3",
|
|
"node-cache": "^5.1.2",
|
|
"csv-parser": "^3.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 부록 A: TourAPI 지역 코드표
|
|
|
|
| 코드 | 지역명 | 코드 | 지역명 |
|
|
|-----|-------|-----|-------|
|
|
| 1 | 서울특별시 | 31 | 경기도 |
|
|
| 2 | 인천광역시 | 32 | 강원특별자치도 |
|
|
| 3 | 대전광역시 | 33 | 충청북도 |
|
|
| 4 | 대구광역시 | 34 | 충청남도 |
|
|
| 5 | 광주광역시 | 35 | 경상북도 |
|
|
| 6 | 부산광역시 | 36 | 경상남도 |
|
|
| 7 | 울산광역시 | 37 | 전북특별자치도 |
|
|
| 8 | 세종특별자치시 | 38 | 전라남도 |
|
|
| | | 39 | 제주특별자치도 |
|
|
|
|
---
|
|
|
|
## 부록 B: 참고 링크
|
|
|
|
- [TourAPI 공식 사이트](https://api.visitkorea.or.kr/)
|
|
- [공공데이터포털 - 국문관광정보서비스](https://www.data.go.kr/data/15101578/openapi.do)
|
|
- [전국관광펜션업소표준데이터](https://www.data.go.kr/data/15013105/standard.do)
|
|
- [카카오 개발자 - Local API](https://developers.kakao.com/docs/latest/ko/local/dev-guide)
|
|
- [카카오맵 SDK](https://apis.map.kakao.com/web/)
|
|
|
|
---
|
|
|
|
**문서 끝**
|