CASTAD-v0.1/server/services/festivalService.js

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;