446 lines
12 KiB
JavaScript
446 lines
12 KiB
JavaScript
/**
|
|
* 축제 서비스
|
|
* TourAPI 축제 데이터 동기화 및 관리
|
|
*/
|
|
|
|
const { tourApiClient, AREA_CODE, AREA_NAME } = require('./tourApiClient');
|
|
|
|
class FestivalService {
|
|
constructor(db) {
|
|
this.db = db;
|
|
}
|
|
|
|
// ============================================
|
|
// 축제 데이터 동기화
|
|
// ============================================
|
|
|
|
/**
|
|
* 전체 축제 동기화 (향후 6개월)
|
|
*/
|
|
async syncAllFestivals() {
|
|
console.log('=== 축제 데이터 동기화 시작 ===');
|
|
|
|
const today = tourApiClient.getTodayString();
|
|
const endDate = tourApiClient.getDateAfterMonths(6);
|
|
|
|
let totalSynced = 0;
|
|
let pageNo = 1;
|
|
|
|
while (true) {
|
|
try {
|
|
const result = await tourApiClient.searchFestival({
|
|
eventStartDate: today,
|
|
eventEndDate: endDate,
|
|
numOfRows: 100,
|
|
pageNo,
|
|
arrange: 'A',
|
|
});
|
|
|
|
if (!result.items || result.items.length === 0) break;
|
|
|
|
// 배열로 변환 (단일 아이템일 경우)
|
|
const items = Array.isArray(result.items) ? result.items : [result.items];
|
|
|
|
for (const item of items) {
|
|
await this.upsertFestival(item);
|
|
totalSynced++;
|
|
}
|
|
|
|
console.log(` - 페이지 ${pageNo}: ${items.length}건 처리`);
|
|
|
|
if (items.length < 100) break;
|
|
pageNo++;
|
|
|
|
// API 호출 제한 대응
|
|
await this.sleep(100);
|
|
} catch (error) {
|
|
console.error(` - 페이지 ${pageNo} 에러:`, error.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.log(`=== 축제 동기화 완료: ${totalSynced}건 ===`);
|
|
return totalSynced;
|
|
}
|
|
|
|
/**
|
|
* 특정 지역 축제 동기화
|
|
*/
|
|
async syncFestivalsByArea(areaCode) {
|
|
console.log(`=== ${AREA_NAME[areaCode] || areaCode} 축제 동기화 ===`);
|
|
|
|
const today = tourApiClient.getTodayString();
|
|
const endDate = tourApiClient.getDateAfterMonths(6);
|
|
|
|
let totalSynced = 0;
|
|
let pageNo = 1;
|
|
|
|
while (true) {
|
|
const result = await tourApiClient.searchFestival({
|
|
eventStartDate: today,
|
|
eventEndDate: endDate,
|
|
areaCode,
|
|
numOfRows: 100,
|
|
pageNo,
|
|
});
|
|
|
|
if (!result.items || result.items.length === 0) break;
|
|
|
|
const items = Array.isArray(result.items) ? result.items : [result.items];
|
|
|
|
for (const item of items) {
|
|
await this.upsertFestival(item);
|
|
totalSynced++;
|
|
}
|
|
|
|
if (items.length < 100) break;
|
|
pageNo++;
|
|
await this.sleep(100);
|
|
}
|
|
|
|
return totalSynced;
|
|
}
|
|
|
|
/**
|
|
* 축제 데이터 저장/업데이트
|
|
*/
|
|
async upsertFestival(item) {
|
|
const exists = await this.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,
|
|
sido: AREA_NAME[item.areacode] || null,
|
|
mapx: item.mapx ? parseFloat(item.mapx) : null,
|
|
mapy: item.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 this.db.run(`
|
|
UPDATE festivals SET
|
|
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?, sido = ?,
|
|
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.sido,
|
|
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
|
|
]);
|
|
return { action: 'updated', id: exists.id };
|
|
} else {
|
|
const result = await this.db.run(`
|
|
INSERT INTO festivals (
|
|
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code, sido,
|
|
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.sido, data.mapx, data.mapy,
|
|
data.event_start_date, data.event_end_date, data.first_image, data.first_image2,
|
|
data.tel, data.last_synced_at
|
|
]);
|
|
return { action: 'inserted', id: result.lastID };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 축제 상세 정보 업데이트
|
|
*/
|
|
async fetchAndUpdateDetail(contentId) {
|
|
try {
|
|
const detail = await tourApiClient.getFestivalDetail(contentId);
|
|
|
|
const { common, intro } = detail;
|
|
|
|
await this.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 = ?
|
|
`, [
|
|
common.overview,
|
|
common.homepage,
|
|
intro.eventplace,
|
|
intro.placeinfo,
|
|
intro.playtime,
|
|
intro.program,
|
|
intro.usetimefestival,
|
|
intro.agelimit,
|
|
intro.sponsor1,
|
|
intro.sponsor1tel,
|
|
intro.sponsor2,
|
|
intro.sponsor2tel,
|
|
intro.subevent,
|
|
intro.bookingplace,
|
|
contentId
|
|
]);
|
|
|
|
return detail;
|
|
} catch (error) {
|
|
console.error(`축제 상세 조회 실패 (${contentId}):`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 축제 조회
|
|
// ============================================
|
|
|
|
/**
|
|
* 활성 축제 목록 (종료되지 않은)
|
|
*/
|
|
async getActiveFestivals(options = {}) {
|
|
const {
|
|
areaCode = null,
|
|
limit = 20,
|
|
offset = 0,
|
|
} = options;
|
|
|
|
const today = tourApiClient.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 this.db.all(query, params);
|
|
}
|
|
|
|
/**
|
|
* 진행중인 축제
|
|
*/
|
|
async getOngoingFestivals(areaCode = null, limit = 20) {
|
|
const today = tourApiClient.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 LIMIT ?';
|
|
params.push(limit);
|
|
|
|
return this.db.all(query, params);
|
|
}
|
|
|
|
/**
|
|
* 예정 축제
|
|
*/
|
|
async getUpcomingFestivals(areaCode = null, limit = 20) {
|
|
const today = tourApiClient.getTodayString();
|
|
|
|
let query = `
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_start_date > ?
|
|
`;
|
|
const params = [today];
|
|
|
|
if (areaCode) {
|
|
query += ' AND area_code = ?';
|
|
params.push(areaCode);
|
|
}
|
|
|
|
query += ' ORDER BY event_start_date ASC LIMIT ?';
|
|
params.push(limit);
|
|
|
|
return this.db.all(query, params);
|
|
}
|
|
|
|
/**
|
|
* 축제 상세 조회
|
|
*/
|
|
async getFestivalById(id) {
|
|
const festival = await this.db.get('SELECT * FROM festivals WHERE id = ?', [id]);
|
|
|
|
if (festival && !festival.overview) {
|
|
// 상세 정보가 없으면 API에서 가져오기
|
|
await this.fetchAndUpdateDetail(festival.content_id);
|
|
return this.db.get('SELECT * FROM festivals WHERE id = ?', [id]);
|
|
}
|
|
|
|
return festival;
|
|
}
|
|
|
|
/**
|
|
* content_id로 축제 조회
|
|
*/
|
|
async getFestivalByContentId(contentId) {
|
|
return this.db.get('SELECT * FROM festivals WHERE content_id = ?', [contentId]);
|
|
}
|
|
|
|
/**
|
|
* 축제 검색
|
|
*/
|
|
async searchFestivals(keyword, limit = 20) {
|
|
return this.db.all(`
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND (title LIKE ? OR addr1 LIKE ?)
|
|
AND event_end_date >= ?
|
|
ORDER BY event_start_date ASC
|
|
LIMIT ?
|
|
`, [`%${keyword}%`, `%${keyword}%`, tourApiClient.getTodayString(), limit]);
|
|
}
|
|
|
|
/**
|
|
* 월별 축제 조회
|
|
*/
|
|
async getFestivalsByMonth(year, month) {
|
|
const startDate = `${year}${String(month).padStart(2, '0')}01`;
|
|
const endDate = `${year}${String(month).padStart(2, '0')}31`;
|
|
|
|
return this.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]);
|
|
}
|
|
|
|
/**
|
|
* 지역별 축제 통계
|
|
*/
|
|
async getFestivalStatsByRegion() {
|
|
const today = tourApiClient.getTodayString();
|
|
|
|
return this.db.all(`
|
|
SELECT
|
|
area_code,
|
|
sido,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN event_start_date <= ? AND event_end_date >= ? THEN 1 ELSE 0 END) as ongoing,
|
|
SUM(CASE WHEN event_start_date > ? THEN 1 ELSE 0 END) as upcoming
|
|
FROM festivals
|
|
WHERE is_active = 1 AND event_end_date >= ?
|
|
GROUP BY area_code
|
|
ORDER BY total DESC
|
|
`, [today, today, today, today]);
|
|
}
|
|
|
|
// ============================================
|
|
// 좌표 기반 조회
|
|
// ============================================
|
|
|
|
/**
|
|
* 좌표 근처 축제 조회 (DB에서)
|
|
*/
|
|
async getNearbyFestivals(mapX, mapY, radiusKm = 50) {
|
|
const today = tourApiClient.getTodayString();
|
|
|
|
// 대략적인 위경도 범위 계산 (1도 ≈ 111km)
|
|
const latRange = radiusKm / 111;
|
|
const lngRange = radiusKm / 88; // 한국 위도 기준
|
|
|
|
const festivals = await this.db.all(`
|
|
SELECT * FROM festivals
|
|
WHERE is_active = 1
|
|
AND event_end_date >= ?
|
|
AND mapx IS NOT NULL AND mapy IS NOT NULL
|
|
AND mapy BETWEEN ? AND ?
|
|
AND mapx BETWEEN ? AND ?
|
|
`, [
|
|
today,
|
|
mapY - latRange, mapY + latRange,
|
|
mapX - lngRange, mapX + lngRange,
|
|
]);
|
|
|
|
// 정확한 거리 계산 및 필터링
|
|
return festivals
|
|
.map(f => ({
|
|
...f,
|
|
distance_km: this.calculateDistance(mapY, mapX, f.mapy, f.mapx),
|
|
}))
|
|
.filter(f => f.distance_km <= radiusKm)
|
|
.sort((a, b) => a.distance_km - b.distance_km);
|
|
}
|
|
|
|
/**
|
|
* 좌표 근처 축제 조회 (API 직접)
|
|
*/
|
|
async getNearbyFestivalsFromApi(mapX, mapY, radius = 20000) {
|
|
return tourApiClient.getNearbyFestivals(mapX, mapY, radius);
|
|
}
|
|
|
|
// ============================================
|
|
// 유틸리티
|
|
// ============================================
|
|
|
|
/**
|
|
* 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 Math.round(R * c * 100) / 100; // 소수점 2자리
|
|
}
|
|
|
|
toRad(deg) {
|
|
return deg * (Math.PI / 180);
|
|
}
|
|
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|
|
|
|
module.exports = FestivalService;
|