From 39d327fb45c402b8913b5559809a4990298c816d Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Fri, 28 Nov 2025 11:35:36 +0900
Subject: [PATCH 1/6] =?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 */}
,
+ document.body
)}
);
@@ -462,8 +495,16 @@ const SelectBasicComponent: React.FC = ({
- {isOpen && !isDesignMode && (
-
+ {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
+ {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
+
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? (
@@ -479,7 +520,8 @@ const SelectBasicComponent: React.FC
= ({
) : (
옵션이 없습니다
)}
-
+
,
+ document.body
)}
);
@@ -544,9 +586,13 @@ const SelectBasicComponent: React.FC = ({
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
+ updateDropdownPosition();
+ setIsOpen(true);
+ }}
+ onFocus={() => {
+ updateDropdownPosition();
setIsOpen(true);
}}
- onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
@@ -555,8 +601,16 @@ const SelectBasicComponent: React.FC = ({
)}
readOnly={isDesignMode}
/>
- {isOpen && !isDesignMode && filteredOptions.length > 0 && (
-
+ {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
+ {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
+
{filteredOptions.map((option, index) => (
= ({
{option.label}
))}
-
+
,
+ document.body
)}
);
@@ -604,8 +659,16 @@ const SelectBasicComponent: React.FC = ({
- {isOpen && !isDesignMode && (
-
+ {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
+ {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
+
= ({
))}
-
+ ,
+ document.body
)}
);
@@ -647,7 +711,12 @@ const SelectBasicComponent: React.FC = ({
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
- onClick={() => !isDesignMode && setIsOpen(true)}
+ onClick={() => {
+ if (!isDesignMode) {
+ updateDropdownPosition();
+ setIsOpen(true);
+ }
+ }}
style={{
pointerEvents: isDesignMode ? "none" : "auto",
height: "100%"
@@ -680,22 +749,30 @@ const SelectBasicComponent: React.FC = ({
{placeholder}
)}
- {isOpen && !isDesignMode && (
-
+ {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
+ {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
+
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
- const isSelected = selectedValues.includes(option.value);
+ const isOptionSelected = selectedValues.includes(option.value);
return (
{
- const newVals = isSelected
+ const newVals = isOptionSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
@@ -708,7 +785,7 @@ const SelectBasicComponent: React.FC
= ({
+ ,
+ document.body
)}
);
@@ -749,8 +827,16 @@ const SelectBasicComponent: React.FC
= ({
- {isOpen && !isDesignMode && (
-
+ {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
+ {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
+
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? (
@@ -766,7 +852,8 @@ const SelectBasicComponent: React.FC
= ({
) : (
옵션이 없습니다
)}
-
+
,
+ document.body
)}
);
From 53eab6ac9c6f1b5ec8a3d5ed4927040dc5ff9dc5 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Mon, 1 Dec 2025 10:30:47 +0900
Subject: [PATCH 3/6] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?=
=?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?=
=?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9D=84=20company=5Fcode=20=EA=B8=B0?=
=?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/controllers/DashboardController.ts | 2 +-
backend-node/src/services/DashboardService.ts | 19 +++++++++++--------
2 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts
index 01ac16c0..76b666f0 100644
--- a/backend-node/src/controllers/DashboardController.ts
+++ b/backend-node/src/controllers/DashboardController.ts
@@ -419,7 +419,7 @@ export class DashboardController {
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string,
category: req.query.category as string,
- createdBy: userId, // 본인이 만든 대시보드만
+ // createdBy 제거 - 회사 대시보드 전체 표시
};
const result = await DashboardService.getDashboards(
diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts
index 4b13d6b8..5f3cea61 100644
--- a/backend-node/src/services/DashboardService.ts
+++ b/backend-node/src/services/DashboardService.ts
@@ -178,21 +178,24 @@ export class DashboardService {
let params: any[] = [];
let paramIndex = 1;
- // 회사 코드 필터링 (최우선)
+ // 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
if (companyCode) {
- whereConditions.push(`d.company_code = $${paramIndex}`);
- params.push(companyCode);
- paramIndex++;
- }
-
- // 권한 필터링
- if (userId) {
+ if (companyCode === '*') {
+ // 최고 관리자는 모든 대시보드 조회 가능
+ } else {
+ whereConditions.push(`d.company_code = $${paramIndex}`);
+ params.push(companyCode);
+ paramIndex++;
+ }
+ } else if (userId) {
+ // 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
whereConditions.push(
`(d.created_by = $${paramIndex} OR d.is_public = true)`
);
params.push(userId);
paramIndex++;
} else {
+ // 비로그인 사용자는 공개 대시보드만
whereConditions.push("d.is_public = true");
}
From 64c11d548cb2aac17a293fe3046569ebb4c120e3 Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Mon, 1 Dec 2025 10:44:56 +0900
Subject: [PATCH 4/6] =?UTF-8?q?=EB=94=94=EC=A7=80=ED=84=B8=20=ED=8A=B8?=
=?UTF-8?q?=EC=9C=88=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EC=B5=9C=EA=B3=A0=20=EA=B4=80=EB=A6=AC?=
=?UTF-8?q?=EC=9E=90=20=EA=B6=8C=ED=95=9C=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../digitalTwinLayoutController.ts | 41 ++++++++++++++-----
1 file changed, 31 insertions(+), 10 deletions(-)
diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts
index d7ecbae1..f95ed0e2 100644
--- a/backend-node/src/controllers/digitalTwinLayoutController.ts
+++ b/backend-node/src/controllers/digitalTwinLayoutController.ts
@@ -22,11 +22,19 @@ export const getLayouts = async (
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
- WHERE l.company_code = $1
`;
- const params: any[] = [companyCode];
- let paramIndex = 2;
+ const params: any[] = [];
+ let paramIndex = 1;
+
+ // 최고 관리자는 모든 레이아웃 조회 가능
+ if (companyCode && companyCode !== '*') {
+ query += ` WHERE l.company_code = $${paramIndex}`;
+ params.push(companyCode);
+ paramIndex++;
+ } else {
+ query += ` WHERE 1=1`;
+ }
if (externalDbConnectionId) {
query += ` AND l.external_db_connection_id = $${paramIndex}`;
@@ -75,14 +83,27 @@ export const getLayoutById = async (
const companyCode = req.user?.companyCode;
const { id } = req.params;
- // 레이아웃 기본 정보
- const layoutQuery = `
- SELECT l.*
- FROM digital_twin_layout l
- WHERE l.id = $1 AND l.company_code = $2
- `;
+ // 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
+ let layoutQuery: string;
+ let layoutParams: any[];
- const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
+ if (companyCode && companyCode !== '*') {
+ layoutQuery = `
+ SELECT l.*
+ FROM digital_twin_layout l
+ WHERE l.id = $1 AND l.company_code = $2
+ `;
+ layoutParams = [id, companyCode];
+ } else {
+ layoutQuery = `
+ SELECT l.*
+ FROM digital_twin_layout l
+ WHERE l.id = $1
+ `;
+ layoutParams = [id];
+ }
+
+ const layoutResult = await pool.query(layoutQuery, layoutParams);
if (layoutResult.rowCount === 0) {
return res.status(404).json({
From ad0a84f2c3978e3652c708668a4fec8405f2c8fe Mon Sep 17 00:00:00 2001
From: dohyeons
Date: Mon, 1 Dec 2025 11:07:35 +0900
Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?=
=?UTF-8?q?=EB=93=9C=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EC=83=9D=EC=84=B1?=
=?UTF-8?q?=EC=9E=90=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend-node/src/services/DashboardService.ts | 9 +++++++--
.../(main)/admin/dashboard/DashboardListClient.tsx | 12 ++++++++++++
frontend/lib/api/dashboard.ts | 2 ++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts
index 5f3cea61..0d96b285 100644
--- a/backend-node/src/services/DashboardService.ts
+++ b/backend-node/src/services/DashboardService.ts
@@ -231,7 +231,7 @@ export class DashboardService {
const whereClause = whereConditions.join(" AND ");
- // 대시보드 목록 조회 (users 테이블 조인 제거)
+ // 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
const dashboardQuery = `
SELECT
d.id,
@@ -245,13 +245,16 @@ export class DashboardService {
d.tags,
d.category,
d.view_count,
+ d.company_code,
+ u.user_name as created_by_name,
COUNT(de.id) as elements_count
FROM dashboards d
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
+ LEFT JOIN user_info u ON d.created_by = u.user_id
WHERE ${whereClause}
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
- d.view_count
+ d.view_count, d.company_code, u.user_name
ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
@@ -280,12 +283,14 @@ export class DashboardService {
thumbnailUrl: row.thumbnail_url,
isPublic: row.is_public,
createdBy: row.created_by,
+ createdByName: row.created_by_name || row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags || "[]"),
category: row.category,
viewCount: parseInt(row.view_count || "0"),
elementsCount: parseInt(row.elements_count || "0"),
+ companyCode: row.company_code,
})),
pagination: {
page,
diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx
index e7680584..613ab16b 100644
--- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx
+++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx
@@ -195,6 +195,7 @@ export default function DashboardListClient() {
제목
설명
+ 생성자
생성일
수정일
작업
@@ -209,6 +210,9 @@ export default function DashboardListClient() {
+
+
+
@@ -277,6 +281,7 @@ export default function DashboardListClient() {
제목
설명
+ 생성자
생성일
수정일
작업
@@ -296,6 +301,9 @@ export default function DashboardListClient() {
{dashboard.description || "-"}
+
+ {dashboard.createdByName || dashboard.createdBy || "-"}
+
{formatDate(dashboard.createdAt)}
@@ -363,6 +371,10 @@ export default function DashboardListClient() {
설명
{dashboard.description || "-"}
+
+ 생성자
+ {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 6/6] =?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({