diff --git a/.gitignore b/.gitignore index 7c7f5694..ca80d8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -272,3 +272,5 @@ out/ .classpath .settings/ bin/ + +/src/generated/prisma diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 67a2e138..ee373fa8 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -47,7 +47,7 @@ "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", "jest": "^29.7.0", - "nodemon": "^3.0.2", + "nodemon": "^3.1.10", "prettier": "^3.1.0", "supertest": "^6.3.3", "ts-jest": "^29.1.1", diff --git a/backend-node/package.json b/backend-node/package.json index bcd934cf..258475e9 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -65,7 +65,7 @@ "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", "jest": "^29.7.0", - "nodemon": "^3.0.2", + "nodemon": "^3.1.10", "prettier": "^3.1.0", "supertest": "^6.3.3", "ts-jest": "^29.1.1", diff --git a/backend-node/prisma/migrations/migration_lock.toml b/backend-node/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/backend-node/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index 534aac5d..d008293c 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -16,6 +16,38 @@ const router = Router(); * GET /api/external-db-connections * 외부 DB 연결 목록 조회 */ +/** + * GET /api/external-db-connections/types/supported + * 지원하는 DB 타입 목록 조회 + */ +router.get( + "/types/supported", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { DB_TYPE_OPTIONS, DB_TYPE_DEFAULTS } = await import( + "../types/externalDbTypes" + ); + + return res.status(200).json({ + success: true, + data: { + types: DB_TYPE_OPTIONS, + defaults: DB_TYPE_DEFAULTS, + }, + message: "지원하는 DB 타입 목록을 조회했습니다.", + }); + } catch (error) { + console.error("DB 타입 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + router.get( "/", authenticateToken, @@ -53,6 +85,7 @@ router.get( } ); + /** * GET /api/external-db-connections/:id * 특정 외부 DB 연결 조회 @@ -208,42 +241,28 @@ router.delete( ); /** - * POST /api/external-db-connections/test - * 데이터베이스 연결 테스트 + * POST /api/external-db-connections/:id/test + * 데이터베이스 연결 테스트 (ID 기반) */ router.post( - "/test", + "/:id/test", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { - const testData = - req.body as import("../types/externalDbTypes").ConnectionTestRequest; - - // 필수 필드 검증 - const requiredFields = [ - "db_type", - "host", - "port", - "database_name", - "username", - "password", - ]; - const missingFields = requiredFields.filter( - (field) => !testData[field as keyof typeof testData] - ); - - if (missingFields.length > 0) { + const id = parseInt(req.params.id); + + if (isNaN(id)) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다.", + message: "유효하지 않은 연결 ID입니다.", error: { - code: "MISSING_FIELDS", - details: `다음 필드가 필요합니다: ${missingFields.join(", ")}`, + code: "INVALID_ID", + details: "연결 ID는 숫자여야 합니다.", }, }); } - const result = await ExternalDbConnectionService.testConnection(testData); + const result = await ExternalDbConnectionService.testConnectionById(id); return res.status(200).json({ success: result.success, @@ -265,35 +284,59 @@ router.post( ); /** - * GET /api/external-db-connections/types/supported - * 지원하는 DB 타입 목록 조회 + * POST /api/external-db-connections/:id/execute + * SQL 쿼리 실행 */ -router.get( - "/types/supported", +router.post( + "/:id/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { - const { DB_TYPE_OPTIONS, DB_TYPE_DEFAULTS } = await import( - "../types/externalDbTypes" - ); + const id = parseInt(req.params.id); + const { query } = req.body; - return res.status(200).json({ - success: true, - data: { - types: DB_TYPE_OPTIONS, - defaults: DB_TYPE_DEFAULTS, - }, - message: "지원하는 DB 타입 목록을 조회했습니다.", - }); + if (!query?.trim()) { + return res.status(400).json({ + success: false, + message: "쿼리가 입력되지 않았습니다." + }); + } + + const result = await ExternalDbConnectionService.executeQuery(id, query); + return res.json(result); } catch (error) { - console.error("DB 타입 목록 조회 오류:", error); + console.error("쿼리 실행 오류:", error); return res.status(500).json({ success: false, - message: "서버 내부 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: "쿼리 실행 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" }); } } ); +/** + * GET /api/external-db-connections/:id/tables + * 데이터베이스 테이블 목록 조회 + */ +router.get( + "/:id/tables", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + const result = await ExternalDbConnectionService.getTables(id); + return res.json(result); + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } +); + + export default router; diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 2ed1e3b7..8039ebb0 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -6,6 +6,7 @@ import { ExternalDbConnection, ExternalDbConnectionFilter, ApiResponse, + TableInfo, } from "../types/externalDbTypes"; import { PasswordEncryption } from "../utils/passwordEncryption"; @@ -315,15 +316,57 @@ export class ExternalDbConnectionService { } /** - * 데이터베이스 연결 테스트 + * 데이터베이스 연결 테스트 (ID 기반) */ - static async testConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest + static async testConnectionById( + id: number ): Promise { const startTime = Date.now(); try { - switch (testData.db_type.toLowerCase()) { + // 저장된 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다.", + error: { + code: "CONNECTION_NOT_FOUND", + details: `ID ${id}에 해당하는 연결 정보가 없습니다.` + } + }; + } + + // 비밀번호 복호화 + const decryptedPassword = await this.getDecryptedPassword(id); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다.", + error: { + code: "DECRYPTION_FAILED", + details: "저장된 비밀번호를 복호화할 수 없습니다." + } + }; + } + + // 테스트용 데이터 준비 + const testData = { + db_type: connection.db_type, + host: connection.host, + port: connection.port, + database_name: connection.database_name, + username: connection.username, + password: decryptedPassword, + connection_timeout: connection.connection_timeout || undefined, + ssl_enabled: connection.ssl_enabled || undefined + }; + + // 실제 연결 테스트 수행 + switch (connection.db_type.toLowerCase()) { case "postgresql": return await this.testPostgreSQLConnection(testData, startTime); case "mysql": @@ -360,7 +403,7 @@ export class ExternalDbConnectionService { * PostgreSQL 연결 테스트 */ private static async testPostgreSQLConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest, + testData: any, startTime: number ): Promise { const { Client } = await import("pg"); @@ -416,7 +459,7 @@ export class ExternalDbConnectionService { * MySQL 연결 테스트 (모의 구현) */ private static async testMySQLConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest, + testData: any, startTime: number ): Promise { // MySQL 라이브러리가 없으므로 모의 구현 @@ -434,7 +477,7 @@ export class ExternalDbConnectionService { * Oracle 연결 테스트 (모의 구현) */ private static async testOracleConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest, + testData: any, startTime: number ): Promise { return { @@ -451,7 +494,7 @@ export class ExternalDbConnectionService { * SQL Server 연결 테스트 (모의 구현) */ private static async testMSSQLConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest, + testData: any, startTime: number ): Promise { return { @@ -468,7 +511,7 @@ export class ExternalDbConnectionService { * SQLite 연결 테스트 (모의 구현) */ private static async testSQLiteConnection( - testData: import("../types/externalDbTypes").ConnectionTestRequest, + testData: any, startTime: number ): Promise { return { @@ -546,4 +589,237 @@ export class ExternalDbConnectionService { return null; } } + + /** + * SQL 쿼리 실행 + */ + static async executeQuery( + id: number, + query: string + ): Promise> { + try { + // 연결 정보 조회 + console.log("연결 정보 조회 시작:", { id }); + const connection = await prisma.external_db_connections.findUnique({ + where: { id } + }); + console.log("조회된 연결 정보:", connection); + + if (!connection) { + console.log("연결 정보를 찾을 수 없음:", { id }); + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = await this.getDecryptedPassword(id); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + // DB 타입에 따른 쿼리 실행 + switch (connection.db_type.toLowerCase()) { + case "postgresql": + return await this.executePostgreSQLQuery(connection, decryptedPassword, query); + case "mysql": + return { + success: false, + message: "MySQL 쿼리 실행은 현재 지원하지 않습니다." + }; + case "oracle": + return { + success: false, + message: "Oracle 쿼리 실행은 현재 지원하지 않습니다." + }; + case "mssql": + return { + success: false, + message: "SQL Server 쿼리 실행은 현재 지원하지 않습니다." + }; + case "sqlite": + return { + success: false, + message: "SQLite 쿼리 실행은 현재 지원하지 않습니다." + }; + default: + return { + success: false, + message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` + }; + } + } catch (error) { + console.error("쿼리 실행 오류:", error); + return { + success: false, + message: "쿼리 실행 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * PostgreSQL 쿼리 실행 + */ + private static async executePostgreSQLQuery( + connection: any, + password: string, + query: string + ): Promise> { + const { Client } = await import("pg"); + const client = new Client({ + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: password, + connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false, + }); + + try { + await client.connect(); + console.log("DB 연결 정보:", { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username + }); + console.log("쿼리 실행:", query); + const result = await client.query(query); + console.log("쿼리 결과:", result.rows); + await client.end(); + + return { + success: true, + message: "쿼리가 성공적으로 실행되었습니다.", + data: result.rows + }; + } catch (error) { + try { + await client.end(); + } catch (endError) { + // 연결 종료 오류는 무시 + } + + return { + success: false, + message: "쿼리 실행 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * 데이터베이스 테이블 목록 조회 + */ + static async getTables(id: number): Promise> { + try { + // 연결 정보 조회 + const connection = await prisma.external_db_connections.findUnique({ + where: { id } + }); + + if (!connection) { + return { + success: false, + message: "연결 정보를 찾을 수 없습니다." + }; + } + + // 비밀번호 복호화 + const decryptedPassword = await this.getDecryptedPassword(id); + if (!decryptedPassword) { + return { + success: false, + message: "비밀번호 복호화에 실패했습니다." + }; + } + + switch (connection.db_type.toLowerCase()) { + case "postgresql": + return await this.getPostgreSQLTables(connection, decryptedPassword); + default: + return { + success: false, + message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` + }; + } + } catch (error) { + console.error("테이블 목록 조회 오류:", error); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + + /** + * PostgreSQL 테이블 목록 조회 + */ + private static async getPostgreSQLTables( + connection: any, + password: string + ): Promise> { + const { Client } = await import("pg"); + const client = new Client({ + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: password, + connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }); + + try { + await client.connect(); + // 테이블 목록과 각 테이블의 컬럼 정보 조회 + const result = await client.query(` + SELECT + t.table_name, + array_agg( + json_build_object( + 'column_name', c.column_name, + 'data_type', c.data_type, + 'is_nullable', c.is_nullable, + 'column_default', c.column_default + ) + ) as columns, + obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description + FROM information_schema.tables t + LEFT JOIN information_schema.columns c + ON c.table_name = t.table_name + AND c.table_schema = t.table_schema + WHERE t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + GROUP BY t.table_name + ORDER BY t.table_name + `); + await client.end(); + + return { + success: true, + data: result.rows.map(row => ({ + table_name: row.table_name, + columns: row.columns || [], + description: row.table_description + })) as TableInfo[], + message: "테이블 목록을 조회했습니다." + }; + } catch (error) { + await client.end(); + return { + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류" + }; + } + } + } diff --git a/backend-node/src/types/externalDbTypes.ts b/backend-node/src/types/externalDbTypes.ts index b5d65959..4bed52a9 100644 --- a/backend-node/src/types/externalDbTypes.ts +++ b/backend-node/src/types/externalDbTypes.ts @@ -4,17 +4,17 @@ export interface ExternalDbConnection { id?: number; connection_name: string; - description?: string; + description?: string | null; db_type: "mysql" | "postgresql" | "oracle" | "mssql" | "sqlite"; host: string; port: number; database_name: string; username: string; password: string; - connection_timeout?: number; - query_timeout?: number; - max_connections?: number; - ssl_enabled?: string; + connection_timeout?: number | null; + query_timeout?: number | null; + max_connections?: number | null; + ssl_enabled?: string | null; ssl_cert_path?: string; connection_options?: Record; company_code: string; @@ -32,6 +32,19 @@ export interface ExternalDbConnectionFilter { search?: string; } +export interface TableColumn { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; +} + +export interface TableInfo { + table_name: string; + columns: TableColumn[]; + description: string | null; +} + export interface ApiResponse { success: boolean; data?: T; diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 3862a74f..0cb5cc57 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,7 +12,7 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + - DATABASE_URL=postgresql://postgres:postgres@postgres-erp:5432/ilshin - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h - CORS_ORIGIN=http://localhost:9771 diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx index a4021493..85e7911f 100644 --- a/frontend/app/(main)/admin/external-connections/page.tsx +++ b/frontend/app/(main)/admin/external-connections/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Plus, Search, Pencil, Trash2, Database } from "lucide-react"; +import { Plus, Search, Pencil, Trash2, Database, Terminal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; @@ -26,6 +26,7 @@ import { ConnectionTestRequest, } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal"; +import { SqlQueryModal } from "@/components/admin/SqlQueryModal"; // DB 타입 매핑 const DB_TYPE_LABELS: Record = { @@ -59,6 +60,8 @@ export default function ExternalConnectionsPage() { const [connectionToDelete, setConnectionToDelete] = useState(null); const [testingConnections, setTestingConnections] = useState>(new Set()); const [testResults, setTestResults] = useState>(new Map()); + const [sqlModalOpen, setSqlModalOpen] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); // 데이터 로딩 const loadConnections = async () => { @@ -170,18 +173,7 @@ export default function ExternalConnectionsPage() { setTestingConnections((prev) => new Set(prev).add(connection.id!)); try { - const testData: ConnectionTestRequest = { - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - username: connection.username, - password: connection.password, - connection_timeout: connection.connection_timeout, - ssl_enabled: connection.ssl_enabled, - }; - - const result = await ExternalDbConnectionAPI.testConnection(testData); + const result = await ExternalDbConnectionAPI.testConnection(connection.id); setTestResults((prev) => new Map(prev).set(connection.id!, result.success)); @@ -193,7 +185,7 @@ export default function ExternalConnectionsPage() { } else { toast({ title: "연결 실패", - description: `${connection.connection_name} 연결에 실패했습니다.`, + description: result.message || `${connection.connection_name} 연결에 실패했습니다.`, variant: "destructive", }); } @@ -369,6 +361,19 @@ export default function ExternalConnectionsPage() {
+
); } diff --git a/frontend/components/admin/SqlQueryModal.tsx b/frontend/components/admin/SqlQueryModal.tsx new file mode 100644 index 00000000..cdc85786 --- /dev/null +++ b/frontend/components/admin/SqlQueryModal.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect, ChangeEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; + +interface TableColumn { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; +} + +interface TableInfo { + table_name: string; + columns: TableColumn[]; + description: string | null; +} + +interface QueryResult { + [key: string]: string | number | boolean | null | undefined; +} + +interface TableColumn { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; +} + +interface TableInfo { + table_name: string; + columns: TableColumn[]; + description: string | null; +} + +interface SqlQueryModalProps { + isOpen: boolean; + onClose: () => void; + connectionId: number; + connectionName: string; +} + +export const SqlQueryModal: React.FC = ({ isOpen, onClose, connectionId, connectionName }) => { + const { toast } = useToast(); + const [query, setQuery] = useState("SELECT * FROM "); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(""); + const [loadingTables, setLoadingTables] = useState(false); + + // 테이블 목록 로딩 + useEffect(() => { + console.log("SqlQueryModal - connectionId:", connectionId); + const loadTables = async () => { + setLoadingTables(true); + try { + const result = await ExternalDbConnectionAPI.getTables(connectionId); + if (result.success && result.data) { + setTables(result.data as unknown as TableInfo[]); + } + } catch (error) { + console.error("테이블 목록 로딩 오류:", error); + toast({ + title: "오류", + description: "테이블 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + const handleExecute = async () => { + console.log("실행 버튼 클릭"); + if (!query.trim()) { + toast({ + title: "오류", + description: "실행할 쿼리를 입력해주세요.", + variant: "destructive", + }); + return; + } + + console.log("쿼리 실행 시작:", { connectionId, query }); + setLoading(true); + try { + const result = await ExternalDbConnectionAPI.executeQuery(connectionId, query); + console.log("쿼리 실행 결과:", result); + if (result.success && result.data) { + setResults(result.data); + toast({ + title: "성공", + description: "쿼리가 성공적으로 실행되었습니다.", + }); + } else { + toast({ + title: "오류", + description: result.message || "쿼리 실행 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("쿼리 실행 오류:", error); + toast({ + title: "오류", + description: error instanceof Error ? error.message : "쿼리 실행 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + return ( + + + + {connectionName} - SQL 쿼리 실행 + + 데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다. + + + + {/* 쿼리 입력 영역 */} +
+
+ {/* 테이블 선택 */} +
+
+ +
+ + {/* 테이블 정보 */} +
+

사용 가능한 테이블

+
+ {tables.map((table) => ( +
+
+

{table.table_name}

+ +
+ {table.description && ( +

{table.description}

+ )} +
+ {table.columns.map((column: TableColumn) => ( +
+ {column.column_name} + ({column.data_type}) +
+ ))} +
+
+ ))} +
+
+
+ + {/* 쿼리 입력 */} +
+