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

472 lines
17 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 class RiskAlertService {
/**
* 기상청 특보 정보 조회 (기상청 API 허브 - 현재 발효 중인 특보 API)
*/
async getWeatherAlerts(): Promise<Alert[]> {
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<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 [];
}
// 특보가 없으면 빈 배열 반환 (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<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 [];
}
/**
* 도로공사 정보 조회 (국토교통부 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 [];
}
/**
* 전체 알림 조회 (통합)
*/
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;
}
}
/**
* 기상청 시간 형식 파싱 (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';
}
}