Files
TK-FB-Project/api.hyungi.net/services/weatherService.js
Hyungi Ahn 74d3a78aa3 feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:27:22 +09:00

402 lines
11 KiB
JavaScript

/**
* 날씨 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<Object>} 날씨 데이터
*/
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<string[]>} 해당하는 날씨 조건 코드 배열
*/
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
};