487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
/**
|
||
* 리스크/알림 서비스
|
||
* - 기상청 특보 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 응답 수신 완료');
|
||
|
||
// 텍스트 응답 파싱 (인코딩 자동 감지)
|
||
const iconv = require('iconv-lite');
|
||
const buffer = Buffer.from(warningResponse.data);
|
||
|
||
// UTF-8 먼저 시도, 실패하면 EUC-KR 시도
|
||
let responseText: string;
|
||
const utf8Text = buffer.toString('utf-8');
|
||
|
||
// UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지)
|
||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||
responseText = utf8Text;
|
||
console.log('📝 UTF-8 인코딩으로 디코딩');
|
||
} else {
|
||
// EUC-KR로 디코딩
|
||
responseText = iconv.decode(buffer, 'EUC-KR');
|
||
console.log('📝 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';
|
||
}
|
||
|
||
}
|
||
|