diff --git a/backend-node/.env.example b/backend-node/.env.example index fdba2895..807ae916 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file MAINTENANCE_DATA_SOURCE=memory DOCUMENT_DATA_SOURCE=memory + +# OpenWeatherMap API 키 추가 (실시간 날씨) +# https://openweathermap.org/api 에서 무료 가입 후 발급 +OPENWEATHER_API_KEY=your_openweathermap_api_key_here + diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index f737a833..b84dc218 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -17,19 +17,54 @@ export class OpenApiProxyController { console.log(`🌤️ 날씨 조회 요청: ${city}`); - // 기상청 API Hub 키 확인 + // 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 키가 없으면 테스트 모드로 실시간 날씨 제공 + // API 키가 없으면 오류 반환 if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.'); + res.status(503).json({ + success: false, + message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.', }); return; } @@ -48,32 +83,39 @@ export class OpenApiProxyController { // 기상청 API Hub 사용 (apihub.kma.go.kr) const now = new Date(); - // 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능 - // 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회 - const minute = now.getMinutes(); - let targetTime = new Date(now); + // 한국 시간(KST = UTC+9)으로 변환 + const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 + const kstNow = new Date(now.getTime() + kstOffset); - if (minute < 10) { - // 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로 - targetTime = new Date(now.getTime() - 60 * 60 * 1000); - } + // 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표 + // 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정 + const targetTime = new Date(kstNow); // tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회) - const year = targetTime.getFullYear(); - const month = String(targetTime.getMonth() + 1).padStart(2, '0'); - const day = String(targetTime.getDate()).padStart(2, '0'); - const hour = String(targetTime.getHours()).padStart(2, '0'); + 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 - 지상관측시간자료 - const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php'; + // 기상청 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}, 시간: ${tm})`); + console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`); const response = await axios.get(url, { params: { - tm: tm, - stn: 0, // 0 = 전체 관측소 데이터 조회 + tm1: tm1, + tm2: tm2, + stn: regionCode.stnId, // 특정 관측소만 조회 authKey: apiKey, help: 0, disp: 1, @@ -95,30 +137,36 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 날씨 조회 실패:', error); - // API 호출 실패 시 자동으로 테스트 모드로 전환 + // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { const status = error.response?.status; - // 모든 오류 → 테스트 데이터 반환 - console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, - }); + 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 { - // 예상치 못한 오류 → 테스트 데이터 반환 - console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + res.status(500).json({ + success: false, + message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } @@ -169,15 +217,19 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 환율 조회 실패:', error); - // API 호출 실패 시 실제 근사값 반환 - console.log('⚠️ API 오류 발생. 근사값을 반환합니다.'); - const { base = 'KRW', target = 'USD' } = req.query; - const approximateRate = generateRealisticExchangeRate(base as string, target as string); - - res.json({ - success: true, - data: approximateRate, - }); + // API 호출 실패 시 명확한 오류 메시지 반환 + if (axios.isAxiosError(error)) { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 오류가 발생했습니다.', + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', + }); + } } } @@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st throw new Error('날씨 데이터를 파싱할 수 없습니다.'); } - // 요청한 관측소(stnId)의 데이터 찾기 - const targetLine = lines.find((line: string) => { + // 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨) + const targetLines = lines.filter((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1) }); - if (!targetLine) { + 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) diff --git a/backend-node/src/routes/vehicleRoutes.ts b/backend-node/src/routes/vehicleRoutes.ts new file mode 100644 index 00000000..b8cfa8ac --- /dev/null +++ b/backend-node/src/routes/vehicleRoutes.ts @@ -0,0 +1,52 @@ +import express from "express"; +import { query } from "../database/db"; + +const router = express.Router(); + +/** + * 차량 위치 자동 업데이트 API + * - 모든 active/warning 상태 차량의 위치를 랜덤하게 조금씩 이동 + */ +router.post("/move", async (req, res) => { + try { + // move_vehicles() 함수 실행 + await query("SELECT move_vehicles()"); + + res.json({ + success: true, + message: "차량 위치가 업데이트되었습니다" + }); + } catch (error) { + console.error("차량 위치 업데이트 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 업데이트 실패" + }); + } +}); + +/** + * 차량 위치 목록 조회 + */ +router.get("/locations", async (req, res) => { + try { + const result = await query(` + SELECT * FROM vehicle_locations + ORDER BY last_update DESC + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error("차량 위치 조회 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 조회 실패" + }); + } +}); + +export default router; + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 1d8c6fbf..9b93bca6 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -526,12 +526,9 @@ export function CanvasElement({
{lastUpdated ? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', { diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index e91746a9..3ad09305 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { {/* 헤더 */}
총 {markers.length.toLocaleString()}개 마커
) : ( diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index 428e849e..dd4652d5 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -323,67 +323,71 @@ export default function TodoWidget({ element }: TodoWidgetProps) { return (마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}
날씨 정보 불러오는 중...
+실제 기상청 API 연결 중...
+실시간 관측 데이터를 가져오고 있습니다
+{error || '날씨 정보를 불러올 수 없습니다.'}
++ {isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'} +
++ {error || '날씨 정보를 불러올 수 없습니다.'} +
+ {isTestMode && ( ++ 임시 데이터가 표시됩니다 +
+ )} +