배송/화물현황과 리스크/알림(api 활용, 공공데이터 복구시 대체될 가능성 있음)
This commit is contained in:
548
backend-node/src/services/riskAlertService.ts
Normal file
548
backend-node/src/services/riskAlertService.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 리스크/알림 서비스
|
||||
* - 기상청 특보 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 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 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 날씨 특보 더미 데이터
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user