/** * 리스크/알림 서비스 * - 기상청 특보 API * - 국토교통부 교통사고/도로공사 API 연동 */ import axios from 'axios'; export interface Alert { id: string; type: 'accident' | 'weather' | 'construction'; severity: 'high' | 'medium' | 'low'; title: string; location: string; description: string; timestamp: string; } export class RiskAlertService { /** * 기상청 특보 정보 조회 (기상청 API 허브 - 현재 발효 중인 특보 API) */ async getWeatherAlerts(): Promise { try { const apiKey = process.env.KMA_API_KEY; if (!apiKey) { console.log('⚠️ 기상청 API 키가 없습니다. 빈 데이터를 반환합니다.'); return []; } const alerts: Alert[] = []; // 기상청 특보 현황 조회 API (실제 발효 중인 특보) try { const warningUrl = 'https://apihub.kma.go.kr/api/typ01/url/wrn_now_data.php'; const warningResponse = await axios.get(warningUrl, { params: { fe: 'f', // 발표 중인 특보 tm: '', // 현재 시각 disp: 0, authKey: apiKey, }, timeout: 30000, // 30초로 증가 responseType: 'arraybuffer', // 인코딩 문제 해결 }); console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); // 텍스트 응답 파싱 (EUC-KR 인코딩) const iconv = require('iconv-lite'); const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); if (typeof responseText === 'string' && responseText.includes('#START7777')) { const lines = responseText.split('\n'); for (const line of lines) { // 주석 및 헤더 라인 무시 if (line.startsWith('#') || line.trim() === '' || line.includes('7777END')) { continue; } // 데이터 라인 파싱 const fields = line.split(',').map((f) => f.trim()); if (fields.length >= 7) { const regUpKo = fields[1]; // 상위 특보 지역명 const regKo = fields[3]; // 특보 지역명 const tmFc = fields[4]; // 발표 시각 const wrnType = fields[6]; // 특보 종류 const wrnLevel = fields[7]; // 특보 수준 (주의보/경보) // 특보 종류별 매핑 const warningMap: Record = { '풍랑': { title: '풍랑주의보', severity: 'medium' }, '강풍': { title: '강풍주의보', severity: 'medium' }, '대설': { title: '대설특보', severity: 'high' }, '폭설': { title: '대설특보', severity: 'high' }, '태풍': { title: '태풍특보', severity: 'high' }, '호우': { title: '호우특보', severity: 'high' }, '한파': { title: '한파특보', severity: 'high' }, '폭염': { title: '폭염특보', severity: 'high' }, '건조': { title: '건조특보', severity: 'low' }, '해일': { title: '해일특보', severity: 'high' }, '너울': { title: '너울주의보', severity: 'low' }, }; const warningInfo = warningMap[wrnType]; if (warningInfo) { // 경보는 심각도 높이기 const severity = wrnLevel.includes('경보') ? 'high' : warningInfo.severity; const title = wrnLevel.includes('경보') ? wrnType + '경보' : warningInfo.title; alerts.push({ id: `warning-${Date.now()}-${alerts.length}`, type: 'weather' as const, severity: severity, title: title, location: regKo || regUpKo || '전국', description: `${wrnLevel} 발표 - ${regUpKo} ${regKo}`, timestamp: this.parseKmaTime(tmFc), }); } } } } console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`); } catch (warningError: any) { console.error('❌ 기상청 특보 API 오류:', warningError.message); return []; } // 특보가 없으면 빈 배열 반환 (0건) if (alerts.length === 0) { console.log('ℹ️ 현재 발효 중인 기상특보 없음 (0건)'); } return alerts; } catch (error: any) { console.error('❌ 기상청 특보 API 오류:', error.message); // API 오류 시 빈 배열 반환 return []; } } /** * 교통사고 정보 조회 (국토교통부 ITS API 우선, 실패 시 한국도로공사) */ async getAccidentAlerts(): Promise { // 1순위: 국토교통부 ITS API (실시간 돌발정보) const itsApiKey = process.env.ITS_API_KEY; if (itsApiKey) { try { const url = `https://openapi.its.go.kr:9443/eventInfo`; const response = await axios.get(url, { params: { apiKey: itsApiKey, type: 'all', eventType: 'acc', // 교통사고 minX: 124, // 전국 범위 maxX: 132, minY: 33, maxY: 43, getType: 'json', }, timeout: 10000, }); console.log('✅ 국토교통부 ITS 교통사고 API 응답 수신 완료'); const alerts: Alert[] = []; if (response.data?.header?.resultCode === 0 && response.data?.body?.items) { const items = Array.isArray(response.data.body.items) ? response.data.body.items : [response.data.body.items]; items.forEach((item: any, index: number) => { // ITS API 필드: eventType(교통사고), roadName, message, startDate, lanesBlocked const lanesCount = (item.lanesBlocked || '').match(/\d+/)?.[0] || 0; const severity = Number(lanesCount) >= 2 ? 'high' : Number(lanesCount) === 1 ? 'medium' : 'low'; alerts.push({ id: `accident-its-${Date.now()}-${index}`, type: 'accident' as const, severity: severity as 'high' | 'medium' | 'low', title: `[${item.roadName || '고속도로'}] 교통사고`, location: `${item.roadName || ''} ${item.roadDrcType || ''}`.trim() || '정보 없음', description: item.message || `${item.eventDetailType || '사고 발생'} - ${item.lanesBlocked || '차로 통제'}`, timestamp: this.parseITSTime(item.startDate || ''), }); }); } if (alerts.length === 0) { console.log('ℹ️ 현재 교통사고 없음 (0건)'); } else { console.log(`✅ 총 ${alerts.length}건의 교통사고 감지 (ITS)`); } return alerts; } catch (error: any) { console.error('❌ 국토교통부 ITS API 오류:', error.message); console.log('ℹ️ 2순위 API로 전환합니다.'); } } // 2순위: 한국도로공사 API (현재 차단됨) const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; const response = await axios.get(url, { params: { key: exwayApiKey, type: 'json', }, timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://data.ex.co.kr/', }, }); console.log('✅ 한국도로공사 교통예보 API 응답 수신 완료'); const alerts: Alert[] = []; if (response.data?.list) { const items = Array.isArray(response.data.list) ? response.data.list : [response.data.list]; items.forEach((item: any, index: number) => { const contentType = item.conzoneCd || item.contentType || ''; if (contentType === '00' || item.content?.includes('사고')) { const severity = contentType === '31' ? 'high' : contentType === '30' ? 'medium' : 'low'; alerts.push({ id: `accident-exway-${Date.now()}-${index}`, type: 'accident' as const, severity: severity as 'high' | 'medium' | 'low', title: '교통사고', location: item.routeName || item.location || '고속도로', description: item.content || item.message || '교통사고 발생', timestamp: new Date(item.regDate || Date.now()).toISOString(), }); } }); } if (alerts.length > 0) { console.log(`✅ 총 ${alerts.length}건의 교통사고 감지 (한국도로공사)`); return alerts; } } catch (error: any) { console.error('❌ 한국도로공사 API 오류:', error.message); } // 모든 API 실패 시 빈 배열 console.log('ℹ️ 모든 교통사고 API 실패. 빈 배열을 반환합니다.'); return []; } /** * 도로공사 정보 조회 (국토교통부 ITS API 우선, 실패 시 한국도로공사) */ async getRoadworkAlerts(): Promise { // 1순위: 국토교통부 ITS API (실시간 돌발정보 - 공사) const itsApiKey = process.env.ITS_API_KEY; if (itsApiKey) { try { const url = `https://openapi.its.go.kr:9443/eventInfo`; const response = await axios.get(url, { params: { apiKey: itsApiKey, type: 'all', eventType: 'all', // 전체 조회 후 필터링 minX: 124, maxX: 132, minY: 33, maxY: 43, getType: 'json', }, timeout: 10000, }); console.log('✅ 국토교통부 ITS 도로공사 API 응답 수신 완료'); const alerts: Alert[] = []; if (response.data?.header?.resultCode === 0 && response.data?.body?.items) { const items = Array.isArray(response.data.body.items) ? response.data.body.items : [response.data.body.items]; items.forEach((item: any, index: number) => { // 공사/작업만 필터링 if (item.eventType === '공사' || item.eventDetailType === '작업') { const lanesCount = (item.lanesBlocked || '').match(/\d+/)?.[0] || 0; const severity = Number(lanesCount) >= 2 ? 'high' : 'medium'; alerts.push({ id: `construction-its-${Date.now()}-${index}`, type: 'construction' as const, severity: severity as 'high' | 'medium' | 'low', title: `[${item.roadName || '고속도로'}] 도로 공사`, location: `${item.roadName || ''} ${item.roadDrcType || ''}`.trim() || '정보 없음', description: item.message || `${item.eventDetailType || '작업'} - ${item.lanesBlocked || '차로 통제'}`, timestamp: this.parseITSTime(item.startDate || ''), }); } }); } if (alerts.length === 0) { console.log('ℹ️ 현재 도로공사 없음 (0건)'); } else { console.log(`✅ 총 ${alerts.length}건의 도로공사 감지 (ITS)`); } return alerts; } catch (error: any) { console.error('❌ 국토교통부 ITS API 오류:', error.message); console.log('ℹ️ 2순위 API로 전환합니다.'); } } // 2순위: 한국도로공사 API const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; const response = await axios.get(url, { params: { key: exwayApiKey, type: 'json', }, timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://data.ex.co.kr/', }, }); console.log('✅ 한국도로공사 교통예보 API 응답 수신 완료 (도로공사)'); const alerts: Alert[] = []; if (response.data?.list) { const items = Array.isArray(response.data.list) ? response.data.list : [response.data.list]; items.forEach((item: any, index: number) => { const contentType = item.conzoneCd || item.contentType || ''; if (contentType === '03' || item.content?.includes('작업') || item.content?.includes('공사')) { const severity = contentType === '31' ? 'high' : contentType === '30' ? 'medium' : 'low'; alerts.push({ id: `construction-exway-${Date.now()}-${index}`, type: 'construction' as const, severity: severity as 'high' | 'medium' | 'low', title: '도로 공사', location: item.routeName || item.location || '고속도로', description: item.content || item.message || '도로 공사 진행 중', timestamp: new Date(item.regDate || Date.now()).toISOString(), }); } }); } if (alerts.length > 0) { console.log(`✅ 총 ${alerts.length}건의 도로공사 감지 (한국도로공사)`); return alerts; } } catch (error: any) { console.error('❌ 한국도로공사 API 오류:', error.message); } // 모든 API 실패 시 빈 배열 console.log('ℹ️ 모든 도로공사 API 실패. 빈 배열을 반환합니다.'); return []; } /** * 전체 알림 조회 (통합) */ async getAllAlerts(): Promise { try { const [weatherAlerts, accidentAlerts, roadworkAlerts] = await Promise.all([ this.getWeatherAlerts(), this.getAccidentAlerts(), this.getRoadworkAlerts(), ]); // 모든 알림 합치기 const allAlerts = [...weatherAlerts, ...accidentAlerts, ...roadworkAlerts]; // 시간 순으로 정렬 (최신순) allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); return allAlerts; } catch (error: any) { console.error('❌ 전체 알림 조회 오류:', error.message); throw error; } } /** * 기상청 시간 형식 파싱 (YYYYMMDDHHmm -> ISO) */ private parseKmaTime(tmFc: string): string { try { if (!tmFc || tmFc.length !== 12) { return new Date().toISOString(); } const year = tmFc.substring(0, 4); const month = tmFc.substring(4, 6); const day = tmFc.substring(6, 8); const hour = tmFc.substring(8, 10); const minute = tmFc.substring(10, 12); return new Date(`${year}-${month}-${day}T${hour}:${minute}:00+09:00`).toISOString(); } catch (error) { return new Date().toISOString(); } } /** * ITS API 시간 형식 파싱 (YYYYMMDDHHmmss -> ISO) */ private parseITSTime(dateStr: string): string { try { if (!dateStr || dateStr.length !== 14) { return new Date().toISOString(); } const year = dateStr.substring(0, 4); const month = dateStr.substring(4, 6); const day = dateStr.substring(6, 8); const hour = dateStr.substring(8, 10); const minute = dateStr.substring(10, 12); const second = dateStr.substring(12, 14); return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}+09:00`).toISOString(); } catch (error) { return new Date().toISOString(); } } /** * 기상 특보 심각도 판단 */ private getWeatherSeverity(wrnLv: string): 'high' | 'medium' | 'low' { if (wrnLv.includes('경보') || wrnLv.includes('특보')) { return 'high'; } if (wrnLv.includes('주의보')) { return 'medium'; } return 'low'; } /** * 기상 특보 제목 생성 */ private getWeatherTitle(wrnLv: string): string { if (wrnLv.includes('대설')) return '대설특보'; if (wrnLv.includes('태풍')) return '태풍특보'; if (wrnLv.includes('강풍')) return '강풍특보'; if (wrnLv.includes('호우')) return '호우특보'; if (wrnLv.includes('한파')) return '한파특보'; if (wrnLv.includes('폭염')) return '폭염특보'; return '기상특보'; } /** * 교통사고 심각도 판단 */ private getAccidentSeverity(accInfo: string): 'high' | 'medium' | 'low' { if (accInfo.includes('중대') || accInfo.includes('다중') || accInfo.includes('추돌')) { return 'high'; } if (accInfo.includes('접촉') || accInfo.includes('경상')) { return 'medium'; } return 'low'; } }