ERP-node/backend-node/src/controllers/openApiProxyController.ts

1326 lines
48 KiB
TypeScript
Raw Normal View History

/**
* OpenAPI
* - API(, ) API
*/
import { Request, Response } from 'express';
import axios from 'axios';
export class OpenApiProxyController {
/**
* ( API Hub)
* GET /api/open-api/weather?city=Seoul
*/
async getWeather(req: Request, res: Response): Promise<void> {
try {
const { city = '서울' } = req.query;
console.log(`🌤️ 날씨 조회 요청: ${city}`);
// 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트)
const openWeatherKey = process.env.OPENWEATHER_API_KEY;
if (openWeatherKey) {
try {
console.log(`🌍 OpenWeatherMap API 호출: ${city}`);
const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
params: {
q: `${city},KR`,
appid: openWeatherKey,
units: 'metric',
lang: 'kr',
},
timeout: 10000,
});
const data = response.data;
const weatherData = {
city: data.name,
country: data.sys.country,
temperature: Math.round(data.main.temp),
feelsLike: Math.round(data.main.feels_like),
humidity: data.main.humidity,
pressure: data.main.pressure,
weatherMain: data.weather[0].main,
weatherDescription: data.weather[0].description,
weatherIcon: data.weather[0].icon,
windSpeed: Math.round(data.wind.speed * 10) / 10,
clouds: data.clouds.all,
timestamp: new Date().toISOString(),
};
console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
res.json({ success: true, data: weatherData });
return;
} catch (error) {
console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error);
}
}
// 2순위: 기상청 API Hub (매시간 정시 데이터)
const apiKey = process.env.KMA_API_KEY;
// API 키가 없으면 오류 반환
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.');
res.status(503).json({
success: false,
message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.',
});
return;
}
// 도시명 → 기상청 지역 코드 매핑
const regionCode = getKMARegionCode(city as string);
if (!regionCode) {
res.status(404).json({
success: false,
message: `지원하지 않는 지역입니다: ${city}`,
});
return;
}
// 기상청 API Hub 사용 (apihub.kma.go.kr)
const now = new Date();
// 한국 시간(KST = UTC+9)으로 변환
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
const kstNow = new Date(now.getTime() + kstOffset);
// 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표
// 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정
const targetTime = new Date(kstNow);
// tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회)
const year = targetTime.getUTCFullYear();
const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
const day = String(targetTime.getUTCDate()).padStart(2, '0');
const hour = String(targetTime.getUTCHours()).padStart(2, '0');
const tm = `${year}${month}${day}${hour}00`;
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
// 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보)
// sfctm3: 시간 범위 조회 가능 (tm1~tm2)
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php';
// 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준
const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전
const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`;
const tm2 = tm; // 현재 시간
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`);
const response = await axios.get(url, {
params: {
tm1: tm1,
tm2: tm2,
stn: regionCode.stnId, // 특정 관측소만 조회
authKey: apiKey,
help: 0,
disp: 1,
},
timeout: 10000,
});
console.log('📊 기상청 API Hub 응답:', response.data);
// 기상청 API Hub 응답은 텍스트 형식이므로 파싱 필요
const weatherData = parseKMAHubWeatherData(response.data, regionCode);
console.log(`✅ 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
res.json({
success: true,
data: weatherData,
});
} catch (error: unknown) {
console.error('❌ 날씨 조회 실패:', error);
// API 호출 실패 시 명확한 오류 메시지 반환
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401 || status === 403) {
res.status(401).json({
success: false,
message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.',
});
} else if (status === 404) {
res.status(404).json({
success: false,
message: '기상청 API에서 데이터를 찾을 수 없습니다.',
});
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
res.status(504).json({
success: false,
message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
});
} else {
res.status(500).json({
success: false,
message: '기상청 API 호출 중 오류가 발생했습니다.',
error: error.message,
});
}
} else {
res.status(500).json({
success: false,
message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
}
/**
* ( API)
* GET /api/open-api/exchange-rate?base=KRW&target=USD
*/
async getExchangeRate(req: Request, res: Response): Promise<void> {
try {
const { base = 'KRW', target = 'USD' } = req.query;
console.log(`💱 환율 조회 요청: ${base} -> ${target}`);
// ExchangeRate-API.com 사용 (무료, API 키 불필요)
const url = `https://open.er-api.com/v6/latest/${base}`;
console.log(`📡 ExchangeRate-API 호출: ${base} -> ${target}`);
const response = await axios.get(url, {
timeout: 10000,
});
console.log('📊 ExchangeRate-API 응답:', response.data);
// 환율 데이터 추출
const rates = response.data?.rates;
if (!rates || !rates[target as string]) {
throw new Error(`환율 데이터를 찾을 수 없습니다: ${base} -> ${target}`);
}
const rate = rates[target as string];
const exchangeData = {
base: base as string,
target: target as string,
rate: rate,
timestamp: new Date().toISOString(),
source: 'ExchangeRate-API.com',
};
console.log(`✅ 환율 조회 성공: 1 ${base} = ${rate} ${target}`);
res.json({
success: true,
data: exchangeData,
});
} catch (error: unknown) {
console.error('❌ 환율 조회 실패:', error);
// API 호출 실패 시 명확한 오류 메시지 반환
if (axios.isAxiosError(error)) {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 오류가 발생했습니다.',
error: error.message,
});
} else {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
}
/**
* Geocoding ( )
* POST /api/open-api/geocode
* Body: { address: "서울특별시 강남구 테헤란로 123" }
*/
async geocode(req: Request, res: Response): Promise<void> {
try {
const { address } = req.body;
if (!address) {
res.status(400).json({
success: false,
message: '주소가 필요합니다.',
});
return;
}
console.log(`📍 Geocoding 요청: ${address}`);
// Kakao Geocoding API 호출
const apiKey = process.env.KAKAO_REST_API_KEY || 'demo'; // TODO: 실제 API 키 필요
const url = 'https://dapi.kakao.com/v2/local/search/address.json';
const response = await axios.get(url, {
params: { query: address },
headers: {
Authorization: `KakaoAK ${apiKey}`,
},
timeout: 5000,
});
if (response.data.documents && response.data.documents.length > 0) {
const result = response.data.documents[0];
console.log(`✅ Geocoding 성공: ${result.address_name}`);
res.json({
success: true,
data: {
address: result.address_name,
lat: parseFloat(result.y),
lng: parseFloat(result.x),
},
});
} else {
console.log(`❌ 주소를 찾을 수 없음: ${address}`);
res.status(404).json({
success: false,
message: '주소를 찾을 수 없습니다.',
});
}
} catch (error: unknown) {
console.error('❌ Geocoding 실패:', error);
if (axios.isAxiosError(error)) {
res.status(500).json({
success: false,
message: 'Geocoding 중 오류가 발생했습니다.',
error: error.message,
});
} else {
res.status(500).json({
success: false,
message: 'Geocoding 중 오류가 발생했습니다.',
});
}
}
}
}
/**
* ( // )
*/
function 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' },
'Daegu': { name: '대구', stnId: '143' },
'Gwangju': { name: '광주', stnId: '156' },
'Daejeon': { name: '대전', stnId: '133' },
'Ulsan': { name: '울산', stnId: '152' },
'Sejong': { name: '세종', stnId: '239' },
'Jeju': { name: '제주', stnId: '184' },
// 서울특별시
'서울': { name: '서울', stnId: '108' },
'종로구': { name: '서울 종로구', stnId: '108' },
'중구': { name: '서울 중구', stnId: '108' },
'용산구': { name: '서울 용산구', stnId: '108' },
'성동구': { name: '서울 성동구', stnId: '108' },
'광진구': { name: '서울 광진구', stnId: '108' },
'동대문구': { name: '서울 동대문구', stnId: '108' },
'중랑구': { name: '서울 중랑구', stnId: '108' },
'성북구': { name: '서울 성북구', stnId: '108' },
'강북구': { name: '서울 강북구', stnId: '108' },
'도봉구': { name: '서울 도봉구', stnId: '108' },
'노원구': { name: '서울 노원구', stnId: '108' },
'은평구': { name: '서울 은평구', stnId: '108' },
'서대문구': { name: '서울 서대문구', stnId: '108' },
'마포구': { name: '서울 마포구', stnId: '108' },
'양천구': { name: '서울 양천구', stnId: '108' },
'강서구': { name: '서울 강서구', stnId: '108' },
'구로구': { name: '서울 구로구', stnId: '108' },
'금천구': { name: '서울 금천구', stnId: '108' },
'영등포구': { name: '서울 영등포구', stnId: '108' },
'동작구': { name: '서울 동작구', stnId: '108' },
'관악구': { name: '서울 관악구', stnId: '108' },
'서초구': { name: '서울 서초구', stnId: '108' },
'강남구': { name: '서울 강남구', stnId: '108' },
'송파구': { name: '서울 송파구', stnId: '108' },
'강동구': { name: '서울 강동구', stnId: '108' },
// 부산광역시
'부산': { name: '부산', stnId: '159' },
'중구(부산)': { name: '부산 중구', stnId: '159' },
'서구(부산)': { name: '부산 서구', stnId: '159' },
'동구(부산)': { name: '부산 동구', stnId: '159' },
'영도구': { name: '부산 영도구', stnId: '159' },
'부산진구': { name: '부산 부산진구', stnId: '159' },
'동래구': { name: '부산 동래구', stnId: '159' },
'남구(부산)': { name: '부산 남구', stnId: '159' },
'북구(부산)': { name: '부산 북구', stnId: '159' },
'해운대구': { name: '부산 해운대구', stnId: '159' },
'사하구': { name: '부산 사하구', stnId: '159' },
'금정구': { name: '부산 금정구', stnId: '159' },
'강서구(부산)': { name: '부산 강서구', stnId: '159' },
'연제구': { name: '부산 연제구', stnId: '159' },
'수영구': { name: '부산 수영구', stnId: '159' },
'사상구': { name: '부산 사상구', stnId: '159' },
'기장군': { name: '부산 기장군', stnId: '159' },
// 인천광역시
'인천': { name: '인천', stnId: '112' },
'중구(인천)': { name: '인천 중구', stnId: '112' },
'동구(인천)': { name: '인천 동구', stnId: '112' },
'미추홀구': { name: '인천 미추홀구', stnId: '112' },
'연수구': { name: '인천 연수구', stnId: '112' },
'남동구': { name: '인천 남동구', stnId: '112' },
'부평구': { name: '인천 부평구', stnId: '112' },
'계양구': { name: '인천 계양구', stnId: '112' },
'서구(인천)': { name: '인천 서구', stnId: '112' },
'강화군': { name: '인천 강화군', stnId: '201' },
'옹진군': { name: '인천 옹진군', stnId: '112' },
// 대구광역시
'대구': { name: '대구', stnId: '143' },
'중구(대구)': { name: '대구 중구', stnId: '143' },
'동구(대구)': { name: '대구 동구', stnId: '143' },
'서구(대구)': { name: '대구 서구', stnId: '143' },
'남구(대구)': { name: '대구 남구', stnId: '143' },
'북구(대구)': { name: '대구 북구', stnId: '143' },
'수성구': { name: '대구 수성구', stnId: '143' },
'달서구': { name: '대구 달서구', stnId: '143' },
'달성군': { name: '대구 달성군', stnId: '143' },
// 광주광역시
'광주': { name: '광주', stnId: '156' },
'동구(광주)': { name: '광주 동구', stnId: '156' },
'서구(광주)': { name: '광주 서구', stnId: '156' },
'남구(광주)': { name: '광주 남구', stnId: '156' },
'북구(광주)': { name: '광주 북구', stnId: '156' },
'광산구': { name: '광주 광산구', stnId: '156' },
// 대전광역시
'대전': { name: '대전', stnId: '133' },
'동구(대전)': { name: '대전 동구', stnId: '133' },
'중구(대전)': { name: '대전 중구', stnId: '133' },
'서구(대전)': { name: '대전 서구', stnId: '133' },
'유성구': { name: '대전 유성구', stnId: '133' },
'대덕구': { name: '대전 대덕구', stnId: '133' },
// 울산광역시
'울산': { name: '울산', stnId: '152' },
'중구(울산)': { name: '울산 중구', stnId: '152' },
'남구(울산)': { name: '울산 남구', stnId: '152' },
'동구(울산)': { name: '울산 동구', stnId: '152' },
'북구(울산)': { name: '울산 북구', stnId: '152' },
'울주군': { name: '울산 울주군', stnId: '152' },
// 세종특별자치시
'세종': { name: '세종', stnId: '239' },
// 경기도
'수원': { name: '수원', stnId: '119' },
'성남': { name: '성남', stnId: '119' },
'의정부': { name: '의정부', stnId: '108' },
'안양': { name: '안양', stnId: '119' },
'부천': { name: '부천', stnId: '112' },
'광명': { name: '광명', stnId: '119' },
'평택': { name: '평택', stnId: '119' },
'동두천': { name: '동두천', stnId: '98' },
'안산': { name: '안산', stnId: '119' },
'고양': { name: '고양', stnId: '108' },
'과천': { name: '과천', stnId: '119' },
'구리': { name: '구리', stnId: '108' },
'남양주': { name: '남양주', stnId: '108' },
'오산': { name: '오산', stnId: '119' },
'시흥': { name: '시흥', stnId: '119' },
'군포': { name: '군포', stnId: '119' },
'의왕': { name: '의왕', stnId: '119' },
'하남': { name: '하남', stnId: '108' },
'용인': { name: '용인', stnId: '119' },
'파주': { name: '파주', stnId: '98' },
'이천': { name: '이천', stnId: '203' },
'안성': { name: '안성', stnId: '119' },
'김포': { name: '김포', stnId: '112' },
'화성': { name: '화성', stnId: '119' },
'광주(경기)': { name: '광주(경기)', stnId: '119' },
'양주': { name: '양주', stnId: '98' },
'포천': { name: '포천', stnId: '98' },
'여주': { name: '여주', stnId: '203' },
'연천군': { name: '연천', stnId: '98' },
'가평군': { name: '가평', stnId: '201' },
'양평군': { name: '양평', stnId: '119' },
// 강원도
'춘천': { name: '춘천', stnId: '101' },
'원주': { name: '원주', stnId: '114' },
'강릉': { name: '강릉', stnId: '105' },
'동해': { name: '동해', stnId: '106' },
'태백': { name: '태백', stnId: '216' },
'속초': { name: '속초', stnId: '90' },
'삼척': { name: '삼척', stnId: '106' },
'홍천': { name: '홍천', stnId: '212' },
'횡성': { name: '횡성', stnId: '114' },
'영월': { name: '영월', stnId: '121' },
'평창': { name: '평창', stnId: '100' },
'정선': { name: '정선', stnId: '217' },
'철원': { name: '철원', stnId: '95' },
'화천': { name: '화천', stnId: '212' },
'양구': { name: '양구', stnId: '212' },
'인제': { name: '인제', stnId: '211' },
'고성(강원)': { name: '고성(강원)', stnId: '90' },
'양양': { name: '양양', stnId: '90' },
// 충청북도
'청주': { name: '청주', stnId: '131' },
'충주': { name: '충주', stnId: '127' },
'제천': { name: '제천', stnId: '221' },
'보은': { name: '보은', stnId: '226' },
'옥천': { name: '옥천', stnId: '226' },
'영동': { name: '영동', stnId: '135' },
'증평': { name: '증평', stnId: '131' },
'진천': { name: '진천', stnId: '131' },
'괴산': { name: '괴산', stnId: '127' },
'음성': { name: '음성', stnId: '127' },
'단양': { name: '단양', stnId: '221' },
// 충청남도
'천안': { name: '천안', stnId: '232' },
'공주': { name: '공주', stnId: '236' },
'보령': { name: '보령', stnId: '235' },
'아산': { name: '아산', stnId: '232' },
'서산': { name: '서산', stnId: '129' },
'논산': { name: '논산', stnId: '238' },
'계룡': { name: '계룡', stnId: '238' },
'당진': { name: '당진', stnId: '129' },
'금산': { name: '금산', stnId: '238' },
'부여': { name: '부여', stnId: '236' },
'서천': { name: '서천', stnId: '235' },
'청양': { name: '청양', stnId: '235' },
'홍성': { name: '홍성', stnId: '177' },
'예산': { name: '예산', stnId: '232' },
'태안': { name: '태안', stnId: '129' },
// 전라북도
'전주': { name: '전주', stnId: '146' },
'군산': { name: '군산', stnId: '140' },
'익산': { name: '익산', stnId: '146' },
'정읍': { name: '정읍', stnId: '251' },
'남원': { name: '남원', stnId: '247' },
'김제': { name: '김제', stnId: '140' },
'완주': { name: '완주', stnId: '146' },
'진안': { name: '진안', stnId: '248' },
'무주': { name: '무주', stnId: '248' },
'장수': { name: '장수', stnId: '248' },
'임실': { name: '임실', stnId: '247' },
'순창': { name: '순창', stnId: '254' },
'고창': { name: '고창', stnId: '172' },
'부안': { name: '부안', stnId: '140' },
// 전라남도
'목포': { name: '목포', stnId: '165' },
'여수': { name: '여수', stnId: '168' },
'순천': { name: '순천', stnId: '174' },
'나주': { name: '나주', stnId: '170' },
'광양': { name: '광양', stnId: '168' },
'담양': { name: '담양', stnId: '156' },
'곡성': { name: '곡성', stnId: '252' },
'구례': { name: '구례', stnId: '252' },
'고흥': { name: '고흥', stnId: '262' },
'보성': { name: '보성', stnId: '258' },
'화순': { name: '화순', stnId: '262' },
'장흥': { name: '장흥', stnId: '260' },
'강진': { name: '강진', stnId: '259' },
'해남': { name: '해남', stnId: '261' },
'영암': { name: '영암', stnId: '170' },
'무안': { name: '무안', stnId: '165' },
'함평': { name: '함평', stnId: '172' },
'영광': { name: '영광', stnId: '252' },
'장성': { name: '장성', stnId: '172' },
'완도': { name: '완도', stnId: '261' },
'진도': { name: '진도', stnId: '175' },
'신안': { name: '신안', stnId: '165' },
// 경상북도
'포항': { name: '포항', stnId: '138' },
'경주': { name: '경주', stnId: '283' },
'김천': { name: '김천', stnId: '279' },
'안동': { name: '안동', stnId: '136' },
'구미': { name: '구미', stnId: '279' },
'영주': { name: '영주', stnId: '272' },
'영천': { name: '영천', stnId: '281' },
'상주': { name: '상주', stnId: '137' },
'문경': { name: '문경', stnId: '273' },
'경산': { name: '경산', stnId: '283' },
'군위': { name: '군위', stnId: '278' },
'의성': { name: '의성', stnId: '278' },
'청송': { name: '청송', stnId: '276' },
'영양': { name: '영양', stnId: '277' },
'영덕': { name: '영덕', stnId: '277' },
'청도': { name: '청도', stnId: '284' },
'고령': { name: '고령', stnId: '279' },
'성주': { name: '성주', stnId: '279' },
'칠곡': { name: '칠곡', stnId: '279' },
'예천': { name: '예천', stnId: '273' },
'봉화': { name: '봉화', stnId: '271' },
'울진': { name: '울진', stnId: '130' },
'울릉도': { name: '울릉도', stnId: '115' },
'독도': { name: '독도', stnId: '115' },
// 경상남도
'창원': { name: '창원', stnId: '155' },
'진주': { name: '진주', stnId: '192' },
'통영': { name: '통영', stnId: '162' },
'사천': { name: '사천', stnId: '192' },
'김해': { name: '김해', stnId: '159' },
'밀양': { name: '밀양', stnId: '288' },
'거제': { name: '거제', stnId: '162' },
'양산': { name: '양산', stnId: '288' },
'의령': { name: '의령', stnId: '192' },
'함안': { name: '함안', stnId: '155' },
'창녕': { name: '창녕', stnId: '288' },
'고성(경남)': { name: '고성(경남)', stnId: '162' },
'남해': { name: '남해', stnId: '295' },
'하동': { name: '하동', stnId: '295' },
'산청': { name: '산청', stnId: '289' },
'함양': { name: '함양', stnId: '289' },
'거창': { name: '거창', stnId: '284' },
'합천': { name: '합천', stnId: '285' },
// 제주특별자치도
'제주': { name: '제주', stnId: '184' },
'서귀포': { name: '서귀포', stnId: '189' },
'성산': { name: '제주 성산', stnId: '188' },
'고산': { name: '제주 고산', stnId: '185' },
};
return regions[city] || null;
}
/**
* ID를
*/
function convertStationToGrid(stnId: string): { nx: number; ny: number } {
const gridMap: Record<string, { nx: number; ny: number }> = {
'108': { nx: 60, ny: 127 }, // 서울
'159': { nx: 98, ny: 76 }, // 부산
'112': { nx: 55, ny: 124 }, // 인천
'143': { nx: 89, ny: 90 }, // 대구
'156': { nx: 58, ny: 74 }, // 광주
'133': { nx: 67, ny: 100 }, // 대전
'152': { nx: 102, ny: 84 }, // 울산
'239': { nx: 66, ny: 103 }, // 세종
'119': { nx: 60, ny: 121 }, // 수원
'101': { nx: 73, ny: 134 }, // 춘천
'184': { nx: 52, ny: 38 }, // 제주
'105': { nx: 92, ny: 131 }, // 강릉
};
return gridMap[stnId] || { nx: 60, ny: 127 }; // 기본값: 서울
}
/**
* (YYYYMMDD)
*/
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
/**
*
* 40 , 10
*/
function getBaseTime(date: Date): string {
const hour = date.getHours();
const minute = date.getMinutes();
// 현재 시각이 40분 이전이면 이전 시간 데이터 조회
let baseHour = minute < 40 ? hour - 1 : hour;
// 0시 이전이면 전날 23시
if (baseHour < 0) {
baseHour = 23;
}
return String(baseHour).padStart(2, '0') + '00';
}
/**
* API Hub
*/
function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: string }): any {
// API Hub 응답 형식: #을 구분자로 사용하는 텍스트
const lines = data.split('\n').filter((line: string) => line.trim() && !line.startsWith('#'));
if (lines.length === 0) {
throw new Error('날씨 데이터를 파싱할 수 없습니다.');
}
// 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨)
const targetLines = lines.filter((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
});
if (targetLines.length === 0) {
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
}
// 가장 최근 데이터 선택 (마지막 줄)
const targetLine = targetLines[targetLines.length - 1];
// 데이터 라인 파싱 (공백으로 구분)
const values = targetLine.trim().split(/\s+/);
// 관측 시각 로깅
const obsTime = values[0]; // YYMMDDHHMI
console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`);
// 기상청 API Hub 데이터 형식 (실제 응답 기준):
// [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ...
const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11)
const humidity = parseFloat(values[13]) || 0; // HM: 습도 (인덱스 13)
const pressure = parseFloat(values[7]) || 1013; // PA: 현지기압 (인덱스 7)
const windSpeed = parseFloat(values[3]) || 0; // WS: 풍속 (인덱스 3)
const rainfall = parseFloat(values[15]) || 0; // RN: 강수량 (인덱스 15)
console.log(`📊 기상 데이터: ${regionCode.name} - 기온 ${temperature}°C, 습도 ${humidity}%, 강수량 ${rainfall}mm`);
// 날씨 상태 추정 (강수량 + 습도 기반)
let weatherMain = 'Clear';
let weatherDescription = '맑음';
let weatherIcon = '01d';
let clouds = 10;
// 강수량이 있으면 비
if (rainfall > 0) {
if (rainfall >= 10) {
weatherMain = 'Rain';
weatherDescription = '비 (강수)';
weatherIcon = '10d';
clouds = 100;
} else {
weatherMain = 'Rain';
weatherDescription = '비 (약간)';
weatherIcon = '09d';
clouds = 90;
}
}
// 습도 기반 날씨 추정
else if (humidity > 80) {
weatherMain = 'Clouds';
weatherDescription = '흐림';
weatherIcon = '04d';
clouds = 90;
} else if (humidity > 60) {
weatherMain = 'Clouds';
weatherDescription = '구름 많음';
weatherIcon = '03d';
clouds = 60;
} else if (humidity > 40) {
weatherMain = 'Clouds';
weatherDescription = '구름 조금';
weatherIcon = '02d';
clouds = 30;
}
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(),
};
}
/**
* ( - )
*/
function parseKMAWeatherData(items: any[], cityName: string): any {
const data: Record<string, any> = {
city: cityName,
country: 'KR',
temperature: 0,
feelsLike: 0,
humidity: 0,
pressure: 1013,
weatherMain: 'Clear',
weatherDescription: '맑음',
weatherIcon: '01d',
windSpeed: 0,
clouds: 0,
timestamp: new Date().toISOString(),
};
items.forEach((item: any) => {
const category = item.category;
const value = parseFloat(item.obsrValue);
switch (category) {
case 'T1H': // 기온 (°C)
data.temperature = Math.round(value);
data.feelsLike = Math.round(value - 2); // 체감온도 근사값
break;
case 'RN1': // 1시간 강수량 (mm)
if (value > 0) {
data.weatherMain = 'Rain';
data.weatherDescription = '비';
data.weatherIcon = '10d';
}
break;
case 'REH': // 습도 (%)
data.humidity = Math.round(value);
break;
case 'WSD': // 풍속 (m/s)
data.windSpeed = value;
break;
case 'SKY': // 하늘상태 (1:맑음, 3:구름많음, 4:흐림)
if (value === 1) {
data.weatherMain = 'Clear';
data.weatherDescription = '맑음';
data.weatherIcon = '01d';
data.clouds = 10;
} else if (value === 3) {
data.weatherMain = 'Clouds';
data.weatherDescription = '구름 많음';
data.weatherIcon = '02d';
data.clouds = 60;
} else if (value === 4) {
data.weatherMain = 'Clouds';
data.weatherDescription = '흐림';
data.weatherIcon = '04d';
data.clouds = 90;
}
break;
case 'PTY': // 강수형태 (0:없음, 1:비, 2:비/눈, 3:눈, 4:소나기)
if (value === 1 || value === 4) {
data.weatherMain = 'Rain';
data.weatherDescription = value === 4 ? '소나기' : '비';
data.weatherIcon = value === 4 ? '09d' : '10d';
} else if (value === 2) {
data.weatherMain = 'Rain';
data.weatherDescription = '진눈깨비';
data.weatherIcon = '13d';
} else if (value === 3) {
data.weatherMain = 'Snow';
data.weatherDescription = '눈';
data.weatherIcon = '13d';
}
break;
}
});
return data;
}
/**
*
* (API )
*/
function generateRealisticWeatherData(cityName: string): any {
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; // 가을
// 랜덤 변동 (-2 ~ +2)
const tempVariation = Math.floor(Math.random() * 5) - 2;
const temperature = baseTemp + tempVariation;
// 날씨 상태 (확률 기반)
const weatherRandom = Math.random();
let weatherMain = 'Clear';
let weatherDescription = '맑음';
let weatherIcon = '01d';
let clouds = 10;
if (weatherRandom < 0.1) {
// 10% 비
weatherMain = 'Rain';
weatherDescription = '비';
weatherIcon = '10d';
clouds = 80;
} else if (weatherRandom < 0.25) {
// 15% 흐림
weatherMain = 'Clouds';
weatherDescription = '흐림';
weatherIcon = '04d';
clouds = 90;
} else if (weatherRandom < 0.5) {
// 25% 구름 많음
weatherMain = 'Clouds';
weatherDescription = '구름 많음';
weatherIcon = '02d';
clouds = 60;
}
// 50% 맑음 (기본값)
return {
city: cityName,
country: 'KR',
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.floor(Math.random() * 30) + 50, // 50-80%
pressure: Math.floor(Math.random() * 15) + 1008, // 1008-1023 hPa
weatherMain,
weatherDescription,
weatherIcon,
windSpeed: Math.random() * 4 + 1, // 1-5 m/s
clouds,
timestamp: now.toISOString(),
};
}
/**
*
*/
function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: number; ny: number }): any {
const header = data.response?.header;
const body = data.response?.body;
if (header?.resultCode !== '00') {
throw new Error(`API 오류: ${header?.resultMsg || '알 수 없는 오류'}`);
}
const items = body?.items?.item || [];
if (items.length === 0) {
throw new Error('날씨 데이터가 없습니다.');
}
// 초단기실황 응답 항목:
// T1H: 기온, RN1: 1시간 강수량, REH: 습도, PTY: 강수형태, WSD: 풍속
const weatherMap: Record<string, any> = {};
items.forEach((item: any) => {
weatherMap[item.category] = item.obsrValue;
});
const temperature = parseFloat(weatherMap['T1H']) || 0; // 기온
const humidity = parseFloat(weatherMap['REH']) || 0; // 습도
const rainfall = parseFloat(weatherMap['RN1']) || 0; // 1시간 강수량
const precipType = parseInt(weatherMap['PTY']) || 0; // 강수형태 (0: 없음, 1: 비, 2: 비/눈, 3: 눈, 4: 소나기)
const windSpeed = parseFloat(weatherMap['WSD']) || 0; // 풍속
console.log(`📊 기상 데이터: ${gridCoord.name} - 기온 ${temperature}°C, 습도 ${humidity}%, 강수 ${rainfall}mm`);
// 날씨 상태 결정
let weatherMain = 'Clear';
let weatherDescription = '맑음';
let weatherIcon = '01d';
let clouds = 10;
// 강수형태 기반
if (precipType > 0) {
if (precipType === 1 || precipType === 4) {
weatherMain = 'Rain';
weatherDescription = rainfall >= 10 ? '비 (강수)' : '비';
weatherIcon = rainfall >= 10 ? '10d' : '09d';
clouds = 100;
} else if (precipType === 2) {
weatherMain = 'Snow';
weatherDescription = '눈/비';
weatherIcon = '13d';
clouds = 100;
} else if (precipType === 3) {
weatherMain = 'Snow';
weatherDescription = '눈';
weatherIcon = '13d';
clouds = 100;
}
}
// 습도 기반
else if (humidity > 80) {
weatherMain = 'Clouds';
weatherDescription = '흐림';
weatherIcon = '04d';
clouds = 90;
} else if (humidity > 60) {
weatherMain = 'Clouds';
weatherDescription = '구름 많음';
weatherIcon = '03d';
clouds = 60;
} else if (humidity > 40) {
weatherMain = 'Clouds';
weatherDescription = '구름 조금';
weatherIcon = '02d';
clouds = 30;
}
return {
city: gridCoord.name,
country: 'KR',
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.round(humidity),
pressure: 1013, // 초단기실황에는 기압 정보 없음
weatherMain,
weatherDescription,
weatherIcon,
windSpeed: Math.round(windSpeed * 10) / 10,
clouds,
timestamp: new Date().toISOString(),
};
}
/**
*
* API는 (nx, ny)
*/
function getGridCoordinates(city: string): { name: string; nx: number; ny: number } | null {
const grids: Record<string, { name: string; nx: number; ny: number }> = {
// 영어 도시명
'Seoul': { name: '서울', nx: 60, ny: 127 },
'Busan': { name: '부산', nx: 98, ny: 76 },
'Incheon': { name: '인천', nx: 55, ny: 124 },
'Daegu': { name: '대구', nx: 89, ny: 90 },
'Gwangju': { name: '광주', nx: 58, ny: 74 },
'Daejeon': { name: '대전', nx: 67, ny: 100 },
'Ulsan': { name: '울산', nx: 102, ny: 84 },
'Sejong': { name: '세종', nx: 66, ny: 103 },
'Jeju': { name: '제주', nx: 52, ny: 38 },
// 서울 (중구 기준)
'서울': { name: '서울', nx: 60, ny: 127 },
'종로구': { name: '서울 종로구', nx: 60, ny: 127 },
'중구': { name: '서울 중구', nx: 60, ny: 127 },
'용산구': { name: '서울 용산구', nx: 60, ny: 126 },
'성동구': { name: '서울 성동구', nx: 61, ny: 127 },
'광진구': { name: '서울 광진구', nx: 62, ny: 126 },
'동대문구': { name: '서울 동대문구', nx: 61, ny: 127 },
'중랑구': { name: '서울 중랑구', nx: 62, ny: 128 },
'성북구': { name: '서울 성북구', nx: 61, ny: 127 },
'강북구': { name: '서울 강북구', nx: 61, ny: 128 },
'도봉구': { name: '서울 도봉구', nx: 61, ny: 129 },
'노원구': { name: '서울 노원구', nx: 61, ny: 129 },
'은평구': { name: '서울 은평구', nx: 59, ny: 127 },
'서대문구': { name: '서울 서대문구', nx: 59, ny: 127 },
'마포구': { name: '서울 마포구', nx: 59, ny: 127 },
'양천구': { name: '서울 양천구', nx: 58, ny: 126 },
'강서구': { name: '서울 강서구', nx: 58, ny: 126 },
'구로구': { name: '서울 구로구', nx: 58, ny: 125 },
'금천구': { name: '서울 금천구', nx: 59, ny: 124 },
'영등포구': { name: '서울 영등포구', nx: 58, ny: 126 },
'동작구': { name: '서울 동작구', nx: 59, ny: 125 },
'관악구': { name: '서울 관악구', nx: 59, ny: 125 },
'서초구': { name: '서울 서초구', nx: 61, ny: 125 },
'강남구': { name: '서울 강남구', nx: 61, ny: 126 },
'송파구': { name: '서울 송파구', nx: 62, ny: 126 },
'강동구': { name: '서울 강동구', nx: 62, ny: 126 },
// 부산
'부산': { name: '부산', nx: 98, ny: 76 },
'해운대': { name: '부산 해운대구', nx: 99, ny: 75 },
'해운대구': { name: '부산 해운대구', nx: 99, ny: 75 },
// 인천
'인천': { name: '인천', nx: 55, ny: 124 },
// 대구
'대구': { name: '대구', nx: 89, ny: 90 },
// 광주
'광주': { name: '광주', nx: 58, ny: 74 },
// 대전
'대전': { name: '대전', nx: 67, ny: 100 },
// 울산
'울산': { name: '울산', nx: 102, ny: 84 },
// 세종
'세종': { name: '세종', nx: 66, ny: 103 },
// 경기도 주요 도시
'수원': { name: '수원', nx: 60, ny: 121 },
'성남': { name: '성남', nx: 62, ny: 123 },
'고양': { name: '고양', nx: 57, ny: 128 },
'용인': { name: '용인', nx: 64, ny: 119 },
'화성': { name: '화성', nx: 57, ny: 119 },
'부천': { name: '부천', nx: 56, ny: 125 },
// 화성시 읍/면/동
'영천동': { name: '화성 영천동', nx: 57, ny: 119 },
'화성 영천동': { name: '화성 영천동', nx: 57, ny: 119 },
'봉담읍': { name: '화성 봉담읍', nx: 57, ny: 120 },
'화성 봉담읍': { name: '화성 봉담읍', nx: 57, ny: 120 },
'동탄': { name: '화성 동탄', nx: 61, ny: 120 },
'화성 동탄': { name: '화성 동탄', nx: 61, ny: 120 },
// 성남시 구/동
'분당구': { name: '성남 분당구', nx: 62, ny: 123 },
'성남 분당구': { name: '성남 분당구', nx: 62, ny: 123 },
'분당': { name: '성남 분당구', nx: 62, ny: 123 },
'판교': { name: '성남 판교', nx: 62, ny: 124 },
'성남 판교': { name: '성남 판교', nx: 62, ny: 124 },
// 용인시
'수지구': { name: '용인 수지구', nx: 62, ny: 121 },
'용인 수지구': { name: '용인 수지구', nx: 62, ny: 121 },
'기흥구': { name: '용인 기흥구', nx: 61, ny: 120 },
'용인 기흥구': { name: '용인 기흥구', nx: 61, ny: 120 },
'처인구': { name: '용인 처인구', nx: 64, ny: 119 },
'용인 처인구': { name: '용인 처인구', nx: 64, ny: 119 },
// 강원도
'춘천': { name: '춘천', nx: 73, ny: 134 },
'강릉': { name: '강릉', nx: 92, ny: 131 },
'원주': { name: '원주', nx: 76, ny: 122 },
// 충청도
'청주': { name: '청주', nx: 69, ny: 107 },
'천안': { name: '천안', nx: 63, ny: 110 },
// 전라도
'전주': { name: '전주', nx: 63, ny: 89 },
'목포': { name: '목포', nx: 50, ny: 67 },
'여수': { name: '여수', nx: 73, ny: 66 },
// 경상도
'포항': { name: '포항', nx: 102, ny: 94 },
'창원': { name: '창원', nx: 90, ny: 77 },
'진주': { name: '진주', nx: 90, ny: 75 },
// 제주도
'제주': { name: '제주', nx: 52, ny: 38 },
'서귀포': { name: '서귀포', nx: 52, ny: 33 },
};
return grids[city] || null;
}
/**
*
* @param apiResponse - API
* @param gridInfo -
* @returns
*/
function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string; nx: number; ny: number }) {
try {
const response = apiResponse.response;
const header = response.header;
const body = response.body;
// API 응답 코드 확인
if (header.resultCode !== '00') {
console.error('❌ 공공데이터포털 API 오류:', header.resultMsg);
throw new Error(`API Error: ${header.resultMsg}`);
}
const items = body.items.item;
if (!items || items.length === 0) {
throw new Error('날씨 데이터가 없습니다.');
}
// 카테고리별 데이터 추출
const dataMap: Record<string, string> = {};
items.forEach((item: any) => {
dataMap[item.category] = item.obsrValue;
});
// T1H: 기온(°C)
// RN1: 1시간 강수량(mm)
// REH: 습도(%)
// WSD: 풍속(m/s)
// VEC: 풍향(deg)
// PTY: 강수형태 (0:없음, 1:비, 2:비/눈, 3:눈, 5:빗방울, 6:빗방울눈날림, 7:눈날림)
const temperature = parseFloat(dataMap['T1H'] || '0');
const humidity = parseFloat(dataMap['REH'] || '0');
const windSpeed = parseFloat(dataMap['WSD'] || '0');
const precipitation = parseFloat(dataMap['RN1'] || '0');
const ptyCode = dataMap['PTY'] || '0';
// 강수형태 → 날씨 상태 매핑
let weatherStatus = 'clear';
let weatherDescription = '맑음';
if (ptyCode === '1' || ptyCode === '5') {
weatherStatus = 'rain';
weatherDescription = '비';
} else if (ptyCode === '2' || ptyCode === '6') {
weatherStatus = 'rain';
weatherDescription = '비/눈';
} else if (ptyCode === '3' || ptyCode === '7') {
weatherStatus = 'snow';
weatherDescription = '눈';
} else if (temperature > 25) {
weatherDescription = '맑음';
} else if (temperature < 5) {
weatherDescription = '추움';
}
return {
city: gridInfo.name,
temperature: Math.round(temperature * 10) / 10,
humidity: Math.round(humidity),
windSpeed: Math.round(windSpeed * 10) / 10,
precipitation: Math.round(precipitation * 10) / 10,
weather: weatherStatus,
description: weatherDescription,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('❌ 공공데이터포털 응답 파싱 실패:', error);
throw error;
}
}
/**
*
* @param currencyCode - ISO (USD, EUR, JPY )
* @returns
*/
function getBOKStatCode(currencyCode: string): string | null {
const statCodes: Record<string, string> = {
// 주요 통화 (매일 고시)
'USD': '0000001', // 미국 달러
'JPY': '0000002', // 일본 엔 (100엔)
'EUR': '0000003', // 유럽연합 유로
'CNY': '0000053', // 중국 위안
'GBP': '0000002', // 영국 파운드
'AUD': '0000007', // 호주 달러
'CAD': '0000009', // 캐나다 달러
'CHF': '0000017', // 스위스 프랑
'HKD': '0000019', // 홍콩 달러
'SEK': '0000020', // 스웨덴 크로나
'NOK': '0000021', // 노르웨이 크로네
'DKK': '0000022', // 덴마크 크로네
'SGD': '0000024', // 싱가포르 달러
'NZD': '0000026', // 뉴질랜드 달러
'THB': '0000028', // 태국 바트
'MYR': '0000029', // 말레이시아 링깃
'IDR': '0000030', // 인도네시아 루피아 (100루피아)
'PHP': '0000031', // 필리핀 페소
'INR': '0000032', // 인도 루피
'BRL': '0000034', // 브라질 레알
};
return statCodes[currencyCode] || null;
}
/**
* API
* @param apiResponse - API
* @param base -
* @param target -
* @param isReverse - (KRW USD)
* @returns
*/
function parseBOKExchangeData(
apiResponse: any,
base: string,
target: string,
isReverse: boolean
) {
try {
// API 응답 구조: { StatisticSearch: { list_total_count, row: [...] } }
const result = apiResponse.StatisticSearch;
if (!result || result.list_total_count === '0') {
throw new Error('환율 데이터가 없습니다. (주말/공휴일일 수 있습니다)');
}
const row = result.row[0];
const rate = parseFloat(row.DATA_VALUE);
// KRW → USD (역수 계산)
const finalRate = isReverse ? (1 / rate) : rate;
return {
base,
target,
rate: Math.round(finalRate * 100) / 100, // 소수점 2자리
timestamp: new Date().toISOString(),
source: '한국은행 (BOK)',
};
} catch (error) {
console.error('❌ 한국은행 API 응답 파싱 실패:', error);
throw error;
}
}
/**
*
* @param base -
* @param target -
* @returns
*/
function generateTestExchangeRate(base: string, target: string) {
// 실제 환율 기준 (2024년 평균)
const baseRates: Record<string, number> = {
'USD': 1300, // 1 USD = 1300 KRW
'EUR': 1400, // 1 EUR = 1400 KRW
'JPY': 9.0, // 1 JPY = 9 KRW (100엔 기준)
'CNY': 180, // 1 CNY = 180 KRW
'GBP': 1650, // 1 GBP = 1650 KRW
};
let rate: number;
if (base === 'KRW' && baseRates[target]) {
// KRW → USD: 1/1300 = 0.00077
rate = 1 / baseRates[target];
} else if (target === 'KRW' && baseRates[base]) {
// USD → KRW: 1300
rate = baseRates[base];
} else if (base === 'KRW' && target === 'KRW') {
rate = 1;
} else {
// 지원하지 않는 통화 쌍
rate = 1.0;
}
// 약간의 랜덤 변동 (±1%)
const variation = 0.98 + Math.random() * 0.04;
rate = rate * variation;
return {
base,
target,
rate: Math.round(rate * 100) / 100,
timestamp: new Date().toISOString(),
source: 'TEST_DATA',
};
}
/**
*
*/
function generateRealisticExchangeRate(base: string, target: string) {
const baseRates: Record<string, number> = {
'USD': 1380, 'EUR': 1500, 'JPY': 9.2, 'CNY': 195, 'GBP': 1790,
};
let rate = 1;
if (base === 'KRW' && baseRates[target]) {
rate = 1 / baseRates[target];
} else if (target === 'KRW' && baseRates[base]) {
rate = baseRates[base];
} else if (baseRates[base] && baseRates[target]) {
rate = baseRates[target] / baseRates[base];
}
return { base, target, rate, timestamp: new Date().toISOString(), source: 'ExchangeRate-API (Cache)' };
}