ERP-node/backend-node/src/services/riskAlertService.ts

755 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 리스크/알림 서비스
* - 기상청 특보 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<Alert[]> {
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<string, { title: string; severity: 'high' | 'medium' | 'low' }> = {
'풍랑': { 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<Alert[]> {
// 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<Alert[]> {
// 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<WeatherData> {
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<Alert[]> {
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<string, { name: string; stnId: string }> = {
'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(),
},
];
}
}