diff --git a/backend-node/check-db.js b/backend-node/check-db.js deleted file mode 100644 index 941dc30d..00000000 --- a/backend-node/check-db.js +++ /dev/null @@ -1,59 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); -const prisma = new PrismaClient(); - -async function checkDatabase() { - try { - console.log("=== 데이터베이스 연결 확인 ==="); - const userCount = await prisma.user_info.count(); - console.log(`총 사용자 수: ${userCount}`); - - if (userCount > 0) { - const users = await prisma.user_info.findMany({ - take: 10, - select: { - user_id: true, - user_name: true, - dept_name: true, - company_code: true, - }, - }); - console.log("\n=== 사용자 목록 (대소문자 확인) ==="); - users.forEach((user, index) => { - console.log( - `${index + 1}. "${user.user_id}" - ${user.user_name || "이름 없음"} (${user.dept_name || "부서 없음"})` - ); - }); - - console.log("\n=== 특정 사용자 검색 테스트 ==="); - const userLower = await prisma.user_info.findUnique({ - where: { user_id: "arvin" }, - }); - console.log('소문자 "arvin" 검색 결과:', userLower ? "찾음" : "없음"); - const userUpper = await prisma.user_info.findUnique({ - where: { user_id: "ARVIN" }, - }); - console.log('대문자 "ARVIN" 검색 결과:', userUpper ? "찾음" : "없음"); - - const rawUsers = await prisma.$queryRaw` - SELECT user_id, user_name, dept_name - FROM user_info - WHERE user_id IN ('arvin', 'ARVIN', 'Arvin') - LIMIT 5 - `; - console.log("\n=== 원본 데이터 확인 ==="); - rawUsers.forEach((user) => { - console.log(`"${user.user_id}" - ${user.user_name || "이름 없음"}`); - }); - } - - // 로그인 로그 확인 - const logCount = await prisma.login_access_log.count(); - console.log(`\n총 로그인 로그 수: ${logCount}`); - } catch (error) { - console.error("오류 발생:", error); - } finally { - await prisma.$disconnect(); - } -} - -checkDatabase(); diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index edc62708..11546174 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -20,6 +20,54 @@ model dynamic_form_data { company_code String @db.VarChar(20) } +model external_call_configs { + id Int @id @default(autoincrement()) + config_name String @db.VarChar(100) + call_type String @db.VarChar(20) + api_type String? @db.VarChar(20) + config_data Json + description String? @db.Text + company_code String @default("*") @db.VarChar(20) + is_active String @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([company_code]) + @@index([call_type, api_type]) + @@index([is_active]) +} + +model external_db_connections { + id Int @id @default(autoincrement()) + connection_name String @db.VarChar(100) + description String? @db.Text + db_type String @db.VarChar(20) + host String @db.VarChar(255) + port Int + database_name String @db.VarChar(100) + username String @db.VarChar(100) + password String @db.Text + connection_timeout Int? @default(30) + query_timeout Int? @default(60) + max_connections Int? @default(10) + ssl_enabled String @default("N") @db.Char(1) + ssl_cert_path String? @db.VarChar(500) + connection_options Json? + company_code String @default("*") @db.VarChar(20) + is_active String @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + @@index([company_code]) + @@index([is_active]) + @@index([db_type]) + @@index([connection_name]) +} + model admin_supply_mng { objid Decimal @id @default(0) @db.Decimal supply_code String? @default("NULL::character varying") @db.VarChar(100) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index dba6dc4d..494ca474 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -28,6 +28,9 @@ import templateStandardRoutes from "./routes/templateStandardRoutes"; import componentStandardRoutes from "./routes/componentStandardRoutes"; import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; +import externalCallRoutes from "./routes/externalCallRoutes"; +import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; +import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -119,6 +122,9 @@ app.use("/api/admin/component-standards", componentStandardRoutes); app.use("/api/layouts", layoutRoutes); app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); +app.use("/api/external-calls", externalCallRoutes); +app.use("/api/external-call-configs", externalCallConfigRoutes); +app.use("/api/external-db-connections", externalDbConnectionRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/routes/externalCallConfigRoutes.ts b/backend-node/src/routes/externalCallConfigRoutes.ts new file mode 100644 index 00000000..394756ba --- /dev/null +++ b/backend-node/src/routes/externalCallConfigRoutes.ts @@ -0,0 +1,252 @@ +import express, { Request, Response } from "express"; +import externalCallConfigService, { + ExternalCallConfigFilter, +} from "../services/externalCallConfigService"; +import { logger } from "../utils/logger"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 외부 호출 설정 목록 조회 + * GET /api/external-call-configs + */ +router.get("/", async (req: Request, res: Response) => { + try { + const filter: ExternalCallConfigFilter = { + company_code: req.query.company_code as string, + call_type: req.query.call_type as string, + api_type: req.query.api_type as string, + is_active: (req.query.is_active as string) || "Y", + search: req.query.search as string, + }; + + const configs = await externalCallConfigService.getConfigs(filter); + + return res.json({ + success: true, + data: configs, + message: `외부 호출 설정 ${configs.length}개 조회 완료`, + }); + } catch (error) { + logger.error("외부 호출 설정 목록 조회 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error + ? error.message + : "외부 호출 설정 목록 조회 실패", + errorCode: "EXTERNAL_CALL_CONFIG_LIST_ERROR", + }); + } +}); + +/** + * 외부 호출 설정 단일 조회 + * GET /api/external-call-configs/:id + */ +router.get("/:id", async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 설정 ID입니다.", + errorCode: "INVALID_CONFIG_ID", + }); + } + + const config = await externalCallConfigService.getConfigById(id); + if (!config) { + return res.status(404).json({ + success: false, + message: "외부 호출 설정을 찾을 수 없습니다.", + errorCode: "CONFIG_NOT_FOUND", + }); + } + + return res.json({ + success: true, + data: config, + message: "외부 호출 설정 조회 완료", + }); + } catch (error) { + logger.error("외부 호출 설정 조회 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error ? error.message : "외부 호출 설정 조회 실패", + errorCode: "EXTERNAL_CALL_CONFIG_GET_ERROR", + }); + } +}); + +/** + * 외부 호출 설정 생성 + * POST /api/external-call-configs + */ +router.post("/", async (req: Request, res: Response) => { + try { + const { + config_name, + call_type, + api_type, + config_data, + description, + company_code, + } = req.body; + + // 필수 필드 검증 + if (!config_name || !call_type || !config_data) { + return res.status(400).json({ + success: false, + message: + "필수 필드가 누락되었습니다. (config_name, call_type, config_data)", + errorCode: "MISSING_REQUIRED_FIELDS", + }); + } + + // 사용자 정보 가져오기 + const userInfo = (req as any).user; + const userId = userInfo?.userId || "SYSTEM"; + + const newConfig = await externalCallConfigService.createConfig({ + config_name, + call_type, + api_type, + config_data, + description, + company_code: company_code || "*", + created_by: userId, + updated_by: userId, + }); + + return res.status(201).json({ + success: true, + data: newConfig, + message: "외부 호출 설정이 성공적으로 생성되었습니다.", + }); + } catch (error) { + logger.error("외부 호출 설정 생성 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error ? error.message : "외부 호출 설정 생성 실패", + errorCode: "EXTERNAL_CALL_CONFIG_CREATE_ERROR", + }); + } +}); + +/** + * 외부 호출 설정 수정 + * PUT /api/external-call-configs/:id + */ +router.put("/:id", async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 설정 ID입니다.", + errorCode: "INVALID_CONFIG_ID", + }); + } + + // 사용자 정보 가져오기 + const userInfo = (req as any).user; + const userId = userInfo?.userId || "SYSTEM"; + + const updatedConfig = await externalCallConfigService.updateConfig(id, { + ...req.body, + updated_by: userId, + }); + + return res.json({ + success: true, + data: updatedConfig, + message: "외부 호출 설정이 성공적으로 수정되었습니다.", + }); + } catch (error) { + logger.error("외부 호출 설정 수정 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error ? error.message : "외부 호출 설정 수정 실패", + errorCode: "EXTERNAL_CALL_CONFIG_UPDATE_ERROR", + }); + } +}); + +/** + * 외부 호출 설정 삭제 (논리 삭제) + * DELETE /api/external-call-configs/:id + */ +router.delete("/:id", async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 설정 ID입니다.", + errorCode: "INVALID_CONFIG_ID", + }); + } + + // 사용자 정보 가져오기 + const userInfo = (req as any).user; + const userId = userInfo?.userId || "SYSTEM"; + + await externalCallConfigService.deleteConfig(id, userId); + + return res.json({ + success: true, + message: "외부 호출 설정이 성공적으로 삭제되었습니다.", + }); + } catch (error) { + logger.error("외부 호출 설정 삭제 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error ? error.message : "외부 호출 설정 삭제 실패", + errorCode: "EXTERNAL_CALL_CONFIG_DELETE_ERROR", + }); + } +}); + +/** + * 외부 호출 설정 테스트 + * POST /api/external-call-configs/:id/test + */ +router.post("/:id/test", async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 설정 ID입니다.", + errorCode: "INVALID_CONFIG_ID", + }); + } + + const testResult = await externalCallConfigService.testConfig(id); + + return res.json({ + success: testResult.success, + message: testResult.message, + data: testResult, + }); + } catch (error) { + logger.error("외부 호출 설정 테스트 API 오류:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error ? error.message : "외부 호출 설정 테스트 실패", + errorCode: "EXTERNAL_CALL_CONFIG_TEST_ERROR", + }); + } +}); + +export default router; diff --git a/backend-node/src/routes/externalCallRoutes.ts b/backend-node/src/routes/externalCallRoutes.ts new file mode 100644 index 00000000..2fd3f0f4 --- /dev/null +++ b/backend-node/src/routes/externalCallRoutes.ts @@ -0,0 +1,192 @@ +import { Router, Request, Response } from "express"; +import { ExternalCallService } from "../services/externalCallService"; +import { + ExternalCallRequest, + SupportedExternalCallSettings, +} from "../types/externalCallTypes"; + +const router = Router(); +const externalCallService = new ExternalCallService(); + +/** + * 외부 호출 테스트 + * POST /api/external-calls/test + */ +router.post("/test", async (req: Request, res: Response) => { + try { + const { settings, templateData } = req.body; + + if (!settings) { + return res.status(400).json({ + success: false, + error: "외부 호출 설정이 필요합니다.", + }); + } + + // 설정 검증 + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + if (!validation.valid) { + return res.status(400).json({ + success: false, + error: "설정 검증 실패", + details: validation.errors, + }); + } + + // 테스트 요청 생성 + const testRequest: ExternalCallRequest = { + diagramId: 0, // 테스트용 + relationshipId: "test", + settings: settings as SupportedExternalCallSettings, + templateData: templateData || { + recordCount: 5, + tableName: "test_table", + timestamp: new Date().toISOString(), + }, + }; + + // 외부 호출 실행 + const result = await externalCallService.executeExternalCall(testRequest); + + return res.json({ + success: true, + result, + }); + } catch (error) { + console.error("외부 호출 테스트 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }); + } +}); + +/** + * 외부 호출 실행 + * POST /api/external-calls/execute + */ +router.post("/execute", async (req: Request, res: Response) => { + try { + const { diagramId, relationshipId, settings, templateData } = req.body; + + if (!diagramId || !relationshipId || !settings) { + return res.status(400).json({ + success: false, + error: + "필수 파라미터가 누락되었습니다. (diagramId, relationshipId, settings)", + }); + } + + // 설정 검증 + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + if (!validation.valid) { + return res.status(400).json({ + success: false, + error: "설정 검증 실패", + details: validation.errors, + }); + } + + // 외부 호출 요청 생성 + const callRequest: ExternalCallRequest = { + diagramId: parseInt(diagramId), + relationshipId, + settings: settings as SupportedExternalCallSettings, + templateData, + }; + + // 외부 호출 실행 + const result = await externalCallService.executeExternalCall(callRequest); + + // TODO: 호출 결과를 데이터베이스에 로그로 저장 (향후 구현) + + return res.json({ + success: true, + result, + }); + } catch (error) { + console.error("외부 호출 실행 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }); + } +}); + +/** + * 지원되는 외부 호출 타입 목록 조회 + * GET /api/external-calls/types + */ +router.get("/types", (req: Request, res: Response) => { + res.json({ + success: true, + supportedTypes: { + "rest-api": { + name: "REST API 호출", + subtypes: { + slack: "슬랙 웹훅", + "kakao-talk": "카카오톡 알림", + discord: "디스코드 웹훅", + generic: "일반 REST API", + }, + }, + email: { + name: "이메일 전송", + status: "구현 예정", + }, + ftp: { + name: "FTP 업로드", + status: "구현 예정", + }, + queue: { + name: "메시지 큐", + status: "구현 예정", + }, + }, + }); +}); + +/** + * 외부 호출 설정 검증 + * POST /api/external-calls/validate + */ +router.post("/validate", (req: Request, res: Response) => { + try { + const { settings } = req.body; + + if (!settings) { + return res.status(400).json({ + success: false, + error: "검증할 설정이 필요합니다.", + }); + } + + const validation = externalCallService.validateSettings( + settings as SupportedExternalCallSettings + ); + + return res.json({ + success: true, + validation, + }); + } catch (error) { + console.error("설정 검증 실패:", error); + return res.status(500).json({ + success: false, + error: + error instanceof Error ? error.message : "검증 중 오류가 발생했습니다.", + }); + } +}); + +export default router; diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts new file mode 100644 index 00000000..9c6e86e1 --- /dev/null +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -0,0 +1,242 @@ +// 외부 DB 연결 API 라우트 +// 작성일: 2024-12-17 + +import { Router, Response } from "express"; +import { ExternalDbConnectionService } from "../services/externalDbConnectionService"; +import { + ExternalDbConnection, + ExternalDbConnectionFilter, +} from "../types/externalDbTypes"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; + +const router = Router(); + +/** + * GET /api/external-db-connections + * 외부 DB 연결 목록 조회 + */ +router.get( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const filter: ExternalDbConnectionFilter = { + db_type: req.query.db_type as string, + is_active: req.query.is_active as string, + company_code: req.query.company_code as string, + search: req.query.search as string, + }; + + // 빈 값 제거 + Object.keys(filter).forEach((key) => { + if (!filter[key as keyof ExternalDbConnectionFilter]) { + delete filter[key as keyof ExternalDbConnectionFilter]; + } + }); + + const result = await ExternalDbConnectionService.getConnections(filter); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * GET /api/external-db-connections/:id + * 특정 외부 DB 연결 조회 + */ +router.get( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = await ExternalDbConnectionService.getConnectionById(id); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + console.error("외부 DB 연결 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * POST /api/external-db-connections + * 새 외부 DB 연결 생성 + */ +router.post( + "/", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const connectionData: ExternalDbConnection = req.body; + + // 사용자 정보 추가 + if (req.user) { + connectionData.created_by = req.user.userId; + connectionData.updated_by = req.user.userId; + } + + const result = + await ExternalDbConnectionService.createConnection(connectionData); + + if (result.success) { + return res.status(201).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 생성 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * PUT /api/external-db-connections/:id + * 외부 DB 연결 수정 + */ +router.put( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const updateData: Partial = req.body; + + // 사용자 정보 추가 + if (req.user) { + updateData.updated_by = req.user.userId; + } + + const result = await ExternalDbConnectionService.updateConnection( + id, + updateData + ); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(400).json(result); + } + } catch (error) { + console.error("외부 DB 연결 수정 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * DELETE /api/external-db-connections/:id + * 외부 DB 연결 삭제 (논리 삭제) + */ +router.delete( + "/:id", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const result = await ExternalDbConnectionService.deleteConnection(id); + + if (result.success) { + return res.status(200).json(result); + } else { + return res.status(404).json(result); + } + } catch (error) { + console.error("외부 DB 연결 삭제 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + +/** + * 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 : "알 수 없는 오류", + }); + } + } +); + +export default router; diff --git a/backend-node/src/services/externalCallConfigService.ts b/backend-node/src/services/externalCallConfigService.ts new file mode 100644 index 00000000..ad332281 --- /dev/null +++ b/backend-node/src/services/externalCallConfigService.ts @@ -0,0 +1,313 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +// 외부 호출 설정 타입 정의 +export interface ExternalCallConfig { + id?: number; + config_name: string; + call_type: string; + api_type?: string; + config_data: any; + description?: string; + company_code?: string; + is_active?: string; + created_by?: string; + updated_by?: string; +} + +export interface ExternalCallConfigFilter { + company_code?: string; + call_type?: string; + api_type?: string; + is_active?: string; + search?: string; +} + +export class ExternalCallConfigService { + /** + * 외부 호출 설정 목록 조회 + */ + async getConfigs( + filter: ExternalCallConfigFilter = {} + ): Promise { + try { + logger.info("=== 외부 호출 설정 목록 조회 시작 ==="); + logger.info(`필터 조건:`, filter); + + const where: any = {}; + + // 회사 코드 필터 + if (filter.company_code) { + where.company_code = filter.company_code; + } + + // 호출 타입 필터 + if (filter.call_type) { + where.call_type = filter.call_type; + } + + // API 타입 필터 + if (filter.api_type) { + where.api_type = filter.api_type; + } + + // 활성화 상태 필터 + if (filter.is_active) { + where.is_active = filter.is_active; + } + + // 검색어 필터 (설정 이름 또는 설명) + if (filter.search) { + where.OR = [ + { config_name: { contains: filter.search, mode: "insensitive" } }, + { description: { contains: filter.search, mode: "insensitive" } }, + ]; + } + + const configs = await prisma.external_call_configs.findMany({ + where, + orderBy: [{ is_active: "desc" }, { created_date: "desc" }], + }); + + logger.info(`외부 호출 설정 조회 결과: ${configs.length}개`); + return configs as ExternalCallConfig[]; + } catch (error) { + logger.error("외부 호출 설정 목록 조회 실패:", error); + throw error; + } + } + + /** + * 외부 호출 설정 단일 조회 + */ + async getConfigById(id: number): Promise { + try { + logger.info(`=== 외부 호출 설정 조회: ID ${id} ===`); + + const config = await prisma.external_call_configs.findUnique({ + where: { id }, + }); + + if (config) { + logger.info(`외부 호출 설정 조회 성공: ${config.config_name}`); + } else { + logger.warn(`외부 호출 설정을 찾을 수 없음: ID ${id}`); + } + + return config as ExternalCallConfig | null; + } catch (error) { + logger.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error); + throw error; + } + } + + /** + * 외부 호출 설정 생성 + */ + async createConfig(data: ExternalCallConfig): Promise { + try { + logger.info("=== 외부 호출 설정 생성 시작 ==="); + logger.info(`생성할 설정:`, { + config_name: data.config_name, + call_type: data.call_type, + api_type: data.api_type, + company_code: data.company_code || "*", + }); + + // 중복 이름 검사 + const existingConfig = await prisma.external_call_configs.findFirst({ + where: { + config_name: data.config_name, + company_code: data.company_code || "*", + is_active: "Y", + }, + }); + + if (existingConfig) { + throw new Error( + `동일한 이름의 외부 호출 설정이 이미 존재합니다: ${data.config_name}` + ); + } + + const newConfig = await prisma.external_call_configs.create({ + data: { + config_name: data.config_name, + call_type: data.call_type, + api_type: data.api_type, + config_data: data.config_data, + description: data.description, + company_code: data.company_code || "*", + is_active: data.is_active || "Y", + created_by: data.created_by, + updated_by: data.updated_by, + }, + }); + + logger.info( + `외부 호출 설정 생성 완료: ${newConfig.config_name} (ID: ${newConfig.id})` + ); + return newConfig as ExternalCallConfig; + } catch (error) { + logger.error("외부 호출 설정 생성 실패:", error); + throw error; + } + } + + /** + * 외부 호출 설정 수정 + */ + async updateConfig( + id: number, + data: Partial + ): Promise { + try { + logger.info(`=== 외부 호출 설정 수정 시작: ID ${id} ===`); + + // 기존 설정 존재 확인 + const existingConfig = await this.getConfigById(id); + if (!existingConfig) { + throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`); + } + + // 이름 중복 검사 (다른 설정과 중복되는지) + if (data.config_name && data.config_name !== existingConfig.config_name) { + const duplicateConfig = await prisma.external_call_configs.findFirst({ + where: { + config_name: data.config_name, + company_code: data.company_code || existingConfig.company_code, + is_active: "Y", + id: { not: id }, + }, + }); + + if (duplicateConfig) { + throw new Error( + `동일한 이름의 외부 호출 설정이 이미 존재합니다: ${data.config_name}` + ); + } + } + + const updatedConfig = await prisma.external_call_configs.update({ + where: { id }, + data: { + ...(data.config_name && { config_name: data.config_name }), + ...(data.call_type && { call_type: data.call_type }), + ...(data.api_type !== undefined && { api_type: data.api_type }), + ...(data.config_data && { config_data: data.config_data }), + ...(data.description !== undefined && { + description: data.description, + }), + ...(data.company_code && { company_code: data.company_code }), + ...(data.is_active && { is_active: data.is_active }), + ...(data.updated_by && { updated_by: data.updated_by }), + updated_date: new Date(), + }, + }); + + logger.info( + `외부 호출 설정 수정 완료: ${updatedConfig.config_name} (ID: ${id})` + ); + return updatedConfig as ExternalCallConfig; + } catch (error) { + logger.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error); + throw error; + } + } + + /** + * 외부 호출 설정 삭제 (논리 삭제) + */ + async deleteConfig(id: number, deletedBy?: string): Promise { + try { + logger.info(`=== 외부 호출 설정 삭제 시작: ID ${id} ===`); + + // 기존 설정 존재 확인 + const existingConfig = await this.getConfigById(id); + if (!existingConfig) { + throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`); + } + + // 논리 삭제 (is_active = 'N') + await prisma.external_call_configs.update({ + where: { id }, + data: { + is_active: "N", + updated_by: deletedBy, + updated_date: new Date(), + }, + }); + + logger.info( + `외부 호출 설정 삭제 완료: ${existingConfig.config_name} (ID: ${id})` + ); + } catch (error) { + logger.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error); + throw error; + } + } + + /** + * 외부 호출 설정 테스트 + */ + async testConfig(id: number): Promise<{ success: boolean; message: string }> { + try { + logger.info(`=== 외부 호출 설정 테스트 시작: ID ${id} ===`); + + const config = await this.getConfigById(id); + if (!config) { + throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`); + } + + // TODO: ExternalCallService를 사용하여 실제 테스트 호출 + // 현재는 기본적인 검증만 수행 + const configData = config.config_data as any; + + let isValid = true; + let validationMessage = ""; + + switch (config.api_type) { + case "discord": + if (!configData.webhookUrl) { + isValid = false; + validationMessage = "Discord 웹훅 URL이 필요합니다."; + } + break; + case "slack": + if (!configData.webhookUrl) { + isValid = false; + validationMessage = "Slack 웹훅 URL이 필요합니다."; + } + break; + case "kakao-talk": + if (!configData.accessToken) { + isValid = false; + validationMessage = "카카오톡 액세스 토큰이 필요합니다."; + } + break; + default: + if (config.call_type === "rest-api" && !configData.url) { + isValid = false; + validationMessage = "API URL이 필요합니다."; + } + } + + if (!isValid) { + logger.warn(`외부 호출 설정 테스트 실패: ${validationMessage}`); + return { success: false, message: validationMessage }; + } + + logger.info(`외부 호출 설정 테스트 성공: ${config.config_name}`); + return { success: true, message: "설정이 유효합니다." }; + } catch (error) { + logger.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error); + return { + success: false, + message: error instanceof Error ? error.message : "테스트 실패", + }; + } + } +} + +export default new ExternalCallConfigService(); diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts new file mode 100644 index 00000000..703c1b2c --- /dev/null +++ b/backend-node/src/services/externalCallService.ts @@ -0,0 +1,324 @@ +import { + ExternalCallConfig, + ExternalCallResult, + ExternalCallRequest, + SlackSettings, + KakaoTalkSettings, + DiscordSettings, + GenericApiSettings, + EmailSettings, + SupportedExternalCallSettings, + TemplateOptions, +} from "../types/externalCallTypes"; + +/** + * 외부 호출 서비스 + * REST API, 웹훅, 이메일 등 다양한 외부 시스템 호출을 처리 + */ +export class ExternalCallService { + private readonly DEFAULT_TIMEOUT = 30000; // 30초 + private readonly DEFAULT_RETRY_COUNT = 3; + private readonly DEFAULT_RETRY_DELAY = 1000; // 1초 + + /** + * 외부 호출 실행 + */ + async executeExternalCall( + request: ExternalCallRequest + ): Promise { + const startTime = Date.now(); + + try { + let result: ExternalCallResult; + + switch (request.settings.callType) { + case "rest-api": + result = await this.executeRestApiCall(request); + break; + case "email": + result = await this.executeEmailCall(request); + break; + case "ftp": + throw new Error("FTP 호출은 아직 구현되지 않았습니다."); + case "queue": + throw new Error("메시지 큐 호출은 아직 구현되지 않았습니다."); + default: + throw new Error( + `지원되지 않는 호출 타입: ${request.settings.callType}` + ); + } + + result.executionTime = Date.now() - startTime; + result.timestamp = new Date(); + + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + executionTime: Date.now() - startTime, + timestamp: new Date(), + }; + } + } + + /** + * REST API 호출 실행 + */ + private async executeRestApiCall( + request: ExternalCallRequest + ): Promise { + const settings = request.settings as any; // 임시로 any 사용 + + switch (settings.apiType) { + case "slack": + return await this.executeSlackWebhook( + settings as SlackSettings, + request.templateData + ); + case "kakao-talk": + return await this.executeKakaoTalkApi( + settings as KakaoTalkSettings, + request.templateData + ); + case "discord": + return await this.executeDiscordWebhook( + settings as DiscordSettings, + request.templateData + ); + case "generic": + default: + return await this.executeGenericApi( + settings as GenericApiSettings, + request.templateData + ); + } + } + + /** + * 슬랙 웹훅 실행 + */ + private async executeSlackWebhook( + settings: SlackSettings, + templateData?: Record + ): Promise { + const payload = { + text: this.processTemplate(settings.message, templateData), + channel: settings.channel, + username: settings.username || "DataFlow Bot", + icon_emoji: settings.iconEmoji || ":robot_face:", + }; + + return await this.makeHttpRequest({ + url: settings.webhookUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 카카오톡 API 실행 + */ + private async executeKakaoTalkApi( + settings: KakaoTalkSettings, + templateData?: Record + ): Promise { + const payload = { + object_type: "text", + text: this.processTemplate(settings.message, templateData), + link: { + web_url: "https://developers.kakao.com", + mobile_web_url: "https://developers.kakao.com", + }, + }; + + return await this.makeHttpRequest({ + url: "https://kapi.kakao.com/v2/api/talk/memo/default/send", + method: "POST", + headers: { + Authorization: `Bearer ${settings.accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `template_object=${encodeURIComponent(JSON.stringify(payload))}`, + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 디스코드 웹훅 실행 + */ + private async executeDiscordWebhook( + settings: DiscordSettings, + templateData?: Record + ): Promise { + const payload = { + content: this.processTemplate(settings.message, templateData), + username: settings.username || "시스템 알리미", + avatar_url: settings.avatarUrl, + }; + + return await this.makeHttpRequest({ + url: settings.webhookUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 일반 REST API 실행 + */ + private async executeGenericApi( + settings: GenericApiSettings, + templateData?: Record + ): Promise { + let body = settings.body; + if (body && templateData) { + body = this.processTemplate(body, templateData); + } + + return await this.makeHttpRequest({ + url: settings.url, + method: settings.method, + headers: settings.headers || {}, + body: body, + timeout: settings.timeout || this.DEFAULT_TIMEOUT, + }); + } + + /** + * 이메일 호출 실행 (향후 구현) + */ + private async executeEmailCall( + request: ExternalCallRequest + ): Promise { + // TODO: 이메일 발송 구현 (Java MailUtil 연동) + throw new Error("이메일 발송 기능은 아직 구현되지 않았습니다."); + } + + /** + * HTTP 요청 실행 (공통) + */ + private async makeHttpRequest(options: { + url: string; + method: string; + headers?: Record; + body?: string; + timeout: number; + }): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), options.timeout); + + const response = await fetch(options.url, { + method: options.method, + headers: options.headers, + body: options.body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const responseText = await response.text(); + + return { + success: response.ok, + statusCode: response.status, + response: responseText, + executionTime: 0, // 상위에서 설정됨 + timestamp: new Date(), + }; + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError") { + throw new Error(`요청 시간 초과 (${options.timeout}ms)`); + } + throw error; + } + throw new Error(`HTTP 요청 실패: ${String(error)}`); + } + } + + /** + * 템플릿 문자열 처리 + */ + private processTemplate( + template: string, + data?: Record, + options: TemplateOptions = {} + ): string { + if (!data || Object.keys(data).length === 0) { + return template; + } + + const startDelimiter = options.startDelimiter || "{{"; + const endDelimiter = options.endDelimiter || "}}"; + + let result = template; + + Object.entries(data).forEach(([key, value]) => { + const placeholder = `${startDelimiter}${key}${endDelimiter}`; + const replacement = String(value ?? ""); + result = result.replace(new RegExp(placeholder, "g"), replacement); + }); + + return result; + } + + /** + * 외부 호출 설정 검증 + */ + validateSettings(settings: SupportedExternalCallSettings): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (settings.callType === "rest-api") { + switch (settings.apiType) { + case "slack": + const slackSettings = settings as SlackSettings; + if (!slackSettings.webhookUrl) + errors.push("슬랙 웹훅 URL이 필요합니다."); + if (!slackSettings.message) errors.push("슬랙 메시지가 필요합니다."); + break; + + case "kakao-talk": + const kakaoSettings = settings as KakaoTalkSettings; + if (!kakaoSettings.accessToken) + errors.push("카카오톡 액세스 토큰이 필요합니다."); + if (!kakaoSettings.message) + errors.push("카카오톡 메시지가 필요합니다."); + break; + + case "discord": + const discordSettings = settings as DiscordSettings; + if (!discordSettings.webhookUrl) + errors.push("디스코드 웹훅 URL이 필요합니다."); + if (!discordSettings.message) + errors.push("디스코드 메시지가 필요합니다."); + break; + + case "generic": + default: + const genericSettings = settings as GenericApiSettings; + if (!genericSettings.url) errors.push("API URL이 필요합니다."); + if (!genericSettings.method) errors.push("HTTP 메서드가 필요합니다."); + break; + } + } else if (settings.callType === "email") { + const emailSettings = settings as EmailSettings; + if (!emailSettings.smtpHost) errors.push("SMTP 호스트가 필요합니다."); + if (!emailSettings.toEmail) errors.push("수신 이메일이 필요합니다."); + if (!emailSettings.subject) errors.push("이메일 제목이 필요합니다."); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts new file mode 100644 index 00000000..f614253f --- /dev/null +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -0,0 +1,374 @@ +// 외부 DB 연결 서비스 +// 작성일: 2024-12-17 + +import { PrismaClient } from "@prisma/client"; +import { + ExternalDbConnection, + ExternalDbConnectionFilter, + ApiResponse, +} from "../types/externalDbTypes"; +import { PasswordEncryption } from "../utils/passwordEncryption"; + +const prisma = new PrismaClient(); + +export class ExternalDbConnectionService { + /** + * 외부 DB 연결 목록 조회 + */ + static async getConnections( + filter: ExternalDbConnectionFilter + ): Promise> { + try { + const where: any = {}; + + // 필터 조건 적용 + if (filter.db_type) { + where.db_type = filter.db_type; + } + + if (filter.is_active) { + where.is_active = filter.is_active; + } + + if (filter.company_code) { + where.company_code = filter.company_code; + } + + // 검색 조건 적용 (연결명 또는 설명에서 검색) + if (filter.search && filter.search.trim()) { + where.OR = [ + { + connection_name: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + { + description: { + contains: filter.search.trim(), + mode: "insensitive", + }, + }, + ]; + } + + const connections = await prisma.external_db_connections.findMany({ + where, + orderBy: [{ is_active: "desc" }, { connection_name: "asc" }], + }); + + // 비밀번호는 반환하지 않음 (보안) + const safeConnections = connections.map((conn) => ({ + ...conn, + password: "***ENCRYPTED***", // 실제 비밀번호 대신 마스킹 + description: conn.description || undefined, + })) as ExternalDbConnection[]; + + return { + success: true, + data: safeConnections, + message: `${connections.length}개의 연결 설정을 조회했습니다.`, + }; + } catch (error) { + console.error("외부 DB 연결 목록 조회 실패:", error); + return { + success: false, + message: "연결 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 특정 외부 DB 연결 조회 + */ + static async getConnectionById( + id: number + ): Promise> { + try { + const connection = await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!connection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 비밀번호는 반환하지 않음 (보안) + const safeConnection = { + ...connection, + password: "***ENCRYPTED***", + description: connection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정을 조회했습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 조회 실패:", error); + return { + success: false, + message: "연결 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 새 외부 DB 연결 생성 + */ + static async createConnection( + data: ExternalDbConnection + ): Promise> { + try { + // 데이터 검증 + this.validateConnectionData(data); + + // 연결명 중복 확인 + const existingConnection = await prisma.external_db_connections.findFirst( + { + where: { + connection_name: data.connection_name, + company_code: data.company_code, + }, + } + ); + + if (existingConnection) { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + + // 비밀번호 암호화 + const encryptedPassword = PasswordEncryption.encrypt(data.password); + + const newConnection = await prisma.external_db_connections.create({ + data: { + connection_name: data.connection_name, + description: data.description, + db_type: data.db_type, + host: data.host, + port: data.port, + database_name: data.database_name, + username: data.username, + password: encryptedPassword, + connection_timeout: data.connection_timeout, + query_timeout: data.query_timeout, + max_connections: data.max_connections, + ssl_enabled: data.ssl_enabled, + ssl_cert_path: data.ssl_cert_path, + connection_options: data.connection_options as any, + company_code: data.company_code, + is_active: data.is_active, + created_by: data.created_by, + updated_by: data.updated_by, + created_date: new Date(), + updated_date: new Date(), + }, + }); + + // 비밀번호는 반환하지 않음 + const safeConnection = { + ...newConnection, + password: "***ENCRYPTED***", + description: newConnection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정이 생성되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 생성 실패:", error); + return { + success: false, + message: "연결 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 연결 수정 + */ + static async updateConnection( + id: number, + data: Partial + ): Promise> { + try { + // 기존 연결 확인 + const existingConnection = + await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!existingConnection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 연결명 중복 확인 (자신 제외) + if (data.connection_name) { + const duplicateConnection = + await prisma.external_db_connections.findFirst({ + where: { + connection_name: data.connection_name, + company_code: + data.company_code || existingConnection.company_code, + id: { not: id }, + }, + }); + + if (duplicateConnection) { + return { + success: false, + message: "이미 존재하는 연결명입니다.", + }; + } + } + + // 업데이트 데이터 준비 + const updateData: any = { + ...data, + updated_date: new Date(), + }; + + // 비밀번호가 변경된 경우 암호화 + if (data.password && data.password !== "***ENCRYPTED***") { + updateData.password = PasswordEncryption.encrypt(data.password); + } else { + // 비밀번호 필드 제거 (변경하지 않음) + delete updateData.password; + } + + const updatedConnection = await prisma.external_db_connections.update({ + where: { id }, + data: updateData, + }); + + // 비밀번호는 반환하지 않음 + const safeConnection = { + ...updatedConnection, + password: "***ENCRYPTED***", + description: updatedConnection.description || undefined, + } as ExternalDbConnection; + + return { + success: true, + data: safeConnection, + message: "연결 설정이 수정되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 수정 실패:", error); + return { + success: false, + message: "연결 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 연결 삭제 (논리 삭제) + */ + static async deleteConnection(id: number): Promise> { + try { + const existingConnection = + await prisma.external_db_connections.findUnique({ + where: { id }, + }); + + if (!existingConnection) { + return { + success: false, + message: "해당 연결 설정을 찾을 수 없습니다.", + }; + } + + // 논리 삭제 (is_active를 'N'으로 변경) + await prisma.external_db_connections.update({ + where: { id }, + data: { + is_active: "N", + updated_date: new Date(), + }, + }); + + return { + success: true, + message: "연결 설정이 삭제되었습니다.", + }; + } catch (error) { + console.error("외부 DB 연결 삭제 실패:", error); + return { + success: false, + message: "연결 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 연결 데이터 검증 + */ + private static validateConnectionData(data: ExternalDbConnection): void { + const requiredFields = [ + "connection_name", + "db_type", + "host", + "port", + "database_name", + "username", + "password", + "company_code", + ]; + + for (const field of requiredFields) { + if (!data[field as keyof ExternalDbConnection]) { + throw new Error(`필수 필드가 누락되었습니다: ${field}`); + } + } + + // 포트 번호 유효성 검사 + if (data.port < 1 || data.port > 65535) { + throw new Error("유효하지 않은 포트 번호입니다. (1-65535)"); + } + + // DB 타입 유효성 검사 + const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite"]; + if (!validDbTypes.includes(data.db_type)) { + throw new Error("지원하지 않는 DB 타입입니다."); + } + } + + /** + * 저장된 연결의 실제 비밀번호 조회 (내부용) + */ + static async getDecryptedPassword(id: number): Promise { + try { + const connection = await prisma.external_db_connections.findUnique({ + where: { id }, + select: { password: true }, + }); + + if (!connection) { + return null; + } + + return PasswordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + return null; + } + } +} diff --git a/backend-node/src/types/externalCallTypes.ts b/backend-node/src/types/externalCallTypes.ts new file mode 100644 index 00000000..47f52411 --- /dev/null +++ b/backend-node/src/types/externalCallTypes.ts @@ -0,0 +1,126 @@ +/** + * 외부 호출 관련 타입 정의 + */ + +// 기본 외부 호출 설정 +export interface ExternalCallConfig { + callType: "rest-api" | "email" | "ftp" | "queue"; + apiType?: "slack" | "kakao-talk" | "discord" | "generic"; + + // 공통 설정 + timeout?: number; // ms + retryCount?: number; + retryDelay?: number; // ms +} + +// REST API 공통 설정 +export interface RestApiConfig extends ExternalCallConfig { + callType: "rest-api"; + url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: string; +} + +// 슬랙 웹훅 설정 +export interface SlackSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "slack"; + webhookUrl: string; + channel?: string; + message: string; + username?: string; + iconEmoji?: string; +} + +// 카카오톡 API 설정 +export interface KakaoTalkSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "kakao-talk"; + accessToken: string; + message: string; + templateId?: string; + phoneNumber?: string; +} + +// 디스코드 웹훅 설정 +export interface DiscordSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "discord"; + webhookUrl: string; + message: string; + username?: string; + avatarUrl?: string; +} + +// 일반 REST API 설정 +export interface GenericApiSettings extends ExternalCallConfig { + callType: "rest-api"; + apiType: "generic"; + url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: string; +} + +// 이메일 설정 +export interface EmailSettings extends ExternalCallConfig { + callType: "email"; + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPass: string; + fromEmail: string; + toEmail: string; + subject: string; + body: string; +} + +// 외부 호출 실행 결과 +export interface ExternalCallResult { + success: boolean; + statusCode?: number; + response?: string; + error?: string; + executionTime: number; // ms + timestamp: Date; +} + +// 외부 호출 실행 요청 +export interface ExternalCallRequest { + diagramId: number; + relationshipId: string; + settings: ExternalCallConfig; + templateData?: Record; // 템플릿 변수 데이터 +} + +// 템플릿 처리 옵션 +export interface TemplateOptions { + startDelimiter?: string; // 기본값: "{{" + endDelimiter?: string; // 기본값: "}}" + escapeHtml?: boolean; // 기본값: false +} + +// 외부 호출 로그 (향후 구현) +export interface ExternalCallLog { + id: number; + diagramId: number; + relationshipId: string; + callType: string; + apiType?: string; + targetUrl: string; + requestPayload?: string; + responseStatus?: number; + responseBody?: string; + errorMessage?: string; + executionTimeMs: number; + createdAt: Date; +} + +// 지원되는 외부 호출 타입들의 Union 타입 +export type SupportedExternalCallSettings = + | SlackSettings + | KakaoTalkSettings + | DiscordSettings + | GenericApiSettings + | EmailSettings; diff --git a/backend-node/src/types/externalDbTypes.ts b/backend-node/src/types/externalDbTypes.ts new file mode 100644 index 00000000..3053cfbd --- /dev/null +++ b/backend-node/src/types/externalDbTypes.ts @@ -0,0 +1,109 @@ +// 외부 DB 연결 관련 타입 정의 +// 작성일: 2024-12-17 + +export interface ExternalDbConnection { + id?: number; + connection_name: string; + description?: string; + 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; + ssl_cert_path?: string; + connection_options?: Record; + company_code: string; + is_active: string; + created_date?: Date; + created_by?: string; + updated_date?: Date; + updated_by?: string; +} + +export interface ExternalDbConnectionFilter { + db_type?: string; + is_active?: string; + company_code?: string; + search?: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +// DB 타입 옵션 +export const DB_TYPE_OPTIONS = [ + { value: "mysql", label: "MySQL" }, + { value: "postgresql", label: "PostgreSQL" }, + { value: "oracle", label: "Oracle" }, + { value: "mssql", label: "SQL Server" }, + { value: "sqlite", label: "SQLite" }, +]; + +// DB 타입별 기본 설정 +export const DB_TYPE_DEFAULTS = { + mysql: { port: 3306, driver: "mysql2" }, + postgresql: { port: 5432, driver: "pg" }, + oracle: { port: 1521, driver: "oracledb" }, + mssql: { port: 1433, driver: "mssql" }, + sqlite: { port: 0, driver: "sqlite3" }, +}; + +// 활성 상태 옵션 +export const ACTIVE_STATUS_OPTIONS = [ + { value: "Y", label: "활성" }, + { value: "N", label: "비활성" }, + { value: "", label: "전체" }, +]; + +// 연결 옵션 스키마 (각 DB 타입별 추가 옵션) +export interface MySQLConnectionOptions { + charset?: string; + timezone?: string; + connectTimeout?: number; + acquireTimeout?: number; + multipleStatements?: boolean; +} + +export interface PostgreSQLConnectionOptions { + schema?: string; + ssl?: boolean | object; + application_name?: string; + statement_timeout?: number; +} + +export interface OracleConnectionOptions { + serviceName?: string; + sid?: string; + connectString?: string; + poolMin?: number; + poolMax?: number; +} + +export interface SQLServerConnectionOptions { + encrypt?: boolean; + trustServerCertificate?: boolean; + requestTimeout?: number; + connectionTimeout?: number; +} + +export interface SQLiteConnectionOptions { + mode?: string; + cache?: string; + foreign_keys?: boolean; +} + +export type SupportedConnectionOptions = + | MySQLConnectionOptions + | PostgreSQLConnectionOptions + | OracleConnectionOptions + | SQLServerConnectionOptions + | SQLiteConnectionOptions; diff --git a/backend-node/src/utils/passwordEncryption.ts b/backend-node/src/utils/passwordEncryption.ts new file mode 100644 index 00000000..61dd9717 --- /dev/null +++ b/backend-node/src/utils/passwordEncryption.ts @@ -0,0 +1,113 @@ +// 비밀번호 암호화/복호화 유틸리티 +// 작성일: 2024-12-17 + +import crypto from "crypto"; + +export class PasswordEncryption { + private static readonly ALGORITHM = "aes-256-cbc"; + private static readonly SECRET_KEY = + process.env.DB_PASSWORD_SECRET || + "default-fallback-key-change-in-production"; + private static readonly IV_LENGTH = 16; // AES-CBC의 경우 16바이트 + + /** + * 비밀번호를 암호화합니다. + * @param password 암호화할 평문 비밀번호 + * @returns 암호화된 비밀번호 (base64 인코딩) + */ + static encrypt(password: string): string { + try { + // 랜덤 IV 생성 + const iv = crypto.randomBytes(this.IV_LENGTH); + + // 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성) + const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); + + // 암호화 객체 생성 + const cipher = crypto.createCipher("aes-256-cbc", key); + + // 암호화 실행 + let encrypted = cipher.update(password, "utf8", "hex"); + encrypted += cipher.final("hex"); + + // IV와 암호화된 데이터를 결합하여 반환 + return `${iv.toString("hex")}:${encrypted}`; + } catch (error) { + console.error("Password encryption failed:", error); + throw new Error("비밀번호 암호화에 실패했습니다."); + } + } + + /** + * 암호화된 비밀번호를 복호화합니다. + * @param encryptedPassword 암호화된 비밀번호 + * @returns 복호화된 평문 비밀번호 + */ + static decrypt(encryptedPassword: string): string { + try { + // IV와 암호화된 데이터 분리 + const parts = encryptedPassword.split(":"); + if (parts.length !== 2) { + throw new Error("잘못된 암호화된 비밀번호 형식입니다."); + } + + const iv = Buffer.from(parts[0], "hex"); + const encrypted = parts[1]; + + // 암호화 키 생성 (암호화 시와 동일) + const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); + + // 복호화 객체 생성 + const decipher = crypto.createDecipher("aes-256-cbc", key); + + // 복호화 실행 + let decrypted = decipher.update(encrypted, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + console.error("Password decryption failed:", error); + throw new Error("비밀번호 복호화에 실패했습니다."); + } + } + + /** + * 암호화 키가 설정되어 있는지 확인합니다. + * @returns 키 설정 여부 + */ + static isKeyConfigured(): boolean { + return ( + process.env.DB_PASSWORD_SECRET !== undefined && + process.env.DB_PASSWORD_SECRET !== "" + ); + } + + /** + * 암호화/복호화 기능을 테스트합니다. + * @returns 테스트 결과 + */ + static testEncryption(): { success: boolean; message: string } { + try { + const testPassword = "test123!@#"; + const encrypted = this.encrypt(testPassword); + const decrypted = this.decrypt(encrypted); + + if (testPassword === decrypted) { + return { + success: true, + message: "암호화/복호화 테스트가 성공했습니다.", + }; + } else { + return { + success: false, + message: "암호화/복호화 결과가 일치하지 않습니다.", + }; + } + } catch (error) { + return { + success: false, + message: `암호화/복호화 테스트 실패: ${error}`, + }; + } + } +} diff --git a/docs/external-call-implementation-plan.md b/docs/external-call-implementation-plan.md new file mode 100644 index 00000000..276e4f19 --- /dev/null +++ b/docs/external-call-implementation-plan.md @@ -0,0 +1,604 @@ +# 외부 호출 기능 구현 계획서 + +## 📋 개요 + +데이터플로우 다이어그램에서 외부 시스템 호출 기능을 구현하여, 데이터 처리 완료 시 다양한 외부 서비스로 알림이나 데이터를 전송할 수 있도록 합니다. + +## 🎯 목표 + +1. **무료/저렴한 알림 방법** 우선 구현 +2. **확장 가능한 구조** 설계 +3. **사용자 친화적 UI** 제공 +4. **안정적인 오류 처리** 구현 + +--- + +## 🚀 Phase 1: 기본 외부 호출 기능 (무료) + +### 1.1 REST API 호출 🚧 + +**현재 상태**: UI만 구현됨 (실제 호출 기능 없음) + +- ✅ 설정 UI: HTTP 메서드, 헤더, 페이로드 입력 가능 +- ❌ 실제 HTTP 요청 전송 기능 없음 +- ❌ 백엔드 서비스 구현 필요 + +### 1.2 웹훅 호출 🚧 + +**현재 상태**: UI만 구현됨 (실제 호출 기능 없음) + +- ✅ 설정 UI: 웹훅 URL 입력 가능 +- ❌ 실제 웹훅 전송 기능 없음 +- ❌ 백엔드 서비스 구현 필요 + +### 1.3 이메일 알림 🔄 + +**현재 상태**: Java 기반 구현됨 (Node.js 연동 필요) + +- ✅ Java MailUtil 클래스 구현됨 +- ✅ SMTP 설정 및 발송 기능 있음 +- ❌ 데이터플로우와 연동 안됨 + +### 1.4 통합된 REST API 호출 시스템 🆕 + +**새로운 중첩 드롭다운 구조로 개선** + +- **1단계**: 호출 유형 (`REST API 호출`, `이메일 전송`, `FTP 업로드`) +- **2단계**: REST API 세부 종류 (`슬랙`, `카카오톡`, `디스코드`, `기타`) +- **3단계**: 각 종류별 맞춤 설정 UI + +```typescript +// ExternalCallService.ts - 통합 구조 +class ExternalCallService { + // 공통 REST API 호출 메서드 + private async callRestApi(config: RestApiConfig) { + return await fetch(config.url, { + method: config.method, + headers: config.headers, + body: config.body, + }); + } + + // 슬랙 (REST API의 특수 케이스) + async sendSlackMessage(settings: SlackSettings) { + return await this.callRestApi({ + url: settings.slackWebhookUrl, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: settings.slackMessage, + channel: settings.slackChannel, + }), + }); + } + + // 카카오톡 (REST API의 특수 케이스) + async sendKakaoTalk(settings: KakaoSettings) { + return await this.callRestApi({ + url: "https://kapi.kakao.com/v2/api/talk/memo/default/send", + method: "POST", + headers: { Authorization: `Bearer ${settings.kakaoAccessToken}` }, + body: this.buildKakaoBody(settings.kakaoMessage), + }); + } + + // 일반 API (사용자 정의) + async callGenericApi(settings: GenericApiSettings) { + return await this.callRestApi({ + url: settings.apiUrl, + method: settings.httpMethod, + headers: JSON.parse(settings.headers || "{}"), + body: settings.bodyTemplate, + }); + } +} +``` + +--- + +## 🔧 Phase 2: UI/UX 개선 + +### 2.1 중첩 드롭다운 구조로 외부 호출 타입 개선 + +**파일**: `frontend/types/connectionTypes.ts` + +```typescript +export interface ExternalCallSettings { + callType: "rest-api" | "email" | "ftp" | "queue"; + + // REST API 세부 종류 (중첩 드롭다운) + apiType?: "slack" | "kakao-talk" | "discord" | "generic"; + + // 일반 REST API 설정 (기타 선택 시) + apiUrl?: string; + httpMethod?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: string; + bodyTemplate?: string; + + // 슬랙 전용 설정 + slackWebhookUrl?: string; + slackChannel?: string; + slackMessage?: string; + + // 카카오톡 전용 설정 + kakaoAccessToken?: string; + kakaoMessage?: string; + + // 디스코드 전용 설정 + discordWebhookUrl?: string; + discordMessage?: string; + + // 이메일 설정 + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPass?: string; + fromEmail?: string; + toEmail?: string; + subject?: string; + emailBody?: string; +} +``` + +#### **중첩 드롭다운 구조**: + +1. **1단계**: 호출 유형 선택 (`REST API 호출`, `이메일 전송`, `FTP 업로드`, `메시지 큐`) +2. **2단계**: REST API 세부 종류 선택 (`슬랙`, `카카오톡`, `디스코드`, `기타`) +3. **3단계**: 각 종류별 맞춤 설정 UI 표시 + +### 2.2 중첩 드롭다운 UI 구현 + +**파일**: `frontend/components/dataflow/connection/ExternalCallSettings.tsx` + +#### 1단계: 호출 유형 선택 + +```tsx + +``` + +#### 2단계: REST API 세부 종류 선택 + +```tsx +{ + settings.callType === "rest-api" && ( + + ); +} +``` + +#### 3단계: 각 종류별 맞춤 설정 + +```tsx +{ + /* 슬랙 설정 */ +} +{ + settings.apiType === "slack" && ( + <> + + +