From 2c447fd32576b870e931608f94133f3c9cfc4d04 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 2 Dec 2025 13:20:49 +0900 Subject: [PATCH] =?UTF-8?q?restapi=EB=8F=84=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 34 +- .../controllers/screenManagementController.ts | 35 +- .../src/services/flowDefinitionService.ts | 18 +- .../src/services/screenManagementService.ts | 64 ++- backend-node/src/types/flow.ts | 12 +- .../app/(main)/admin/flow-management/page.tsx | 409 ++++++++++++------ frontend/components/screen/ScreenDesigner.tsx | 47 +- frontend/components/screen/ScreenList.tsx | 300 ++++++++++--- .../table-list/TableListComponent.tsx | 83 ++-- frontend/types/flow.ts | 7 + 10 files changed, 749 insertions(+), 260 deletions(-) diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 85ad2259..e03bfe25 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -32,8 +32,17 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName, dbSourceType, dbConnectionId } = - req.body; + const { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + // REST API 관련 필드 + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; @@ -43,6 +52,9 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, userCompanyCode, }); @@ -54,8 +66,11 @@ export class FlowController { return; } - // 테이블 이름이 제공된 경우에만 존재 확인 - if (tableName) { + // REST API인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi"; + + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) + if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -68,7 +83,16 @@ export class FlowController { } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName, dbSourceType, dbConnectionId }, + { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, userId, userCompanyCode ); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 0ff80988..c7ecf75e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,42 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, tableName, description, isActive } = req.body; + const { + screenName, + tableName, + description, + isActive, + // REST API 관련 필드 추가 + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; + + console.log("화면 정보 수정 요청:", { + screenId: id, + dataSourceType, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }); await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, tableName, description, isActive }, + { + screenName, + tableName, + description, + isActive, + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 759178c1..4416faa0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -27,13 +27,20 @@ export class FlowDefinitionService { tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + restApiConnectionId: request.restApiConnectionId, + restApiEndpoint: request.restApiEndpoint, + restApiJsonPath: request.restApiJsonPath, companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO flow_definition ( + name, description, table_name, db_source_type, db_connection_id, + rest_api_connection_id, rest_api_endpoint, rest_api_json_path, + company_code, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * `; @@ -43,6 +50,9 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + request.restApiConnectionId || null, + request.restApiEndpoint || null, + request.restApiJsonPath || "data", companyCode, userId, ]; @@ -206,6 +216,10 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + // REST API 관련 필드 + restApiConnectionId: row.rest_api_connection_id, + restApiEndpoint: row.rest_api_endpoint, + restApiJsonPath: row.rest_api_json_path, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 71550fd6..007a39e7 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -326,7 +326,19 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, + updateData: { + screenName: string; + tableName?: string; + description?: string; + isActive: string; + // REST API 관련 필드 추가 + dataSourceType?: string; + dbSourceType?: string; + dbConnectionId?: number; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; + }, userCompanyCode: string ): Promise { // 권한 확인 @@ -348,24 +360,43 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 (tableName 포함) + // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, - updated_date = $5 - WHERE screen_id = $6`, + updated_date = $5, + data_source_type = $6, + db_source_type = $7, + db_connection_id = $8, + rest_api_connection_id = $9, + rest_api_endpoint = $10, + rest_api_json_path = $11 + WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), + updateData.dataSourceType || "database", + updateData.dbSourceType || "internal", + updateData.dbConnectionId || null, + updateData.restApiConnectionId || null, + updateData.restApiEndpoint || null, + updateData.restApiJsonPath || null, screenId, ] ); + + console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { + dataSourceType: updateData.dataSourceType, + restApiConnectionId: updateData.restApiConnectionId, + restApiEndpoint: updateData.restApiEndpoint, + restApiJsonPath: updateData.restApiJsonPath, + }); } /** @@ -2016,37 +2047,40 @@ export class ScreenManagementService { // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 해당 회사의 기존 화면 코드들 조회 + // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) + // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + WHERE screen_code LIKE $1 + ORDER BY screen_code DESC`, + [`${companyCode}_%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` ); + console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log(`🔍 패턴: ${pattern}`); + for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); + console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } - // 다음 순번으로 화면 코드 생성 (3자리 패딩) + // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; - const paddedNumber = nextNumber.toString().padStart(3, "0"); - - const newCode = `${companyCode}_${paddedNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`); + // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 + const newCode = `${companyCode}_${nextNumber}`; + console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c127eccc..c877a2b3 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -8,8 +8,12 @@ export interface FlowDefinition { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -22,8 +26,12 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index bb2bf04a..5a335daf 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; export default function FlowManagementPage() { const router = useRouter(); @@ -52,13 +53,19 @@ export default function FlowManagementPage() { ); const [loadingTables, setLoadingTables] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false); - const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + // 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API) + const [selectedDbSource, setSelectedDbSource] = useState("internal"); const [externalConnections, setExternalConnections] = useState< Array<{ id: number; connection_name: string; db_type: string }> >([]); const [externalTableList, setExternalTableList] = useState([]); const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // REST API 연결 관련 상태 + const [restApiConnections, setRestApiConnections] = useState([]); + const [restApiEndpoint, setRestApiEndpoint] = useState(""); + const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -135,75 +142,132 @@ export default function FlowManagementPage() { loadConnections(); }, []); + // REST API 연결 목록 로드 + useEffect(() => { + const loadRestApiConnections = async () => { + try { + const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" }); + setRestApiConnections(connections); + } catch (error) { + console.error("Failed to load REST API connections:", error); + setRestApiConnections([]); + } + }; + loadRestApiConnections(); + }, []); + // 외부 DB 테이블 목록 로드 useEffect(() => { - if (selectedDbSource === "internal" || !selectedDbSource) { + // REST API인 경우 테이블 목록 로드 불필요 + if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) { setExternalTableList([]); return; } - const loadExternalTables = async () => { - try { - setLoadingExternalTables(true); - const token = localStorage.getItem("authToken"); + // 외부 DB인 경우 + if (selectedDbSource.startsWith("external_db_")) { + const connectionId = selectedDbSource.replace("external_db_", ""); + + const loadExternalTables = async () => { + try { + setLoadingExternalTables(true); + const token = localStorage.getItem("authToken"); - const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (response && response.ok) { - const data = await response.json(); - if (data.success && data.data) { - const tables = Array.isArray(data.data) ? data.data : []; - const tableNames = tables - .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => - typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, - ) - .filter(Boolean); - setExternalTableList(tableNames); + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } } else { setExternalTableList([]); } - } else { + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); setExternalTableList([]); + } finally { + setLoadingExternalTables(false); } - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - setExternalTableList([]); - } finally { - setLoadingExternalTables(false); - } - }; + }; - loadExternalTables(); + loadExternalTables(); + } }, [selectedDbSource]); // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - if (!formData.name || !formData.tableName) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName }); + // REST API인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_"); + + if (!formData.name || (!isRestApi && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); toast({ title: "입력 오류", - description: "플로우 이름과 테이블 이름은 필수입니다.", + description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + variant: "destructive", + }); + return; + } + + // REST API인 경우 엔드포인트 검증 + if (isRestApi && !restApiEndpoint) { + toast({ + title: "입력 오류", + description: "REST API 엔드포인트는 필수입니다.", variant: "destructive", }); return; } try { - // DB 소스 정보 추가 - const requestData = { + // 데이터 소스 타입 및 ID 파싱 + let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbConnectionId: number | undefined = undefined; + let restApiConnectionId: number | undefined = undefined; + + if (selectedDbSource === "internal") { + dbSourceType = "internal"; + } else if (selectedDbSource.startsWith("external_db_")) { + dbSourceType = "external"; + dbConnectionId = parseInt(selectedDbSource.replace("external_db_", "")); + } else if (selectedDbSource.startsWith("restapi_")) { + dbSourceType = "restapi"; + restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", "")); + } + + // 요청 데이터 구성 + const requestData: Record = { ...formData, - dbSourceType: selectedDbSource === "internal" ? "internal" : "external", - dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + dbSourceType, + dbConnectionId, }; + // REST API인 경우 추가 정보 + if (dbSourceType === "restapi") { + requestData.restApiConnectionId = restApiConnectionId; + requestData.restApiEndpoint = restApiEndpoint; + requestData.restApiJsonPath = restApiJsonPath || "data"; + // REST API는 가상 테이블명 사용 + requestData.tableName = `_restapi_${restApiConnectionId}`; + } + console.log("✅ Calling createFlowDefinition with:", requestData); - const response = await createFlowDefinition(requestData); + const response = await createFlowDefinition(requestData as Parameters[0]); if (response.success && response.data) { toast({ title: "생성 완료", @@ -212,6 +276,8 @@ export default function FlowManagementPage() { setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); + setRestApiEndpoint(""); + setRestApiJsonPath("data"); loadFlows(); } else { toast({ @@ -415,125 +481,186 @@ export default function FlowManagementPage() { /> - {/* DB 소스 선택 */} + {/* 데이터 소스 선택 */}
- +

- 플로우에서 사용할 데이터베이스를 선택합니다 + 플로우에서 사용할 데이터 소스를 선택합니다

- {/* 테이블 선택 */} -
- - - - - - - - - - 테이블을 찾을 수 없습니다. - - {selectedDbSource === "internal" - ? // 내부 DB 테이블 목록 - tableList.map((table) => ( - { - console.log("📝 Internal table selected:", { - tableName: table.tableName, - currentValue, - }); - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.displayName || table.tableName} - {table.description && ( - {table.description} - )} -
-
- )) - : // 외부 DB 테이블 목록 - externalTableList.map((tableName, index) => ( - { - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
{tableName}
-
- ))} -
-
-
-
-
-

- 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) -

-
+ {/* REST API인 경우 엔드포인트 설정 */} + {selectedDbSource.startsWith("restapi_") ? ( + <> +
+ + setRestApiEndpoint(e.target.value)} + placeholder="예: /api/data/list" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+
+ + setRestApiJsonPath(e.target.value)} + placeholder="예: data 또는 result.items" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + ) : ( + /* 테이블 선택 (내부 DB 또는 외부 DB) */ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {selectedDbSource === "internal" + ? // 내부 DB 테이블 목록 + tableList.map((table) => ( + { + console.log("📝 Internal table selected:", { + tableName: table.tableName, + currentValue, + }); + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.description && ( + {table.description} + )} +
+
+ )) + : // 외부 DB 테이블 목록 + externalTableList.map((tableName, index) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
{tableName}
+
+ ))} +
+
+
+
+
+

+ 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) +

+
+ )}
+ + {/* 데이터 소스 타입 선택 */}
- - - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {tables.map((table) => ( - { - setEditFormData({ ...editFormData, tableName: table.tableName }); - setTableComboboxOpen(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.tableLabel} - {table.tableName} -
-
- ))} -
-
-
-
-
+ +
+ + {/* 데이터베이스 선택 (database 타입인 경우) */} + {editFormData.dataSourceType === "database" && ( +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + setEditFormData({ ...editFormData, tableName: table.tableName }); + setTableComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
+
+ )} + + {/* REST API 선택 (restapi 타입인 경우) */} + {editFormData.dataSourceType === "restapi" && ( + <> +
+ + + + + + + + + + + 연결을 찾을 수 없습니다. + + + {editRestApiConnections.map((conn) => ( + { + setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null }); + setEditRestApiComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {conn.connection_name} + {conn.base_url} +
+
+ ))} +
+
+
+
+
+
+ +
+ + setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })} + placeholder="예: /api/data/list" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+ +
+ + setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })} + placeholder="예: data 또는 result.items" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + )} +