/** * 축제 서비스 * 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;