/** * 날씨 API 서비스 * * 기상청 단기예보 API를 사용하여 현재 날씨 정보를 조회 * 날씨 조건에 따른 안전 체크리스트 필터링 지원 * * @since 2026-02-02 */ const axios = require('axios'); const logger = require('../utils/logger'); const { getDb } = require('../dbPool'); // 기상청 API 설정 const WEATHER_BASE_URL = process.env.WEATHER_API_URL || 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0'; const WEATHER_API = { baseUrl: WEATHER_BASE_URL, ultraShortUrl: `${WEATHER_BASE_URL}/getUltraSrtNcst`, shortForecastUrl: `${WEATHER_BASE_URL}/getVilageFcst`, apiKey: process.env.WEATHER_API_KEY || '', // 화성시 남양읍 좌표 (격자 좌표) // 위도: 37.2072, 경도: 126.8232 defaultLocation: { nx: 57, // 화성시 남양읍 X 좌표 ny: 119 // 화성시 남양읍 Y 좌표 } }; // PTY (강수형태) 코드 const PTY_CODES = { 0: 'none', // 없음 1: 'rain', // 비 2: 'rain', // 비/눈 (혼합) 3: 'snow', // 눈 4: 'rain', // 소나기 5: 'rain', // 빗방울 6: 'rain', // 빗방울/눈날림 7: 'snow' // 눈날림 }; // SKY (하늘상태) 코드 const SKY_CODES = { 1: 'clear', // 맑음 3: 'cloudy', // 구름많음 4: 'overcast' // 흐림 }; /** * 현재 날씨 정보 조회 (초단기실황) * @param {number} nx - 격자 X 좌표 (optional) * @param {number} ny - 격자 Y 좌표 (optional) * @returns {Promise} 날씨 데이터 */ async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATHER_API.defaultLocation.ny) { if (!WEATHER_API.apiKey) { logger.warn('날씨 API 키가 설정되지 않음. 기본값 반환'); return getDefaultWeatherData(); } try { // 현재 시간 기준으로 base_date, base_time 계산 const now = new Date(); const baseDate = formatDate(now); const baseTime = getBaseTime(now); logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny }); // Encoding 키는 이미 URL 인코딩되어 있으므로 직접 URL에 추가 (이중 인코딩 방지) const url = `${WEATHER_API.ultraShortUrl}?serviceKey=${WEATHER_API.apiKey}` + `&pageNo=1&numOfRows=10&dataType=JSON` + `&base_date=${baseDate}&base_time=${baseTime}` + `&nx=${nx}&ny=${ny}`; const response = await axios.get(url, { timeout: 5000 }); if (response.data?.response?.header?.resultCode !== '00') { throw new Error(`API 오류: ${response.data?.response?.header?.resultMsg}`); } const items = response.data.response.body.items.item; const weatherData = parseWeatherItems(items); logger.info('날씨 데이터 파싱 완료', weatherData); return weatherData; } catch (error) { logger.error('날씨 API 호출 실패', { error: error.message }); return getDefaultWeatherData(); } } /** * 날씨 API 응답 파싱 */ function parseWeatherItems(items) { const data = { temperature: null, humidity: null, windSpeed: null, precipitation: null, precipitationType: null, skyCondition: null }; if (!items || !Array.isArray(items)) { return data; } items.forEach(item => { switch (item.category) { case 'T1H': // 기온 data.temperature = parseFloat(item.obsrValue); break; case 'REH': // 습도 data.humidity = parseInt(item.obsrValue); break; case 'WSD': // 풍속 data.windSpeed = parseFloat(item.obsrValue); break; case 'RN1': // 1시간 강수량 data.precipitation = parseFloat(item.obsrValue) || 0; break; case 'PTY': // 강수형태 data.precipitationType = parseInt(item.obsrValue); break; } }); return data; } /** * 날씨 데이터를 기반으로 조건 판단 * @param {Object} weatherData - 날씨 데이터 * @returns {Promise} 해당하는 날씨 조건 코드 배열 */ async function determineWeatherConditions(weatherData) { const conditions = []; // DB에서 날씨 조건 기준 조회 const db = await getDb(); const [thresholds] = await db.execute(` SELECT condition_code, temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold FROM weather_conditions WHERE is_active = TRUE `); // 조건 판단 thresholds.forEach(threshold => { let matches = false; switch (threshold.condition_code) { case 'rain': // 강수형태가 비(1,2,4,5,6) 또는 강수량 > 0 if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'rain') { matches = true; } else if (weatherData.precipitation > 0 && threshold.precip_threshold !== null) { matches = weatherData.precipitation >= threshold.precip_threshold; } break; case 'snow': // 강수형태가 눈(3,7) if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'snow') { matches = true; } break; case 'heat': // 기온이 폭염 기준 이상 if (weatherData.temperature !== null && threshold.temp_threshold_min !== null) { matches = weatherData.temperature >= threshold.temp_threshold_min; } break; case 'cold': // 기온이 한파 기준 이하 if (weatherData.temperature !== null && threshold.temp_threshold_max !== null) { matches = weatherData.temperature <= threshold.temp_threshold_max; } break; case 'wind': // 풍속이 강풍 기준 이상 if (weatherData.windSpeed !== null && threshold.wind_threshold !== null) { matches = weatherData.windSpeed >= threshold.wind_threshold; } break; case 'clear': // 강수 없고 기온이 정상 범위 if (!weatherData.precipitationType || weatherData.precipitationType === 0) { if (weatherData.temperature !== null && weatherData.temperature > -10 && weatherData.temperature < 35) { matches = true; } } break; } if (matches) { conditions.push(threshold.condition_code); } }); // 조건이 없으면 기본으로 'clear' 추가 if (conditions.length === 0) { conditions.push('clear'); } logger.info('날씨 조건 판단 완료', { weatherData, conditions }); return conditions; } /** * TBM 세션에 날씨 정보 저장 * @param {number} sessionId - TBM 세션 ID * @param {Object} weatherData - 날씨 데이터 * @param {string[]} conditions - 날씨 조건 배열 */ async function saveWeatherRecord(sessionId, weatherData, conditions) { const db = await getDb(); try { const weatherDate = new Date().toISOString().split('T')[0]; await db.execute(` INSERT INTO tbm_weather_records (session_id, weather_date, temperature, humidity, wind_speed, precipitation, weather_condition, weather_conditions, data_source, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'api', NOW()) ON DUPLICATE KEY UPDATE temperature = VALUES(temperature), humidity = VALUES(humidity), wind_speed = VALUES(wind_speed), precipitation = VALUES(precipitation), weather_condition = VALUES(weather_condition), weather_conditions = VALUES(weather_conditions), fetched_at = NOW() `, [ sessionId, weatherDate, weatherData.temperature, weatherData.humidity, weatherData.windSpeed, weatherData.precipitation, conditions[0] || 'clear', // 주요 조건 JSON.stringify(conditions) // 모든 조건 ]); logger.info('날씨 기록 저장 완료', { sessionId, conditions }); return { success: true }; } catch (error) { logger.error('날씨 기록 저장 실패', { sessionId, error: error.message }); throw error; } } /** * TBM 세션의 날씨 기록 조회 * @param {number} sessionId - TBM 세션 ID */ async function getWeatherRecord(sessionId) { const db = await getDb(); const [rows] = await db.execute(` SELECT wr.*, wc.condition_name, wc.icon FROM tbm_weather_records wr LEFT JOIN weather_conditions wc ON wr.weather_condition = wc.condition_code WHERE wr.session_id = ? `, [sessionId]); if (rows.length === 0) { return null; } const record = rows[0]; // JSON 문자열 파싱 if (record.weather_conditions && typeof record.weather_conditions === 'string') { try { record.weather_conditions = JSON.parse(record.weather_conditions); } catch (e) { record.weather_conditions = []; } } return record; } /** * 날씨 조건 코드 목록 조회 */ async function getWeatherConditionList() { const db = await getDb(); const [rows] = await db.execute(` SELECT condition_code, condition_name, description, icon, temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold FROM weather_conditions WHERE is_active = TRUE ORDER BY display_order `); return rows; } /** * 기본 날씨 데이터 반환 (API 실패 시) */ function getDefaultWeatherData() { return { temperature: 20, humidity: 50, windSpeed: 2, precipitation: 0, precipitationType: 0, skyCondition: 'clear', isDefault: true }; } /** * 날짜 포맷 (YYYYMMDD) */ function formatDate(date) { 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}`; } /** * 초단기실황 API용 기준시간 계산 * 매시간 정각에 생성되고 10분 후에 제공됨 */ function getBaseTime(date) { let hours = date.getHours(); let minutes = date.getMinutes(); // 10분 이전이면 이전 시간 데이터 사용 if (minutes < 10) { hours = hours - 1; if (hours < 0) hours = 23; } return String(hours).padStart(2, '0') + '00'; } /** * 위경도를 기상청 격자 좌표로 변환 * LCC (Lambert Conformal Conic) 투영법 사용 */ function convertToGrid(lat, lon) { const RE = 6371.00877; // 지구 반경(km) const GRID = 5.0; // 격자 간격(km) const SLAT1 = 30.0; // 투영 위도1(degree) const SLAT2 = 60.0; // 투영 위도2(degree) const OLON = 126.0; // 기준점 경도(degree) const OLAT = 38.0; // 기준점 위도(degree) const XO = 43; // 기준점 X좌표(GRID) const YO = 136; // 기준점 Y좌표(GRID) const DEGRAD = Math.PI / 180.0; const re = RE / GRID; const slat1 = SLAT1 * DEGRAD; const slat2 = SLAT2 * DEGRAD; const olon = OLON * DEGRAD; const olat = OLAT * DEGRAD; let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; let ro = Math.tan(Math.PI * 0.25 + olat * 0.5); ro = re * sf / Math.pow(ro, sn); let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5); ra = re * sf / Math.pow(ra, sn); let theta = lon * DEGRAD - olon; if (theta > Math.PI) theta -= 2.0 * Math.PI; if (theta < -Math.PI) theta += 2.0 * Math.PI; theta *= sn; const x = Math.floor(ra * Math.sin(theta) + XO + 0.5); const y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5); return { nx: x, ny: y }; } module.exports = { getCurrentWeather, determineWeatherConditions, saveWeatherRecord, getWeatherRecord, getWeatherConditionList, convertToGrid, getDefaultWeatherData };