/** * 리스크/알림 서비스 * - 기상청 특보 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 interface WeatherData { city: string; country: string; temperature: number; feelsLike: number; humidity: number; pressure: number; weatherMain: string; weatherDescription: string; weatherIcon: string; windSpeed: number; clouds: number; timestamp: string; } export class RiskAlertService { /** * 기상청 특보 정보 조회 (기상청 API 허브 - 현재 발효 중인 특보 API) */ async getWeatherAlerts(): Promise { try { const apiKey = process.env.KMA_API_KEY; if (!apiKey) { console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); return this.generateDummyWeatherAlerts(); } 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: 10000, 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 this.generateDummyWeatherAlerts(); } // 특보가 없으면 빈 배열 반환 (0건) if (alerts.length === 0) { console.log('ℹ️ 현재 발효 중인 기상특보 없음 (0건)'); } return alerts; } catch (error: any) { console.error('❌ 기상청 특보 API 오류:', error.message); // API 오류 시 더미 데이터 반환 return this.generateDummyWeatherAlerts(); } } /** * 교통사고 정보 조회 (국토교통부 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 this.generateDummyAccidentAlerts(); } /** * 도로공사 정보 조회 (국토교통부 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 this.generateDummyRoadworkAlerts(); } /** * 기상청 실시간 날씨 정보 조회 */ async getCurrentWeather(city: string = '서울'): Promise { try { const apiKey = process.env.KMA_API_KEY; if (!apiKey) { console.log('⚠️ 기상청 API 키가 없습니다. 근사 데이터를 반환합니다.'); return this.generateRealisticWeatherData(city); } // 도시명 → 관측소 코드 매핑 const regionCode = this.getKMARegionCode(city); if (!regionCode) { console.log(`⚠️ 지원하지 않는 지역: ${city}. 근사 데이터를 반환합니다.`); return this.generateRealisticWeatherData(city); } // 기상청 API Hub - 지상관측시간자료 const now = new Date(); const minute = now.getMinutes(); let targetTime = new Date(now); // 데이터가 업데이트되지 않았으면 이전 시간으로 if (minute < 10) { targetTime = new Date(now.getTime() - 60 * 60 * 1000); } const year = targetTime.getFullYear(); const month = String(targetTime.getMonth() + 1).padStart(2, '0'); const day = String(targetTime.getDate()).padStart(2, '0'); const hour = String(targetTime.getHours()).padStart(2, '0'); const tm = `${year}${month}${day}${hour}00`; const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php'; console.log(`📡 기상청 실시간 날씨 조회: ${regionCode.name} (${tm})`); const response = await axios.get(url, { params: { tm: tm, stn: 0, authKey: apiKey, help: 0, disp: 1, }, timeout: 10000, responseType: 'arraybuffer', }); // EUC-KR 디코딩 const iconv = require('iconv-lite'); const responseText = iconv.decode(Buffer.from(response.data), 'EUC-KR'); // 데이터 파싱 const lines = responseText.split('\n').filter((line: string) => line.trim() && !line.startsWith('#')); if (lines.length === 0) { throw new Error('날씨 데이터가 없습니다.'); } // 요청한 관측소 데이터 찾기 (없으면 첫 번째 관측소 사용) let targetLine = lines.find((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; }); if (!targetLine) { console.log(`⚠️ 관측소 ${regionCode.stnId} 데이터 없음. 다른 관측소 데이터 사용`); targetLine = lines[0]; } const values = targetLine.trim().split(/\s+/); // 데이터 추출 const temperature = parseFloat(values[11]) || 0; // TA: 기온 const humidity = parseFloat(values[13]) || 0; // HM: 습도 const pressure = parseFloat(values[7]) || 1013; // PA: 기압 const windSpeed = parseFloat(values[3]) || 0; // WS: 풍속 const rainfall = parseFloat(values[15]) || 0; // RN: 강수량 console.log(`✅ 기상청 실시간 날씨: ${regionCode.name} ${temperature}°C, 습도 ${humidity}%`); // 날씨 상태 추정 let weatherMain = 'Clear'; let weatherDescription = '맑음'; let weatherIcon = '01d'; let clouds = 10; if (rainfall > 0) { weatherMain = 'Rain'; weatherDescription = rainfall >= 10 ? '비 (강수)' : '비'; weatherIcon = rainfall >= 10 ? '10d' : '09d'; clouds = 100; } else if (humidity > 80) { weatherMain = 'Clouds'; weatherDescription = '흐림'; weatherIcon = '04d'; clouds = 90; } else if (humidity > 60) { weatherMain = 'Clouds'; weatherDescription = '구름 많음'; weatherIcon = '03d'; clouds = 60; } return { city: regionCode.name, country: 'KR', temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.round(humidity), pressure: Math.round(pressure), weatherMain, weatherDescription, weatherIcon, windSpeed: Math.round(windSpeed * 10) / 10, clouds, timestamp: new Date().toISOString(), }; } catch (error: any) { console.error('❌ 기상청 실시간 날씨 조회 실패:', error.message); return this.generateRealisticWeatherData(city); } } /** * 전체 알림 조회 (통합) */ 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; } } /** * 도시명 → 기상청 관측소 코드 매핑 */ private getKMARegionCode(city: string): { name: string; stnId: string } | null { const regions: Record = { 'Seoul': { name: '서울', stnId: '108' }, 'Busan': { name: '부산', stnId: '159' }, 'Incheon': { name: '인천', stnId: '112' }, '서울': { name: '서울', stnId: '108' }, '부산': { name: '부산', stnId: '159' }, '인천': { name: '인천', stnId: '112' }, '대구': { name: '대구', stnId: '143' }, '광주': { name: '광주', stnId: '156' }, '대전': { name: '대전', stnId: '133' }, '울산': { name: '울산', stnId: '152' }, '세종': { name: '세종', stnId: '239' }, '제주': { name: '제주', stnId: '184' }, }; return regions[city] || null; } /** * 실시간 근사 날씨 데이터 생성 */ private generateRealisticWeatherData(cityName: string): WeatherData { const now = new Date(); const hour = now.getHours(); const month = now.getMonth() + 1; // 시간대별 기온 변화 let baseTemp = 15; if (hour >= 6 && hour < 9) baseTemp = 12; else if (hour >= 9 && hour < 12) baseTemp = 18; else if (hour >= 12 && hour < 15) baseTemp = 22; else if (hour >= 15 && hour < 18) baseTemp = 20; else if (hour >= 18 && hour < 21) baseTemp = 16; else baseTemp = 13; // 계절별 보정 if (month >= 12 || month <= 2) baseTemp -= 8; else if (month >= 3 && month <= 5) baseTemp += 2; else if (month >= 6 && month <= 8) baseTemp += 8; else baseTemp += 3; const tempVariation = Math.floor(Math.random() * 5) - 2; const temperature = baseTemp + tempVariation; return { city: cityName, country: 'KR', temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.floor(Math.random() * 30) + 50, pressure: Math.floor(Math.random() * 15) + 1008, weatherMain: 'Clear', weatherDescription: '맑음', weatherIcon: '01d', windSpeed: Math.random() * 4 + 1, clouds: 10, timestamp: now.toISOString(), }; } /** * 기상청 시간 형식 파싱 (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'; } /** * 테스트용 날씨 특보 더미 데이터 */ private generateDummyWeatherAlerts(): Alert[] { return [ { id: `weather-${Date.now()}-1`, type: 'weather', severity: 'high', title: '대설특보', location: '강원 영동지역', description: '시간당 2cm 이상 폭설. 차량 운행 주의', timestamp: new Date(Date.now() - 30 * 60000).toISOString(), }, { id: `weather-${Date.now()}-2`, type: 'weather', severity: 'medium', title: '강풍특보', location: '남해안 전 지역', description: '순간 풍속 20m/s 이상. 고속도로 주행 주의', timestamp: new Date(Date.now() - 90 * 60000).toISOString(), }, ]; } /** * 테스트용 교통사고 더미 데이터 */ private generateDummyAccidentAlerts(): Alert[] { return [ { id: `accident-${Date.now()}-1`, type: 'accident', severity: 'high', title: '교통사고 발생', location: '경부고속도로 서울방향 189km', description: '3중 추돌사고로 2차로 통제 중. 우회 권장', timestamp: new Date(Date.now() - 10 * 60000).toISOString(), }, { id: `accident-${Date.now()}-2`, type: 'accident', severity: 'medium', title: '사고 다발 지역', location: '영동고속도로 강릉방향 160km', description: '안개로 인한 가시거리 50m 이하. 서행 운전', timestamp: new Date(Date.now() - 60 * 60000).toISOString(), }, ]; } /** * 테스트용 도로공사 더미 데이터 */ private generateDummyRoadworkAlerts(): Alert[] { return [ { id: `construction-${Date.now()}-1`, type: 'construction', severity: 'medium', title: '도로 공사', location: '서울외곽순환 목동IC~화곡IC', description: '야간 공사로 1차로 통제 (22:00~06:00)', timestamp: new Date(Date.now() - 45 * 60000).toISOString(), }, { id: `construction-${Date.now()}-2`, type: 'construction', severity: 'low', title: '도로 통제', location: '중부내륙고속도로 김천JC~현풍IC', description: '도로 유지보수 작업. 차량 속도 제한 60km/h', timestamp: new Date(Date.now() - 120 * 60000).toISOString(), }, ]; } }