환율과 날씨 위젯 api 활용 완료 날씨는 현재 기상청 ai hub로 사용중 나중에 공공데이터 서비스가 가능할때 바꾸기 바람
This commit is contained in:
parent
75865e2283
commit
26649b78f3
|
|
@ -50,6 +50,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
|||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -205,6 +206,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
|
|||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -134,80 +134,33 @@ export class OpenApiProxyController {
|
|||
|
||||
console.log(`💱 환율 조회 요청: ${base} -> ${target}`);
|
||||
|
||||
// 한국은행 API 키 확인
|
||||
const apiKey = process.env.BOK_API_KEY;
|
||||
// ExchangeRate-API.com 사용 (무료, API 키 불필요)
|
||||
const url = `https://open.er-api.com/v6/latest/${base}`;
|
||||
|
||||
// API 키가 없으면 테스트 데이터 반환
|
||||
if (!apiKey) {
|
||||
console.log('⚠️ 한국은행 API 키가 없습니다. 테스트 데이터를 반환합니다.');
|
||||
const testRate = generateTestExchangeRate(base as string, target as string);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: testRate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 한국은행 API는 KRW 기준만 지원
|
||||
// KRW가 base나 target 중 하나여야 함
|
||||
let currencyCode: string;
|
||||
let isReverse = false;
|
||||
|
||||
if (base === 'KRW') {
|
||||
// KRW → USD (역수 계산 필요)
|
||||
currencyCode = target as string;
|
||||
isReverse = true;
|
||||
} else if (target === 'KRW') {
|
||||
// USD → KRW (정상)
|
||||
currencyCode = base as string;
|
||||
isReverse = false;
|
||||
} else {
|
||||
// KRW가 없는 경우 (USD → EUR 등)
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '한국은행 API는 KRW 기준 환율만 지원합니다. base나 target 중 하나는 KRW여야 합니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 통화 코드 → 한국은행 통계코드 매핑
|
||||
const statCode = getBOKStatCode(currencyCode);
|
||||
if (!statCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `지원하지 않는 통화입니다: ${currencyCode}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 오늘 날짜 (YYYYMMDD)
|
||||
const today = new Date();
|
||||
const searchDate = today.getFullYear() +
|
||||
String(today.getMonth() + 1).padStart(2, '0') +
|
||||
String(today.getDate()).padStart(2, '0');
|
||||
|
||||
// 한국은행 API 호출
|
||||
const url = 'https://ecos.bok.or.kr/api/StatisticSearch/' +
|
||||
`${apiKey}/json/kr/1/1/036Y001/DD/${searchDate}/${searchDate}/${statCode}`;
|
||||
|
||||
console.log(`📡 한국은행 API 호출: ${currencyCode} (통계코드: ${statCode})`);
|
||||
console.log(`📡 ExchangeRate-API 호출: ${base} -> ${target}`);
|
||||
|
||||
const response = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
console.log('📊 한국은행 API 응답:', JSON.stringify(response.data, null, 2));
|
||||
console.log('📊 ExchangeRate-API 응답:', response.data);
|
||||
|
||||
// 한국은행 API 응답 파싱
|
||||
const exchangeData = parseBOKExchangeData(
|
||||
response.data,
|
||||
base as string,
|
||||
target as string,
|
||||
isReverse
|
||||
);
|
||||
// 환율 데이터 추출
|
||||
const rates = response.data?.rates;
|
||||
if (!rates || !rates[target as string]) {
|
||||
throw new Error(`환율 데이터를 찾을 수 없습니다: ${base} -> ${target}`);
|
||||
}
|
||||
|
||||
console.log(`✅ 환율 조회 성공: 1 ${base} = ${exchangeData.rate} ${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,
|
||||
|
|
@ -216,26 +169,15 @@ export class OpenApiProxyController {
|
|||
} catch (error: unknown) {
|
||||
console.error('❌ 환율 조회 실패:', error);
|
||||
|
||||
// API 호출 실패 시 자동으로 테스트 모드로 전환
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.');
|
||||
const { base = 'KRW', target = 'USD' } = req.query;
|
||||
const testRate = generateTestExchangeRate(base as string, target as string);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: testRate,
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.');
|
||||
const { base = 'KRW', target = 'USD' } = req.query;
|
||||
const testRate = generateTestExchangeRate(base as string, target as string);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: testRate,
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1304,3 +1246,21 @@ function generateTestExchangeRate(base: string, target: string) {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 실제 근사값 환율 데이터 생성
|
||||
*/
|
||||
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)' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
|
|
@ -330,16 +342,27 @@ export function CanvasElement({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ExchangeWidget
|
||||
baseCurrency={element.config?.baseCurrency || "KRW"}
|
||||
targetCurrency={element.config?.targetCurrency || "USD"}
|
||||
refreshInterval={600000}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 위젯 렌더링 (기존 방식)
|
||||
// 기타 위젯 렌더링
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||||
>
|
||||
<div>
|
||||
<div className="mb-2 text-4xl">
|
||||
{element.type === "widget" && element.subtype === "exchange" && "💱"}
|
||||
{element.type === "widget" && element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="mb-2 text-4xl">🔧</div>
|
||||
<div className="whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 환율 위젯 컴포넌트
|
||||
* - 실시간 환율 정보를 표시
|
||||
* - 한국은행(BOK) API 연동
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
|
||||
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface ExchangeWidgetProps {
|
||||
baseCurrency?: string;
|
||||
targetCurrency?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function ExchangeWidget({
|
||||
baseCurrency = 'KRW',
|
||||
targetCurrency = 'USD',
|
||||
refreshInterval = 600000,
|
||||
}: ExchangeWidgetProps) {
|
||||
const [base, setBase] = useState(baseCurrency);
|
||||
const [target, setTarget] = useState(targetCurrency);
|
||||
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 지원 통화 목록
|
||||
const currencies = [
|
||||
{ value: 'KRW', label: '🇰🇷 KRW (원)', symbol: '₩' },
|
||||
{ value: 'USD', label: '🇺🇸 USD (달러)', symbol: '$' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR (유로)', symbol: '€' },
|
||||
{ value: 'JPY', label: '🇯🇵 JPY (엔)', symbol: '¥' },
|
||||
{ value: 'CNY', label: '🇨🇳 CNY (위안)', symbol: '¥' },
|
||||
{ value: 'GBP', label: '🇬🇧 GBP (파운드)', symbol: '£' },
|
||||
];
|
||||
|
||||
// 환율 조회
|
||||
const fetchExchangeRate = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = await getExchangeRate(base, target);
|
||||
setExchangeRate(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('환율 조회 실패:', err);
|
||||
|
||||
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
} else if (err.response?.status === 401) {
|
||||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로딩 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
fetchExchangeRate();
|
||||
const interval = setInterval(fetchExchangeRate, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [base, target, refreshInterval]);
|
||||
|
||||
// 통화 스왑
|
||||
const handleSwap = () => {
|
||||
setBase(target);
|
||||
setTarget(base);
|
||||
};
|
||||
|
||||
// 통화 기호 가져오기
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
|
||||
<p className="text-sm text-gray-600">환율 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통화 선택 */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Select value={base} onValueChange={setBase}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-10 w-10 p-0 rounded-full hover:bg-white"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 환율 표시 */}
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산 예시 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 출처 */}
|
||||
<div className="mt-4 pt-3 border-t text-center">
|
||||
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue