From 39d327fb45c402b8913b5559809a4990298c816d Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Fri, 28 Nov 2025 11:35:36 +0900
Subject: [PATCH 1/5] =?UTF-8?q?=EC=99=B8=EB=B6=80=20REST=20API=20=EC=97=B0?=
=?UTF-8?q?=EA=B2=B0=20=ED=99=95=EC=9E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/DashboardController.ts | 177 ++++++++++------
.../externalRestApiConnectionService.ts | 199 ++++++++++--------
.../dashboard/data-sources/MultiApiConfig.tsx | 78 ++++++-
frontend/components/admin/dashboard/types.ts | 5 +-
frontend/lib/api/externalDbConnection.ts | 3 +
5 files changed, 308 insertions(+), 154 deletions(-)
diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts
index 521f5250..01ac16c0 100644
--- a/backend-node/src/controllers/DashboardController.ts
+++ b/backend-node/src/controllers/DashboardController.ts
@@ -1,4 +1,7 @@
import { Response } from "express";
+import https from "https";
+import axios, { AxiosRequestConfig } from "axios";
+import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService";
import {
@@ -7,6 +10,7 @@ import {
DashboardListQuery,
} from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService";
+import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/**
* 대시보드 컨트롤러
@@ -590,7 +594,14 @@ export class DashboardController {
res: Response
): Promise {
try {
- const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
+ const {
+ url,
+ method = "GET",
+ headers = {},
+ queryParams = {},
+ body,
+ externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
+ } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({
@@ -608,85 +619,131 @@ export class DashboardController {
}
});
- // 외부 API 호출 (타임아웃 30초)
- // @ts-ignore - node-fetch dynamic import
- const fetch = (await import("node-fetch")).default;
-
- // 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
- const controller = new (global as any).AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
-
- let response;
- try {
- response = await fetch(urlObj.toString(), {
- method: method.toUpperCase(),
- headers: {
- "Content-Type": "application/json",
- ...headers,
- },
- signal: controller.signal,
- });
- clearTimeout(timeoutId);
- } catch (err: any) {
- clearTimeout(timeoutId);
- if (err.name === 'AbortError') {
- throw new Error('외부 API 요청 타임아웃 (30초 초과)');
+ // Axios 요청 설정
+ const requestConfig: AxiosRequestConfig = {
+ url: urlObj.toString(),
+ method: method.toUpperCase(),
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...headers,
+ },
+ timeout: 60000, // 60초 타임아웃
+ validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
+ };
+
+ // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
+ if (externalConnectionId) {
+ try {
+ // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
+ let companyCode = req.user?.companyCode;
+
+ if (!companyCode) {
+ companyCode = "*";
+ }
+
+ // 커넥션 로드
+ const connectionResult =
+ await ExternalRestApiConnectionService.getConnectionById(
+ Number(externalConnectionId),
+ companyCode
+ );
+
+ if (connectionResult.success && connectionResult.data) {
+ const connection = connectionResult.data;
+
+ // 인증 헤더 생성 (DB 토큰 등)
+ const authHeaders =
+ await ExternalRestApiConnectionService.getAuthHeaders(
+ connection.auth_type,
+ connection.auth_config,
+ connection.company_code
+ );
+
+ // 기존 헤더에 인증 헤더 병합
+ requestConfig.headers = {
+ ...requestConfig.headers,
+ ...authHeaders,
+ };
+
+ // API Key가 Query Param인 경우 처리
+ if (
+ connection.auth_type === "api-key" &&
+ connection.auth_config?.keyLocation === "query" &&
+ connection.auth_config?.keyName &&
+ connection.auth_config?.keyValue
+ ) {
+ const currentUrl = new URL(requestConfig.url!);
+ currentUrl.searchParams.append(
+ connection.auth_config.keyName,
+ connection.auth_config.keyValue
+ );
+ requestConfig.url = currentUrl.toString();
+ }
+ }
+ } catch (connError) {
+ logger.error(
+ `외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
+ connError
+ );
}
- throw err;
}
- if (!response.ok) {
+ // Body 처리
+ if (body) {
+ requestConfig.data = body;
+ }
+
+ // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
+ // ExternalRestApiConnectionService와 동일한 로직 적용
+ const bypassDomains = ["thiratis.com"];
+ const hostname = urlObj.hostname;
+ const shouldBypassTls = bypassDomains.some((domain) =>
+ hostname.includes(domain)
+ );
+
+ if (shouldBypassTls) {
+ requestConfig.httpsAgent = new https.Agent({
+ rejectUnauthorized: false,
+ });
+ }
+
+ const response = await axios(requestConfig);
+
+ if (response.status >= 400) {
throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}`
);
}
- // Content-Type에 따라 응답 파싱
- const contentType = response.headers.get("content-type");
- let data: any;
+ let data = response.data;
+ const contentType = response.headers["content-type"];
- // 한글 인코딩 처리 (EUC-KR → UTF-8)
- const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
- urlObj.hostname.includes('data.go.kr');
-
- if (isKoreanApi) {
- // 한국 정부 API는 EUC-KR 인코딩 사용
- const buffer = await response.arrayBuffer();
- const decoder = new TextDecoder('euc-kr');
- const text = decoder.decode(buffer);
-
- try {
- data = JSON.parse(text);
- } catch {
- data = { text, contentType };
- }
- } else if (contentType && contentType.includes("application/json")) {
- data = await response.json();
- } else if (contentType && contentType.includes("text/")) {
- // 텍스트 응답 (CSV, 일반 텍스트 등)
- const text = await response.text();
- data = { text, contentType };
- } else {
- // 기타 응답 (JSON으로 시도)
- try {
- data = await response.json();
- } catch {
- const text = await response.text();
- data = { text, contentType };
- }
+ // 텍스트 응답인 경우 포맷팅
+ if (typeof data === "string") {
+ data = { text: data, contentType };
}
res.status(200).json({
success: true,
data,
});
- } catch (error) {
+ } catch (error: any) {
+ const status = error.response?.status || 500;
+ const message = error.response?.statusText || error.message;
+
+ logger.error("외부 API 호출 오류:", {
+ message,
+ status,
+ data: error.response?.data,
+ });
+
res.status(500).json({
success: false,
message: "외부 API 호출 중 오류가 발생했습니다.",
error:
process.env.NODE_ENV === "development"
- ? (error as Error).message
+ ? message
: "외부 API 호출 오류",
});
}
diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts
index 668c07ae..0599a409 100644
--- a/backend-node/src/services/externalRestApiConnectionService.ts
+++ b/backend-node/src/services/externalRestApiConnectionService.ts
@@ -460,6 +460,105 @@ export class ExternalRestApiConnectionService {
}
}
+ /**
+ * 인증 헤더 생성
+ */
+ static async getAuthHeaders(
+ authType: AuthType,
+ authConfig: any,
+ companyCode?: string
+ ): Promise> {
+ const headers: Record = {};
+
+ if (authType === "db-token") {
+ const cfg = authConfig || {};
+ const {
+ dbTableName,
+ dbValueColumn,
+ dbWhereColumn,
+ dbWhereValue,
+ dbHeaderName,
+ dbHeaderTemplate,
+ } = cfg;
+
+ if (!dbTableName || !dbValueColumn) {
+ throw new Error("DB 토큰 설정이 올바르지 않습니다.");
+ }
+
+ if (!companyCode) {
+ throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
+ }
+
+ const hasWhereColumn = !!dbWhereColumn;
+ const hasWhereValue =
+ dbWhereValue !== undefined &&
+ dbWhereValue !== null &&
+ dbWhereValue !== "";
+
+ // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
+ if (hasWhereColumn !== hasWhereValue) {
+ throw new Error(
+ "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
+ );
+ }
+
+ // 식별자 검증 (간단한 화이트리스트)
+ const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+ if (
+ !identifierRegex.test(dbTableName) ||
+ !identifierRegex.test(dbValueColumn) ||
+ (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
+ ) {
+ throw new Error(
+ "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
+ );
+ }
+
+ let sql = `
+ SELECT ${dbValueColumn} AS token_value
+ FROM ${dbTableName}
+ WHERE company_code = $1
+ `;
+
+ const params: any[] = [companyCode];
+
+ if (hasWhereColumn && hasWhereValue) {
+ sql += ` AND ${dbWhereColumn} = $2`;
+ params.push(dbWhereValue);
+ }
+
+ sql += `
+ ORDER BY updated_date DESC
+ LIMIT 1
+ `;
+
+ const tokenResult: QueryResult = await pool.query(sql, params);
+
+ if (tokenResult.rowCount === 0) {
+ throw new Error("DB에서 토큰을 찾을 수 없습니다.");
+ }
+
+ const tokenValue = tokenResult.rows[0]["token_value"];
+ const headerName = dbHeaderName || "Authorization";
+ const template = dbHeaderTemplate || "Bearer {{value}}";
+
+ headers[headerName] = template.replace("{{value}}", tokenValue);
+ } else if (authType === "bearer" && authConfig?.token) {
+ headers["Authorization"] = `Bearer ${authConfig.token}`;
+ } else if (authType === "basic" && authConfig) {
+ const credentials = Buffer.from(
+ `${authConfig.username}:${authConfig.password}`
+ ).toString("base64");
+ headers["Authorization"] = `Basic ${credentials}`;
+ } else if (authType === "api-key" && authConfig) {
+ if (authConfig.keyLocation === "header") {
+ headers[authConfig.keyName] = authConfig.keyValue;
+ }
+ }
+
+ return headers;
+ }
+
/**
* REST API 연결 테스트 (테스트 요청 데이터 기반)
*/
@@ -471,99 +570,15 @@ export class ExternalRestApiConnectionService {
try {
// 헤더 구성
- const headers = { ...testRequest.headers };
+ let headers = { ...testRequest.headers };
- // 인증 헤더 추가
- if (testRequest.auth_type === "db-token") {
- const cfg = testRequest.auth_config || {};
- const {
- dbTableName,
- dbValueColumn,
- dbWhereColumn,
- dbWhereValue,
- dbHeaderName,
- dbHeaderTemplate,
- } = cfg;
-
- if (!dbTableName || !dbValueColumn) {
- throw new Error("DB 토큰 설정이 올바르지 않습니다.");
- }
-
- if (!userCompanyCode) {
- throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
- }
-
- const hasWhereColumn = !!dbWhereColumn;
- const hasWhereValue =
- dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
-
- // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
- if (hasWhereColumn !== hasWhereValue) {
- throw new Error(
- "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
- );
- }
-
- // 식별자 검증 (간단한 화이트리스트)
- const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
- if (
- !identifierRegex.test(dbTableName) ||
- !identifierRegex.test(dbValueColumn) ||
- (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
- ) {
- throw new Error(
- "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
- );
- }
-
- let sql = `
- SELECT ${dbValueColumn} AS token_value
- FROM ${dbTableName}
- WHERE company_code = $1
- `;
-
- const params: any[] = [userCompanyCode];
-
- if (hasWhereColumn && hasWhereValue) {
- sql += ` AND ${dbWhereColumn} = $2`;
- params.push(dbWhereValue);
- }
-
- sql += `
- ORDER BY updated_date DESC
- LIMIT 1
- `;
-
- const tokenResult: QueryResult = await pool.query(sql, params);
-
- if (tokenResult.rowCount === 0) {
- throw new Error("DB에서 토큰을 찾을 수 없습니다.");
- }
-
- const tokenValue = tokenResult.rows[0]["token_value"];
- const headerName = dbHeaderName || "Authorization";
- const template = dbHeaderTemplate || "Bearer {{value}}";
-
- headers[headerName] = template.replace("{{value}}", tokenValue);
- } else if (
- testRequest.auth_type === "bearer" &&
- testRequest.auth_config?.token
- ) {
- headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
- } else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
- const credentials = Buffer.from(
- `${testRequest.auth_config.username}:${testRequest.auth_config.password}`
- ).toString("base64");
- headers["Authorization"] = `Basic ${credentials}`;
- } else if (
- testRequest.auth_type === "api-key" &&
- testRequest.auth_config
- ) {
- if (testRequest.auth_config.keyLocation === "header") {
- headers[testRequest.auth_config.keyName] =
- testRequest.auth_config.keyValue;
- }
- }
+ // 인증 헤더 생성 및 병합
+ const authHeaders = await this.getAuthHeaders(
+ testRequest.auth_type,
+ testRequest.auth_config,
+ userCompanyCode
+ );
+ headers = { ...headers, ...authHeaders };
// URL 구성
let url = testRequest.base_url;
diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
index 5c516491..86da8fe7 100644
--- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
+++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
import { getApiUrl } from "@/lib/utils/apiUrl";
@@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [apiConnections, setApiConnections] = useState([]);
- const [selectedConnectionId, setSelectedConnectionId] = useState("");
+ const [selectedConnectionId, setSelectedConnectionId] = useState(dataSource.externalConnectionId || "");
const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개)
@@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
loadApiConnections();
}, []);
+ // dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
+ useEffect(() => {
+ if (dataSource.externalConnectionId) {
+ setSelectedConnectionId(dataSource.externalConnectionId);
+ }
+ }, [dataSource.externalConnectionId]);
+
// 외부 커넥션 선택 핸들러
const handleConnectionSelect = async (connectionId: string) => {
setSelectedConnectionId(connectionId);
@@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const updates: Partial = {
endpoint: fullEndpoint,
+ externalConnectionId: connectionId, // 외부 연결 ID 저장
};
const headers: KeyValuePair[] = [];
const queryParams: KeyValuePair[] = [];
+ // 기본 메서드/바디가 있으면 적용
+ if (connection.default_method) {
+ updates.method = connection.default_method as ChartDataSource["method"];
+ }
+ if (connection.default_body) {
+ updates.body = connection.default_body;
+ }
+
// 기본 헤더가 있으면 적용
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
Object.entries(connection.default_headers).forEach(([key, value]) => {
@@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
}
});
+ const bodyPayload =
+ dataSource.body && dataSource.body.trim().length > 0
+ ? dataSource.body
+ : undefined;
+
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
method: dataSource.method || "GET",
headers,
queryParams,
+ body: bodyPayload,
+ externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
}),
});
@@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
+ {/* HTTP 메서드 */}
+
+
+
+
+
+ {/* Request Body (POST/PUT/PATCH 일 때만) */}
+ {(dataSource.method === "POST" ||
+ dataSource.method === "PUT" ||
+ dataSource.method === "PATCH") && (
+
+ )}
+
{/* JSON Path */}
+
+ 생성자
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
생성일
{formatDate(dashboard.createdAt)}
diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts
index c50755b6..11aca0c2 100644
--- a/frontend/lib/api/dashboard.ts
+++ b/frontend/lib/api/dashboard.ts
@@ -90,6 +90,7 @@ export interface Dashboard {
thumbnailUrl?: string;
isPublic: boolean;
createdBy: string;
+ createdByName?: string;
createdAt: string;
updatedAt: string;
tags?: string[];
@@ -97,6 +98,7 @@ export interface Dashboard {
viewCount: number;
elementsCount?: number;
creatorName?: string;
+ companyCode?: string;
elements?: DashboardElement[];
settings?: {
resolution?: string;
From 75bdc19f255d4d7d499bec695aae544276641daa Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Mon, 1 Dec 2025 11:34:22 +0900
Subject: [PATCH 5/5] =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=8A=A4=EC=BC=80?=
=?UTF-8?q?=EC=A5=B4=EB=9F=AC=20=ED=95=A8=EC=88=98=EB=AA=85=20=EC=98=A4?=
=?UTF-8?q?=EB=A5=98=20=EB=B0=8F=20=EB=A7=A4=ED=95=91=20=EC=A1=B0=ED=9A=8C?=
=?UTF-8?q?=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/app.ts | 2 +-
backend-node/src/controllers/batchManagementController.ts | 2 +-
backend-node/src/services/batchSchedulerService.ts | 8 ++++++++
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index 2e753b56..87470dd6 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -282,7 +282,7 @@ app.listen(PORT, HOST, async () => {
// 배치 스케줄러 초기화
try {
- await BatchSchedulerService.initialize();
+ await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts
index 61194485..cc91de80 100644
--- a/backend-node/src/controllers/batchManagementController.ts
+++ b/backend-node/src/controllers/batchManagementController.ts
@@ -594,7 +594,7 @@ export class BatchManagementController {
if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅
try {
- await BatchSchedulerService.scheduleBatchConfig(result.data);
+ await BatchSchedulerService.scheduleBatch(result.data);
console.log(
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
);
diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts
index a8f755c3..780118fb 100644
--- a/backend-node/src/services/batchSchedulerService.ts
+++ b/backend-node/src/services/batchSchedulerService.ts
@@ -124,6 +124,14 @@ export class BatchSchedulerService {
try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
+ // 매핑 정보가 없으면 상세 조회로 다시 가져오기
+ if (!config.batch_mappings || config.batch_mappings.length === 0) {
+ const fullConfig = await BatchService.getBatchConfigById(config.id);
+ if (fullConfig.success && fullConfig.data) {
+ config = fullConfig.data;
+ }
+ }
+
// 실행 로그 생성
const executionLogResponse =
await BatchExecutionLogService.createExecutionLog({