Merge pull request 'db커넥션, 제어관리 조건 선택 수정' (#32) from dataflowMng into dev

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/32
This commit is contained in:
hyeonsu 2025-09-18 10:20:08 +09:00
commit 42422b7814
29 changed files with 5824 additions and 247 deletions

View File

@ -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();

View File

@ -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)

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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<ExternalDbConnection> = 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;

View File

@ -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<ExternalCallConfig[]> {
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<ExternalCallConfig | null> {
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<ExternalCallConfig> {
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<ExternalCallConfig>
): Promise<ExternalCallConfig> {
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<void> {
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();

View File

@ -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<ExternalCallResult> {
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<ExternalCallResult> {
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<string, unknown>
): Promise<ExternalCallResult> {
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<string, unknown>
): Promise<ExternalCallResult> {
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<string, unknown>
): Promise<ExternalCallResult> {
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<string, unknown>
): Promise<ExternalCallResult> {
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<ExternalCallResult> {
// TODO: 이메일 발송 구현 (Java MailUtil 연동)
throw new Error("이메일 발송 기능은 아직 구현되지 않았습니다.");
}
/**
* HTTP ()
*/
private async makeHttpRequest(options: {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
timeout: number;
}): Promise<ExternalCallResult> {
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<string, unknown>,
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,
};
}
}

View File

@ -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<ApiResponse<ExternalDbConnection[]>> {
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<ApiResponse<ExternalDbConnection>> {
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<ApiResponse<ExternalDbConnection>> {
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<ExternalDbConnection>
): Promise<ApiResponse<ExternalDbConnection>> {
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<ApiResponse<void>> {
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<string | null> {
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;
}
}
}

View File

@ -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<string, string>;
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<string, string>;
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<string, unknown>; // 템플릿 변수 데이터
}
// 템플릿 처리 옵션
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;

View File

@ -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<string, unknown>;
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<T> {
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;

View File

@ -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}`,
};
}
}
}

View File

@ -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
<Select
value={settings.callType}
onValueChange={(value) => onSettingsChange({ ...settings, callType: value })}
>
<SelectContent>
<SelectItem value="rest-api">REST API 호출</SelectItem>
<SelectItem value="email">이메일 전송</SelectItem>
<SelectItem value="ftp">FTP 업로드</SelectItem>
<SelectItem value="queue">메시지 큐</SelectItem>
</SelectContent>
</Select>
```
#### 2단계: REST API 세부 종류 선택
```tsx
{
settings.callType === "rest-api" && (
<Select
value={settings.apiType || "generic"}
onValueChange={(value) =>
onSettingsChange({ ...settings, apiType: value })
}
>
<SelectContent>
<SelectItem value="slack">슬랙</SelectItem>
<SelectItem value="kakao-talk">카카오톡</SelectItem>
<SelectItem value="discord">디스코드</SelectItem>
<SelectItem value="generic">기타 (일반 API)</SelectItem>
</SelectContent>
</Select>
);
}
```
#### 3단계: 각 종류별 맞춤 설정
```tsx
{
/* 슬랙 설정 */
}
{
settings.apiType === "slack" && (
<>
<Input placeholder="https://hooks.slack.com/services/..." />
<Input placeholder="#general" />
<Textarea placeholder="데이터 처리 완료! {{recordCount}}건" />
</>
);
}
{
/* 카카오톡 설정 */
}
{
settings.apiType === "kakao-talk" && (
<>
<Input type="password" placeholder="카카오 액세스 토큰" />
<Textarea placeholder="처리 완료! 총 {{recordCount}}건 처리되었습니다." />
</>
);
}
{
/* 기타 API 설정 */
}
{
settings.apiType === "generic" && (
<>
<Input placeholder="https://api.example.com/webhook" />
<Select placeholder="HTTP Method" />
<Textarea placeholder="Headers (JSON)" />
<Textarea placeholder="Body Template" />
</>
);
}
```
---
## 📱 Phase 3: 메신저 통합 (유료)
### 3.1 SMS 발송
**예상 비용**: 15~20원/건
**추천 서비스**:
- NHN Toast SMS
- 네이버 클라우드 SMS
- 알리고 SMS
```typescript
// SMS 설정 타입
interface SMSSettings {
provider: "toast" | "naver" | "aligo";
apiKey: string;
secretKey: string;
fromNumber: string;
toNumber: string;
message: string;
}
```
### 3.2 카카오톡 알림톡
**예상 비용**: 15~25원/건
**필요 사항**:
- 카카오톡 비즈니스 계정
- 알림톡 템플릿 등록
- 카카오톡 비즈니스 API 연동
```typescript
// 카카오톡 설정 타입
interface KakaoTalkSettings {
apiKey: string;
templateCode: string;
phoneNumber: string;
templateParams: Record<string, string>;
}
```
---
## 🛠️ Phase 4: 고급 기능
### 4.1 재시도 메커니즘
```typescript
interface RetryConfig {
maxRetries: number;
retryDelay: number; // ms
backoffMultiplier: number;
}
// ExternalCallService에서 구현
async executeWithRetry(callFunction: () => Promise<any>, config: RetryConfig) {
let lastError: Error;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
return await callFunction();
} catch (error) {
lastError = error as Error;
if (attempt < config.maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, config.retryDelay * Math.pow(config.backoffMultiplier, attempt - 1))
);
}
}
}
throw lastError!;
}
```
### 4.2 호출 로그 및 모니터링
```sql
-- 외부 호출 로그 테이블
CREATE TABLE external_call_logs (
id SERIAL PRIMARY KEY,
diagram_id INTEGER REFERENCES dataflow_diagrams(diagram_id),
relationship_id VARCHAR(255),
call_type VARCHAR(50),
target_url VARCHAR(500),
request_payload TEXT,
response_status INTEGER,
response_body TEXT,
error_message TEXT,
execution_time_ms INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.3 조건부 호출
```typescript
// 특정 조건에서만 외부 호출 실행
interface ConditionalCall {
condition: string; // "recordCount > 100"
callSettings: ExternalCallSettings;
}
```
---
## 📊 구현 우선순위
### 🥇 **High Priority (즉시 구현)**
1. 🚧 **ExternalCallService 생성** - 백엔드 서비스 기반 구축
2. 🆕 **슬랙 웹훅 구현** - 가장 간단하고 테스트하기 쉬움
3. 🔄 **REST API 호출 완성** - UI는 있으나 실제 기능 없음
4. 🔄 **웹훅 호출 완성** - UI는 있으나 실제 기능 없음
5. 🔄 **이메일 연동** - Java MailUtil을 Node.js에서 호출
6. 🔄 **디스코드 웹훅 추가** - 슬랙과 유사한 구조
### 🥈 **Medium Priority (1-2주 후)**
7. 🔄 재시도 메커니즘
8. 🔄 호출 로그 시스템
9. 🔄 오류 처리 개선
10. 🔄 템플릿 변수 확장
### 🥉 **Low Priority (필요시)**
11. 🔄 SMS 발송
12. 🔄 카카오톡 알림톡
13. 🔄 조건부 호출
14. 🔄 배치 호출
---
## 🔧 기술적 구현 세부사항
### 백엔드 수정사항
#### 1. ExternalCallService 확장
**파일**: `backend-node/src/services/externalCallService.ts`
```typescript
// 슬랙 웹훅 메서드 추가
async sendSlackMessage(settings: SlackSettings): Promise<boolean> {
const payload = {
text: this.processTemplate(settings.message, settings.templateData),
channel: settings.channel,
username: settings.username || "DataFlow Bot",
icon_emoji: settings.iconEmoji || ":robot_face:"
};
const response = await fetch(settings.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
return response.ok;
}
```
#### 2. 데이터베이스 스키마 확장 (선택사항)
```sql
-- 외부 호출 설정을 별도 테이블로 관리 (대용량 처리 시)
CREATE TABLE external_call_configs (
id SERIAL PRIMARY KEY,
diagram_id INTEGER REFERENCES dataflow_diagrams(diagram_id),
relationship_id VARCHAR(255),
call_type VARCHAR(50),
config_data JSONB,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- 인덱스
CREATE INDEX idx_external_calls_diagram ON external_call_configs(diagram_id);
CREATE INDEX idx_external_calls_relationship ON external_call_configs(relationship_id);
```
### 프론트엔드 수정사항
#### 1. 타입 정의 확장
**파일**: `frontend/types/connectionTypes.ts`
- SlackSettings, DiscordSettings 인터페이스 추가
- ExternalCallSettings 확장
#### 2. UI 컴포넌트 확장
**파일**: `frontend/components/dataflow/connection/ExternalCallSettings.tsx`
- 슬랙, 디스코드 설정 UI 추가
- 템플릿 변수 도움말 추가
- 실시간 미리보기 기능
#### 3. 유효성 검사 강화
**파일**: `frontend/components/dataflow/ConnectionSetupModal.tsx`
```typescript
const validateExternalCallSettings = (
settings: ExternalCallSettings
): boolean => {
if (settings.callType === "rest-api") {
switch (settings.apiType) {
case "slack":
return !!(settings.slackWebhookUrl && settings.slackMessage);
case "kakao-talk":
return !!(settings.kakaoAccessToken && settings.kakaoMessage);
case "discord":
return !!(settings.discordWebhookUrl && settings.discordMessage);
case "generic":
return !!(settings.apiUrl && settings.httpMethod);
default:
return false;
}
} else if (settings.callType === "email") {
return !!(settings.toEmail && settings.subject && settings.emailBody);
}
return true;
};
```
---
## 📈 성능 고려사항
### 1. 비동기 처리
- 외부 호출을 메인 데이터 처리와 분리
- 큐 시스템 도입 (Redis/Bull Queue)
### 2. 타임아웃 설정
```typescript
const EXTERNAL_CALL_TIMEOUT = {
"rest-api": 30000, // 30초
webhook: 10000, // 10초
email: 60000, // 60초
slack: 15000, // 15초
};
```
### 3. 호출 제한
```typescript
// 레이트 리미팅
const RATE_LIMITS = {
slack: { requests: 1, per: 1000 }, // 1초당 1회
email: { requests: 10, per: 60000 }, // 1분당 10회
"rest-api": { requests: 100, per: 60000 }, // 1분당 100회
};
```
---
## 🔒 보안 고려사항
### 1. API 키 관리
- 환경변수로 민감 정보 관리
- 데이터베이스 암호화 저장
- API 키 순환 정책
### 2. 입력값 검증
```typescript
// 웹훅 URL 화이트리스트
const ALLOWED_WEBHOOK_DOMAINS = [
"hooks.slack.com",
"discord.com",
"outlook.office365.com",
];
const validateWebhookUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return ALLOWED_WEBHOOK_DOMAINS.some((domain) =>
parsed.hostname.endsWith(domain)
);
} catch {
return false;
}
};
```
### 3. 로깅 및 감사
- 모든 외부 호출 로깅
- 실패 원인 추적
- 사용량 모니터링
---
## 📚 참고 자료
### API 문서
- [Slack Webhooks](https://api.slack.com/messaging/webhooks)
- [Discord Webhooks](https://discord.com/developers/docs/resources/webhook)
- [Microsoft Teams Webhooks](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook)
### SMS/카카오톡 서비스
- [NHN Toast SMS](https://docs.toast.com/ko/Notification/SMS/ko/api-guide/)
- [네이버 클라우드 SMS](https://api.ncloud-docs.com/docs/ai-application-service-sens-smsv2)
- [카카오톡 비즈니스 API](https://developers.kakao.com/docs/latest/ko/message/common)
---
## 📅 일정
### Week 1
- [ ] 슬랙 웹훅 구현
- [ ] 디스코드 웹훅 구현
- [ ] UI 컴포넌트 확장
### Week 2
- [ ] 재시도 메커니즘 구현
- [ ] 호출 로그 시스템 구현
- [ ] 테스트 코드 작성
### Week 3
- [ ] SMS 발송 구현 (선택)
- [ ] 성능 최적화
- [ ] 문서화 완성
### Week 4
- [ ] 카카오톡 알림톡 구현 (선택)
- [ ] 최종 테스트 및 배포
---
## ✅ 체크리스트
### 개발 완료 체크
#### 🚧 **현재 상태 (실제)**
- [x] **중첩 드롭다운 UI 구현** (호출 유형 → API 종류 → 맞춤 설정)
- [x] **타입 정의 개선** (REST API 세부 분류 지원)
- [ ] **ExternalCallService 백엔드 서비스 생성** ← 최우선!
- [ ] 실제 HTTP 요청/웹훅 전송 기능
- [ ] 데이터플로우 실행 시 외부 호출 트리거
- [ ] 슬랙 웹훅 구현 (REST API 방식)
- [ ] 카카오톡 API 호출 구현 (REST API 방식)
- [ ] 디스코드 웹훅 구현 (REST API 방식)
- [ ] 일반 REST API 호출 구현
- [ ] 이메일 발송 연동 (Java → Node.js)
- [ ] 오류 처리 및 재시도 메커니즘
### 배포 준비 체크
- [ ] 환경변수 설정
- [ ] 데이터베이스 마이그레이션
- [ ] 로그 시스템 설정
- [ ] 모니터링 설정
- [ ] 백업 계획 수립
---
이 계획서를 바탕으로 단계별로 구현해나가면 안정적이고 확장 가능한 외부 호출 시스템을 구축할 수 있을 것입니다! 🚀

View File

@ -0,0 +1,411 @@
# 외부 커넥션 관리 시스템 구현 계획서
## 📋 프로젝트 개요
### 목적
- 제어관리 시스템에서 외부 데이터베이스에 접근할 수 있도록 DB 접속 정보를 중앙 관리
- 관리자가 외부 DB 연결 설정을 쉽게 등록, 수정, 삭제, 테스트할 수 있는 시스템 구축
### 주요 기능
- 외부 DB 접속 정보 CRUD 관리
- 다양한 DB 타입 지원 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite)
- 연결 테스트 기능
- 비밀번호 암호화 저장
- 회사별 접속 정보 관리
## 🗄️ 데이터베이스 설계
### 테이블: `external_db_connections`
```sql
CREATE TABLE external_db_connections (
id SERIAL PRIMARY KEY,
connection_name VARCHAR(100) NOT NULL,
description TEXT,
-- DB 연결 정보
db_type VARCHAR(20) NOT NULL, -- mysql, postgresql, oracle, mssql, sqlite
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
database_name VARCHAR(100) NOT NULL,
username VARCHAR(100) NOT NULL,
password TEXT NOT NULL, -- 암호화된 비밀번호
-- 고급 설정
connection_timeout INTEGER DEFAULT 30,
query_timeout INTEGER DEFAULT 60,
max_connections INTEGER DEFAULT 10,
ssl_enabled CHAR(1) DEFAULT 'N',
ssl_cert_path VARCHAR(500),
connection_options JSONB, -- 추가 연결 옵션
-- 관리 정보
company_code VARCHAR(20) DEFAULT '*',
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT NOW(),
created_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT NOW(),
updated_by VARCHAR(50)
);
-- 인덱스
CREATE INDEX idx_external_db_connections_company ON external_db_connections(company_code);
CREATE INDEX idx_external_db_connections_active ON external_db_connections(is_active);
CREATE INDEX idx_external_db_connections_type ON external_db_connections(db_type);
```
### 샘플 데이터
```sql
INSERT INTO external_db_connections (
connection_name, description, db_type, host, port,
database_name, username, password, company_code
) VALUES
(
'영업팀 MySQL',
'영업팀에서 사용하는 고객 데이터베이스',
'mysql',
'sales-db.company.com',
3306,
'sales_db',
'sales_user',
'encrypted_password_here',
'COMP001'
),
(
'재무팀 PostgreSQL',
'재무 데이터 및 회계 정보',
'postgresql',
'finance-db.company.com',
5432,
'finance_db',
'finance_user',
'encrypted_password_here',
'COMP001'
);
```
## 🔧 백엔드 구현
### 1. Prisma 모델 정의
```typescript
// prisma/schema.prisma
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])
}
```
### 2. 타입 정의
```typescript
// backend-node/src/types/externalDbTypes.ts
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<string, unknown>;
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 ConnectionTestRequest {
id?: number;
connection_name?: string;
db_type: string;
host: string;
port: number;
database_name: string;
username: string;
password: string;
connection_timeout?: number;
ssl_enabled?: string;
ssl_cert_path?: string;
}
export interface ConnectionTestResult {
success: boolean;
message: string;
connection_time?: number;
server_version?: string;
error_details?: string;
}
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" },
];
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" },
};
```
### 3. 서비스 계층
```typescript
// backend-node/src/services/externalDbConnectionService.ts
export class ExternalDbConnectionService {
// CRUD 메서드들
static async getConnections(filter: ExternalDbConnectionFilter);
static async getConnectionById(id: number);
static async createConnection(data: ExternalDbConnection);
static async updateConnection(
id: number,
data: Partial<ExternalDbConnection>
);
static async deleteConnection(id: number); // 논리 삭제
static async testConnection(connectionData: ConnectionTestRequest);
// 유틸리티 메서드들
private static encryptPassword(password: string): string;
private static decryptPassword(encryptedPassword: string): string;
private static validateConnectionData(data: ExternalDbConnection): void;
}
```
### 4. API 라우트
```typescript
// backend-node/src/routes/externalDbConnectionRoutes.ts
// GET /api/external-db-connections - 목록 조회
// GET /api/external-db-connections/:id - 상세 조회
// POST /api/external-db-connections - 새 연결 생성
// PUT /api/external-db-connections/:id - 연결 수정
// DELETE /api/external-db-connections/:id - 연결 삭제 (논리삭제)
// POST /api/external-db-connections/test - 연결 테스트
```
## 🎨 프론트엔드 구현
### 1. API 클라이언트
```typescript
// frontend/lib/api/externalDbConnection.ts
export class ExternalDbConnectionAPI {
static async getConnections(filter?: ExternalDbConnectionFilter);
static async getConnectionById(id: number);
static async createConnection(data: ExternalDbConnection);
static async updateConnection(
id: number,
data: Partial<ExternalDbConnection>
);
static async deleteConnection(id: number);
static async testConnection(connectionData: ConnectionTestRequest);
}
```
### 2. 메인 페이지
```typescript
// frontend/app/(main)/admin/external-connections/page.tsx
- 연결 목록 테이블 (리스트형)
- 검색 및 필터링 (DB 타입, 상태, 회사)
- 새 연결 추가 버튼
- 각 행별 편집/삭제/테스트 버튼
```
### 3. 연결 설정 모달
```typescript
// frontend/components/admin/ExternalDbConnectionModal.tsx
- 기본 정보 입력 (연결명, 설명)
- DB 연결 정보 (타입, 호스트, 포트, DB명, 계정)
- 고급 설정 (타임아웃, SSL 등) - 접기/펼치기
- 연결 테스트 버튼
- 저장/취소 버튼
```
### 4. 연결 테스트 다이얼로그
```typescript
// frontend/components/admin/ConnectionTestDialog.tsx
- 테스트 진행 상태 표시
- 연결 결과 (성공/실패, 응답시간, 서버 버전)
- 오류 상세 정보 표시
```
## 🔒 보안 구현
### 1. 비밀번호 암호화
```typescript
// backend-node/src/utils/passwordEncryption.ts
export class PasswordEncryption {
private static readonly ALGORITHM = "aes-256-gcm";
private static readonly SECRET_KEY = process.env.DB_PASSWORD_SECRET;
static encrypt(password: string): string;
static decrypt(encryptedPassword: string): string;
}
```
### 2. 환경 변수 설정
```env
# .env
DB_PASSWORD_SECRET=your-super-secret-encryption-key-here
```
### 3. 접근 권한 제어
- 관리자 권한만 접근 가능
- 회사별 데이터 분리
- API 호출 시 인증 토큰 검증
## 📅 구현 일정
### Phase 1: 데이터베이스 및 백엔드 (2-3일)
- [ ] 데이터베이스 테이블 생성
- [ ] Prisma 모델 정의
- [ ] 타입 정의 작성
- [ ] 서비스 계층 구현
- [ ] API 라우트 구현
- [ ] 비밀번호 암호화 구현
### Phase 2: 프론트엔드 기본 구현 (2-3일)
- [ ] API 클라이언트 작성
- [ ] 메인 페이지 구현 (리스트)
- [ ] 연결 설정 모달 구현
- [ ] 기본 CRUD 기능 구현
### Phase 3: 고급 기능 및 테스트 (1-2일)
- [ ] 연결 테스트 기능 구현
- [ ] 고급 설정 옵션 구현
- [ ] 에러 처리 및 검증 강화
- [ ] UI/UX 개선
### Phase 4: 통합 및 배포 (1일)
- [ ] 메뉴 등록
- [ ] 권한 설정
- [ ] 전체 테스트
- [ ] 문서화 완료
## 🧪 테스트 계획
### 1. 단위 테스트
- 비밀번호 암호화/복호화
- 연결 데이터 검증
- API 엔드포인트 테스트
### 2. 통합 테스트
- 실제 DB 연결 테스트 (다양한 DB 타입)
- 프론트엔드-백엔드 연동 테스트
- 권한 및 보안 테스트
### 3. 사용자 테스트
- 관리자 시나리오 테스트
- UI/UX 사용성 테스트
- 오류 상황 처리 테스트
## 🚀 배포 및 운영
### 1. 환경 설정
- 프로덕션 환경 암호화 키 설정
- DB 접속 권한 최소화
- 로그 모니터링 설정
### 2. 모니터링
- 외부 DB 연결 상태 모니터링
- 연결 풀 사용률 모니터링
- 쿼리 성능 모니터링
### 3. 백업 및 복구
- 연결 설정 정보 백업
- 암호화 키 관리
- 장애 복구 절차
## 📚 참고사항
### 1. 지원 DB 드라이버
- MySQL: `mysql2`
- PostgreSQL: `pg`
- Oracle: `oracledb`
- SQL Server: `mssql`
- SQLite: `sqlite3`
### 2. 연결 풀 관리
- 각 DB별 연결 풀 생성
- 최대 연결 수 제한
- 유휴 연결 정리
### 3. 확장 가능성
- NoSQL DB 지원 (MongoDB, Redis)
- API 연결 지원
- 파일 시스템 연결 지원
---
**작성일**: 2024년 12월 17일
**작성자**: AI Assistant
**버전**: 1.0

View File

@ -0,0 +1,401 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
import {
ExternalCallConfigAPI,
ExternalCallConfig,
ExternalCallConfigFilter,
CALL_TYPE_OPTIONS,
API_TYPE_OPTIONS,
ACTIVE_STATUS_OPTIONS,
} from "@/lib/api/externalCallConfig";
import { ExternalCallConfigModal } from "@/components/admin/ExternalCallConfigModal";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function ExternalCallConfigsPage() {
const [configs, setConfigs] = useState<ExternalCallConfig[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<ExternalCallConfigFilter>({
is_active: "Y",
});
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ExternalCallConfig | null>(null);
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [configToDelete, setConfigToDelete] = useState<ExternalCallConfig | null>(null);
// 외부 호출 설정 목록 조회
const fetchConfigs = async () => {
try {
setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({
...filter,
search: searchQuery.trim() || undefined,
});
if (response.success) {
setConfigs(response.data || []);
} else {
toast.error(response.message || "외부 호출 설정 조회 실패");
}
} catch (error) {
console.error("외부 호출 설정 조회 오류:", error);
toast.error("외부 호출 설정 조회 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
// 초기 로드 및 필터/검색 변경 시 재조회
useEffect(() => {
fetchConfigs();
}, [filter]);
// 검색 실행
const handleSearch = () => {
fetchConfigs();
};
// 검색 입력 시 엔터키 처리
const handleSearchKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 새 설정 추가
const handleAddConfig = () => {
setEditingConfig(null);
setIsModalOpen(true);
};
// 설정 편집
const handleEditConfig = (config: ExternalCallConfig) => {
setEditingConfig(config);
setIsModalOpen(true);
};
// 설정 삭제 확인
const handleDeleteConfig = (config: ExternalCallConfig) => {
setConfigToDelete(config);
setDeleteDialogOpen(true);
};
// 설정 삭제 실행
const confirmDeleteConfig = async () => {
if (!configToDelete?.id) return;
try {
const response = await ExternalCallConfigAPI.deleteConfig(configToDelete.id);
if (response.success) {
toast.success("외부 호출 설정이 삭제되었습니다.");
fetchConfigs();
} else {
toast.error(response.message || "외부 호출 설정 삭제 실패");
}
} catch (error) {
console.error("외부 호출 설정 삭제 오류:", error);
toast.error("외부 호출 설정 삭제 중 오류가 발생했습니다.");
} finally {
setDeleteDialogOpen(false);
setConfigToDelete(null);
}
};
// 설정 테스트
const handleTestConfig = async (config: ExternalCallConfig) => {
if (!config.id) return;
try {
const response = await ExternalCallConfigAPI.testConfig(config.id);
if (response.success && response.data?.success) {
toast.success(`테스트 성공: ${response.data.message}`);
} else {
toast.error(`테스트 실패: ${response.data?.message || response.message}`);
}
} catch (error) {
console.error("외부 호출 설정 테스트 오류:", error);
toast.error("외부 호출 설정 테스트 중 오류가 발생했습니다.");
}
};
// 모달 저장 완료 시 목록 새로고침
const handleModalSave = () => {
setIsModalOpen(false);
setEditingConfig(null);
fetchConfigs();
};
// 호출 타입 라벨 가져오기
const getCallTypeLabel = (callType: string) => {
return CALL_TYPE_OPTIONS.find((option) => option.value === callType)?.label || callType;
};
// API 타입 라벨 가져오기
const getApiTypeLabel = (apiType?: string) => {
if (!apiType) return "";
return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType;
};
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-1">Discord, Slack, .</p>
</div>
<Button onClick={handleAddConfig} className="flex items-center gap-2">
<Plus size={16} />
</Button>
</div>
{/* 검색 및 필터 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter size={18} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 */}
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
<Button onClick={handleSearch} variant="outline">
<Search size={16} />
</Button>
</div>
{/* 필터 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium"> </label>
<Select
value={filter.call_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
call_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm font-medium">API </label>
<Select
value={filter.api_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
api_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Select
value={filter.is_active || "Y"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
is_active: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 설정 목록 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{loading ? (
// 로딩 상태
<div className="py-8 text-center">
<div className="text-muted-foreground"> ...</div>
</div>
) : configs.length === 0 ? (
// 빈 상태
<div className="py-12 text-center">
<div className="text-muted-foreground">
<Plus size={48} className="mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium"> .</p>
<p className="text-sm"> .</p>
</div>
</div>
) : (
// 설정 테이블 목록
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead>API </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow key={config.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{config.config_name}</TableCell>
<TableCell>
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
</TableCell>
<TableCell>
{config.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
<div className="max-w-xs">
{config.description ? (
<span className="text-muted-foreground block truncate text-sm" title={config.description}>
{config.description}
</span>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
{config.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<div className="flex justify-center gap-1">
<Button size="sm" variant="outline" onClick={() => handleTestConfig(config)} title="테스트">
<TestTube size={14} />
</Button>
<Button size="sm" variant="outline" onClick={() => handleEditConfig(config)} title="편집">
<Edit size={14} />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteConfig(config)}
className="text-destructive hover:text-destructive"
title="삭제"
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 외부 호출 설정 모달 */}
<ExternalCallConfigModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleModalSave}
editingConfig={editingConfig}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{configToDelete?.config_name}" ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteConfig} className="bg-destructive hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,522 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import {
ExternalCallConfigAPI,
ExternalCallConfig,
CALL_TYPE_OPTIONS,
API_TYPE_OPTIONS,
ACTIVE_STATUS_OPTIONS,
} from "@/lib/api/externalCallConfig";
interface ExternalCallConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
editingConfig?: ExternalCallConfig | null;
}
export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig }: ExternalCallConfigModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<Partial<ExternalCallConfig>>({
config_name: "",
call_type: "rest-api",
api_type: "discord",
description: "",
is_active: "Y",
config_data: {},
});
// Discord 설정 상태
const [discordSettings, setDiscordSettings] = useState({
webhookUrl: "",
username: "",
avatarUrl: "",
});
// Slack 설정 상태
const [slackSettings, setSlackSettings] = useState({
webhookUrl: "",
channel: "",
username: "",
});
// 카카오톡 설정 상태
const [kakaoSettings, setKakaoSettings] = useState({
accessToken: "",
templateId: "",
});
// 일반 API 설정 상태
const [genericSettings, setGenericSettings] = useState({
url: "",
method: "POST",
headers: "{}",
timeout: "30000",
});
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen && editingConfig) {
setFormData({
config_name: editingConfig.config_name,
call_type: editingConfig.call_type,
api_type: editingConfig.api_type,
description: editingConfig.description || "",
is_active: editingConfig.is_active || "Y",
config_data: editingConfig.config_data,
});
// API 타입별 설정 데이터 로드
const configData = editingConfig.config_data as any;
if (editingConfig.api_type === "discord") {
setDiscordSettings({
webhookUrl: configData.webhookUrl || "",
username: configData.username || "",
avatarUrl: configData.avatarUrl || "",
});
} else if (editingConfig.api_type === "slack") {
setSlackSettings({
webhookUrl: configData.webhookUrl || "",
channel: configData.channel || "",
username: configData.username || "",
});
} else if (editingConfig.api_type === "kakao-talk") {
setKakaoSettings({
accessToken: configData.accessToken || "",
templateId: configData.templateId || "",
});
} else if (editingConfig.api_type === "generic") {
setGenericSettings({
url: configData.url || "",
method: configData.method || "POST",
headers: JSON.stringify(configData.headers || {}, null, 2),
timeout: String(configData.timeout || 30000),
});
}
} else if (isOpen && !editingConfig) {
// 새 설정 추가 시 초기화
setFormData({
config_name: "",
call_type: "rest-api",
api_type: "discord",
description: "",
is_active: "Y",
config_data: {},
});
setDiscordSettings({ webhookUrl: "", username: "", avatarUrl: "" });
setSlackSettings({ webhookUrl: "", channel: "", username: "" });
setKakaoSettings({ accessToken: "", templateId: "" });
setGenericSettings({ url: "", method: "POST", headers: "{}", timeout: "30000" });
}
}, [isOpen, editingConfig]);
// 호출 타입 변경 시 API 타입 초기화
const handleCallTypeChange = (callType: string) => {
setFormData((prev) => ({
...prev,
call_type: callType,
api_type: callType === "rest-api" ? "discord" : undefined,
}));
};
// config_data 생성
const generateConfigData = () => {
const { api_type } = formData;
switch (api_type) {
case "discord":
return {
webhookUrl: discordSettings.webhookUrl,
username: discordSettings.username || "ERP 시스템",
avatarUrl: discordSettings.avatarUrl || null,
};
case "slack":
return {
webhookUrl: slackSettings.webhookUrl,
channel: slackSettings.channel,
username: slackSettings.username || "ERP Bot",
};
case "kakao-talk":
return {
accessToken: kakaoSettings.accessToken,
templateId: kakaoSettings.templateId,
};
case "generic":
try {
return {
url: genericSettings.url,
method: genericSettings.method,
headers: JSON.parse(genericSettings.headers),
timeout: parseInt(genericSettings.timeout),
};
} catch (error) {
throw new Error("헤더 JSON 형식이 올바르지 않습니다.");
}
default:
return {};
}
};
// 폼 검증
const validateForm = () => {
if (!formData.config_name?.trim()) {
toast.error("설정 이름을 입력해주세요.");
return false;
}
if (!formData.call_type) {
toast.error("호출 타입을 선택해주세요.");
return false;
}
// REST API인 경우 API 타입별 검증
if (formData.call_type === "rest-api") {
switch (formData.api_type) {
case "discord":
if (!discordSettings.webhookUrl.trim()) {
toast.error("Discord 웹훅 URL을 입력해주세요.");
return false;
}
break;
case "slack":
if (!slackSettings.webhookUrl.trim()) {
toast.error("Slack 웹훅 URL을 입력해주세요.");
return false;
}
break;
case "kakao-talk":
if (!kakaoSettings.accessToken.trim()) {
toast.error("카카오톡 액세스 토큰을 입력해주세요.");
return false;
}
break;
case "generic":
if (!genericSettings.url.trim()) {
toast.error("API URL을 입력해주세요.");
return false;
}
try {
JSON.parse(genericSettings.headers);
} catch {
toast.error("헤더 JSON 형식이 올바르지 않습니다.");
return false;
}
break;
}
}
return true;
};
// 저장 처리
const handleSave = async () => {
if (!validateForm()) return;
try {
setLoading(true);
const configData = generateConfigData();
const saveData = {
...formData,
config_data: configData,
};
let response;
if (editingConfig?.id) {
response = await ExternalCallConfigAPI.updateConfig(editingConfig.id, saveData);
} else {
response = await ExternalCallConfigAPI.createConfig(saveData as any);
}
if (response.success) {
toast.success(editingConfig ? "외부 호출 설정이 수정되었습니다." : "외부 호출 설정이 생성되었습니다.");
onSave();
} else {
toast.error(response.message || "저장 실패");
}
} catch (error) {
console.error("외부 호출 설정 저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<div>
<Label htmlFor="config_name"> *</Label>
<Input
id="config_name"
value={formData.config_name}
onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
placeholder="예: 개발팀 Discord"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="이 외부 호출 설정에 대한 설명을 입력하세요."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="call_type"> *</Label>
<Select value={formData.call_type} onValueChange={handleCallTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="is_active"></Label>
<Select
value={formData.is_active}
onValueChange={(value) => setFormData((prev) => ({ ...prev, is_active: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* REST API 설정 */}
{formData.call_type === "rest-api" && (
<div className="space-y-4">
<div>
<Label htmlFor="api_type">API *</Label>
<Select
value={formData.api_type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, api_type: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Discord 설정 */}
{formData.api_type === "discord" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Discord </h4>
<div>
<Label htmlFor="discord_webhook"> URL *</Label>
<Input
id="discord_webhook"
value={discordSettings.webhookUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div>
<Label htmlFor="discord_username"></Label>
<Input
id="discord_username"
value={discordSettings.username}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP 시스템"
/>
</div>
<div>
<Label htmlFor="discord_avatar"> URL</Label>
<Input
id="discord_avatar"
value={discordSettings.avatarUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, avatarUrl: e.target.value }))}
placeholder="https://example.com/avatar.png"
/>
</div>
</div>
)}
{/* Slack 설정 */}
{formData.api_type === "slack" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Slack </h4>
<div>
<Label htmlFor="slack_webhook"> URL *</Label>
<Input
id="slack_webhook"
value={slackSettings.webhookUrl}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<div>
<Label htmlFor="slack_channel"></Label>
<Input
id="slack_channel"
value={slackSettings.channel}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, channel: e.target.value }))}
placeholder="#general"
/>
</div>
<div>
<Label htmlFor="slack_username"></Label>
<Input
id="slack_username"
value={slackSettings.username}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP Bot"
/>
</div>
</div>
)}
{/* 카카오톡 설정 */}
{formData.api_type === "kakao-talk" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> </h4>
<div>
<Label htmlFor="kakao_token"> *</Label>
<Input
id="kakao_token"
type="password"
value={kakaoSettings.accessToken}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, accessToken: e.target.value }))}
placeholder="카카오 API 액세스 토큰"
/>
</div>
<div>
<Label htmlFor="kakao_template">릿 ID</Label>
<Input
id="kakao_template"
value={kakaoSettings.templateId}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, templateId: e.target.value }))}
placeholder="template_001"
/>
</div>
</div>
)}
{/* 일반 API 설정 */}
{formData.api_type === "generic" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> API </h4>
<div>
<Label htmlFor="generic_url">API URL *</Label>
<Input
id="generic_url"
value={genericSettings.url}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="generic_method">HTTP </Label>
<Select
value={genericSettings.method}
onValueChange={(value) => setGenericSettings((prev) => ({ ...prev, method: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="generic_timeout"> (ms)</Label>
<Input
id="generic_timeout"
type="number"
value={genericSettings.timeout}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, timeout: e.target.value }))}
placeholder="30000"
/>
</div>
</div>
<div>
<Label htmlFor="generic_headers"> (JSON)</Label>
<Textarea
id="generic_headers"
value={genericSettings.headers}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, headers: e.target.value }))}
placeholder='{"Content-Type": "application/json"}'
rows={3}
/>
</div>
</div>
)}
</div>
)}
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
{formData.call_type !== "rest-api" && (
<div className="text-muted-foreground rounded-lg border p-4 text-center">
{formData.call_type} .
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,432 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Database, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import {
ExternalDbConnectionAPI,
ExternalDbConnection,
DB_TYPE_OPTIONS,
DB_TYPE_DEFAULTS,
} from "@/lib/api/externalDbConnection";
interface ExternalDbConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
editingConnection?: ExternalDbConnection | null;
}
export function ExternalDbConnectionModal({
isOpen,
onClose,
onSave,
editingConnection,
}: ExternalDbConnectionModalProps) {
const [formData, setFormData] = useState<Partial<ExternalDbConnection>>({
connection_name: "",
description: "",
db_type: "mysql",
host: "",
port: 3306,
database_name: "",
username: "",
password: "",
connection_timeout: 30,
query_timeout: 60,
max_connections: 10,
ssl_enabled: "N",
ssl_cert_path: "",
company_code: "*",
is_active: "Y",
});
const [loading, setLoading] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (editingConnection) {
setFormData({
...editingConnection,
password: "", // 보안상 비밀번호는 빈 값으로 시작
});
setShowAdvancedSettings(true); // 편집 시 고급 설정 펼치기
} else {
// 새 연결 생성 시 기본값 설정
setFormData({
connection_name: "",
description: "",
db_type: "mysql",
host: "",
port: 3306,
database_name: "",
username: "",
password: "",
connection_timeout: 30,
query_timeout: 60,
max_connections: 10,
ssl_enabled: "N",
ssl_cert_path: "",
company_code: "*",
is_active: "Y",
});
setShowAdvancedSettings(false);
}
setShowPassword(false);
}
}, [isOpen, editingConnection]);
// DB 타입 변경 시 기본 포트 설정
const handleDbTypeChange = (dbType: string) => {
const defaultPort = DB_TYPE_DEFAULTS[dbType as keyof typeof DB_TYPE_DEFAULTS]?.port || 3306;
setFormData((prev) => ({
...prev,
db_type: dbType as ExternalDbConnection["db_type"],
port: defaultPort,
}));
};
// 폼 데이터 변경 핸들러
const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// 저장
const handleSave = async () => {
// 필수 필드 검증
if (
!formData.connection_name ||
!formData.db_type ||
!formData.host ||
!formData.port ||
!formData.database_name ||
!formData.username
) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
// 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지
if (editingConnection && !formData.password) {
formData.password = "***ENCRYPTED***"; // 서버에서 기존 비밀번호 유지하도록 표시
} else if (!editingConnection && !formData.password) {
toast.error("새 연결 생성 시 비밀번호는 필수입니다.");
return;
}
try {
setLoading(true);
const connectionData = {
...formData,
port: Number(formData.port),
connection_timeout: Number(formData.connection_timeout),
query_timeout: Number(formData.query_timeout),
max_connections: Number(formData.max_connections),
} as ExternalDbConnection;
let response;
if (editingConnection?.id) {
response = await ExternalDbConnectionAPI.updateConnection(editingConnection.id, connectionData);
} else {
response = await ExternalDbConnectionAPI.createConnection(connectionData);
}
if (response.success) {
toast.success(editingConnection ? "연결이 수정되었습니다." : "연결이 생성되었습니다.");
onSave();
} else {
toast.error(response.message || "저장 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
// 취소
const handleCancel = () => {
onClose();
};
// 저장 버튼 비활성화 조건
const isSaveDisabled = () => {
return (
loading ||
!formData.connection_name ||
!formData.host ||
!formData.port ||
!formData.database_name ||
!formData.username ||
(!editingConnection && !formData.password)
);
};
return (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="connection_name"> *</Label>
<Input
id="connection_name"
value={formData.connection_name || ""}
onChange={(e) => handleInputChange("connection_name", e.target.value)}
placeholder="예: 영업팀 MySQL"
/>
</div>
<div>
<Label htmlFor="db_type">DB *</Label>
<Select value={formData.db_type || "mysql"} onValueChange={handleDbTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="연결에 대한 설명을 입력하세요"
rows={2}
/>
</div>
</div>
{/* 연결 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host || ""}
onChange={(e) => handleInputChange("host", e.target.value)}
placeholder="예: localhost, db.company.com"
/>
</div>
<div>
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port || ""}
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
min={1}
max={65535}
/>
</div>
</div>
<div>
<Label htmlFor="database_name"> *</Label>
<Input
id="database_name"
value={formData.database_name || ""}
onChange={(e) => handleInputChange("database_name", e.target.value)}
placeholder="예: sales_db, production"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username || ""}
onChange={(e) => handleInputChange("username", e.target.value)}
placeholder="DB 사용자명"
/>
</div>
<div>
<Label htmlFor="password"> {!editingConnection && "*"}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password || ""}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder={editingConnection ? "변경하려면 입력하세요" : "DB 비밀번호"}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-8 w-8 -translate-y-1/2 p-0"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
</div>
</div>
</div>
</div>
{/* 고급 설정 (접기/펼치기) */}
<Collapsible open={showAdvancedSettings} onOpenChange={setShowAdvancedSettings}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex w-full justify-between p-0">
<h3 className="text-lg font-medium"> </h3>
{showAdvancedSettings ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="connection_timeout"> ()</Label>
<Input
id="connection_timeout"
type="number"
value={formData.connection_timeout || 30}
onChange={(e) => handleInputChange("connection_timeout", parseInt(e.target.value) || 30)}
min={1}
max={300}
/>
</div>
<div>
<Label htmlFor="query_timeout"> ()</Label>
<Input
id="query_timeout"
type="number"
value={formData.query_timeout || 60}
onChange={(e) => handleInputChange("query_timeout", parseInt(e.target.value) || 60)}
min={1}
max={3600}
/>
</div>
<div>
<Label htmlFor="max_connections"> </Label>
<Input
id="max_connections"
type="number"
value={formData.max_connections || 10}
onChange={(e) => handleInputChange("max_connections", parseInt(e.target.value) || 10)}
min={1}
max={100}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="ssl_enabled"
checked={formData.ssl_enabled === "Y"}
onCheckedChange={(checked) => handleInputChange("ssl_enabled", checked ? "Y" : "N")}
/>
<Label htmlFor="ssl_enabled">SSL </Label>
</div>
{formData.ssl_enabled === "Y" && (
<div>
<Label htmlFor="ssl_cert_path">SSL </Label>
<Input
id="ssl_cert_path"
value={formData.ssl_cert_path || ""}
onChange={(e) => handleInputChange("ssl_cert_path", e.target.value)}
placeholder="/path/to/certificate.pem"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company_code"> </Label>
<Input
id="company_code"
value={formData.company_code || "*"}
onChange={(e) => handleInputChange("company_code", e.target.value)}
placeholder="회사 코드 (기본: *)"
/>
</div>
<div>
<Label htmlFor="is_active"></Label>
<Select
value={formData.is_active || "Y"}
onValueChange={(value) => handleInputChange("is_active", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* 편집 모드일 때 정보 표시 */}
{editingConnection && (
<div className="bg-muted rounded-lg p-4 text-sm">
<div className="mb-2 flex items-center gap-2">
<Badge variant="secondary"> </Badge>
<span className="text-muted-foreground">
:{" "}
{editingConnection.created_date ? new Date(editingConnection.created_date).toLocaleString() : "-"}
</span>
</div>
<p className="text-muted-foreground">
. .
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={isSaveDisabled()}>
{loading ? "저장 중..." : editingConnection ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -12,6 +12,7 @@ import {
SimpleKeySettings,
DataSaveSettings,
ExternalCallSettings,
SimpleExternalCallSettings,
ConnectionSetupModalProps,
} from "@/types/connectionTypes";
import { isConditionalConnection } from "@/utils/connectionUtils";
@ -20,7 +21,7 @@ import { ConditionalSettings } from "./condition/ConditionalSettings";
import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
import { ExternalCallSettings as ExternalCallSettingsComponent } from "./connection/ExternalCallSettings";
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
import toast from "react-hot-toast";
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
@ -47,12 +48,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
actions: [],
});
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
callType: "rest-api",
apiUrl: "",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
const [externalCallSettings, setExternalCallSettings] = useState<SimpleExternalCallSettings>({
message: "",
});
// 테이블 및 컬럼 선택을 위한 상태들
@ -87,13 +84,17 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
} else if (connectionType === "data-save") {
// data-save 설정 로드 - 안전하게 처리 (다양한 구조 지원)
let actionsData: Record<string, unknown>[] = [];
const settingsRecord = settings as Record<string, unknown>;
if (Array.isArray((settings as any).actions)) {
if (Array.isArray(settingsRecord.actions)) {
// 직접 actions 배열이 있는 경우
actionsData = (settings as any).actions;
} else if ((settings as any).plan && Array.isArray((settings as any).plan.actions)) {
actionsData = settingsRecord.actions as Record<string, unknown>[];
} else if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
// plan 객체 안에 actions가 있는 경우
actionsData = (settings as any).plan.actions;
const planRecord = settingsRecord.plan as Record<string, unknown>;
if (Array.isArray(planRecord.actions)) {
actionsData = planRecord.actions as Record<string, unknown>[];
}
} else if (Array.isArray(settings)) {
// settings 자체가 actions 배열인 경우
actionsData = settings as Record<string, unknown>[];
@ -130,26 +131,34 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
});
// control 설정도 로드 (전체 실행 조건)
if (
(settings as any).control &&
(settings as any).control.conditionTree &&
Array.isArray((settings as any).control.conditionTree)
) {
const conditionTree = (settings as any).control.conditionTree as ConditionNode[];
setConditions(
conditionTree.map((condition) => ({
...condition,
operator: condition.operator || "=", // 기본값 보장
})),
);
if (settingsRecord.control && typeof settingsRecord.control === "object" && settingsRecord.control !== null) {
const controlRecord = settingsRecord.control as Record<string, unknown>;
if (Array.isArray(controlRecord.conditionTree)) {
const conditionTree = controlRecord.conditionTree as ConditionNode[];
setConditions(
conditionTree.map((condition) => ({
...condition,
operator: condition.operator || "=", // 기본값 보장
})),
);
}
}
} else if (connectionType === "external-call") {
// 외부 호출 설정은 plan에서 로드
const settingsRecord = settings as Record<string, unknown>;
let externalCallData: Record<string, unknown> = {};
if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
const planRecord = settingsRecord.plan as Record<string, unknown>;
if (planRecord.externalCall && typeof planRecord.externalCall === "object") {
externalCallData = planRecord.externalCall as Record<string, unknown>;
}
}
setExternalCallSettings({
callType: (settings.callType as "rest-api" | "webhook") || "rest-api",
apiUrl: (settings.apiUrl as string) || "",
httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST",
headers: (settings.headers as string) || "{}",
bodyTemplate: (settings.bodyTemplate as string) || "{}",
configId: (externalCallData.configId as number) || undefined,
configName: (externalCallData.configName as string) || undefined,
message: (externalCallData.message as string) || "",
});
}
},
@ -326,6 +335,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
// 연결 종류별 설정을 준비
let settings = {};
let plan = {}; // plan 변수 선언
switch (config.connectionType) {
case "simple-key":
@ -335,7 +345,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
// 외부 호출은 plan에 저장
plan = {
externalCall: {
configId: externalCallSettings.configId,
configName: externalCallSettings.configName,
message: externalCallSettings.message,
},
};
settings = {}; // 외부 호출은 settings에 저장하지 않음
break;
}
@ -409,6 +427,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
settings: {
...settings,
...conditionalSettings, // 조건부 연결 설정 추가
...plan, // 외부 호출 plan 추가
},
};
@ -457,6 +476,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
availableTables={availableTables}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={selectedFromTable}
toTableName={selectedToTable}
tableColumnsCache={tableColumnsCache}
/>
);
@ -509,22 +530,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
case "external-call":
// 외부 호출: 호출 타입과 필수 설정이 있어야 함
const hasCallType = !!externalCallSettings.callType;
if (!hasCallType) return true;
switch (externalCallSettings.callType) {
case "rest-api":
return !externalCallSettings.apiUrl?.trim();
case "kakao-talk":
return !externalCallSettings.kakaoAccessToken?.trim() || !externalCallSettings.bodyTemplate?.trim();
case "email":
return !externalCallSettings.apiUrl?.trim(); // 이메일 서버 URL 필요
case "webhook":
return !externalCallSettings.apiUrl?.trim();
default:
return true;
}
// 외부 호출: 설정 ID와 메시지가 있어야 함
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
default:
return false;

View File

@ -3,7 +3,15 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Trash2 } from "lucide-react";
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes";
@ -16,6 +24,9 @@ interface ActionConditionRendererProps {
settings: DataSaveSettings;
onSettingsChange: (settings: DataSaveSettings) => void;
fromTableColumns: ColumnInfo[];
toTableColumns: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
}
@ -26,6 +37,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
settings,
onSettingsChange,
fromTableColumns,
toTableColumns,
fromTableName,
toTableName,
getActionCurrentGroupLevel,
}) => {
const removeConditionGroup = (groupId: string) => {
@ -53,7 +67,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
};
const renderConditionValue = () => {
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
// 선택된 테이블 타입에 따라 컬럼 찾기
const targetColumns = condition.tableType === "from" ? fromTableColumns : toTableColumns;
const selectedColumn = targetColumns.find((col) => col.columnName === condition.field);
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
const inputType = getInputTypeForDataType(dataType);
@ -167,16 +183,46 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
}}
>
<Select value={condition.field || ""} onValueChange={(value) => updateCondition("field", value)}>
<SelectTrigger className="h-6 flex-1 text-xs">
<SelectValue placeholder="필드" />
{/* 1단계: 테이블 선택 */}
<Select
value={condition.tableType || ""}
onValueChange={(value: "from" | "to") => {
updateCondition("tableType", value);
// 테이블이 변경되면 필드 초기화
updateCondition("field", "");
}}
>
<SelectTrigger className="h-6 w-20 text-xs">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
{fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
{fromTableColumns.length > 0 && <SelectItem value="from">{fromTableName || "From 테이블"}</SelectItem>}
{toTableColumns.length > 0 && <SelectItem value="to">{toTableName || "To 테이블"}</SelectItem>}
</SelectContent>
</Select>
{/* 2단계: 선택된 테이블의 컬럼 선택 */}
<Select
value={condition.field || ""}
onValueChange={(value) => updateCondition("field", value)}
disabled={!condition.tableType}
>
<SelectTrigger className="h-6 flex-1 text-xs">
<SelectValue placeholder={condition.tableType ? "컬럼" : "테이블을 먼저 선택하세요"} />
</SelectTrigger>
<SelectContent>
{condition.tableType === "from" &&
fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
{condition.tableType === "to" &&
toTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select

View File

@ -15,6 +15,9 @@ interface ActionConditionsSectionProps {
settings: DataSaveSettings;
onSettingsChange: (settings: DataSaveSettings) => void;
fromTableColumns: ColumnInfo[];
toTableColumns: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
}
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
@ -23,6 +26,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
settings,
onSettingsChange,
fromTableColumns,
toTableColumns,
fromTableName,
toTableName,
}) => {
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
@ -39,6 +45,7 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
operator: "=" as const,
value: "",
dataType: "string",
tableType: undefined, // 사용자가 직접 선택하도록
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
...(currentConditions.length > 0 &&
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
@ -119,6 +126,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
/>
</div>

View File

@ -18,6 +18,8 @@ interface DataSaveSettingsProps {
availableTables: TableInfo[];
fromTableColumns: ColumnInfo[];
toTableColumns: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
}
@ -27,6 +29,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
availableTables,
fromTableColumns,
toTableColumns,
fromTableName,
toTableName,
tableColumnsCache,
}) => {
const addAction = () => {
@ -126,6 +130,9 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
{/* 데이터 분할 설정 */}

View File

@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ExternalCallAPI } from "@/lib/api/externalCall";
import { toast } from "sonner";
import { Globe } from "lucide-react";
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
@ -13,12 +16,128 @@ interface ExternalCallSettingsProps {
onSettingsChange: (settings: ExternalCallSettingsType) => void;
}
const handleTestExternalCall = async (settings: ExternalCallSettingsType) => {
let loadingToastId: string | number | undefined;
try {
// 설정을 백엔드 형식으로 변환
const backendSettings: Record<string, unknown> = {
callType: settings.callType,
timeout: 10000, // 10초 타임아웃 설정
};
if (settings.callType === "rest-api") {
backendSettings.apiType = settings.apiType;
switch (settings.apiType) {
case "slack":
backendSettings.webhookUrl = settings.slackWebhookUrl;
backendSettings.message =
settings.slackMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
backendSettings.channel = settings.slackChannel;
break;
case "kakao-talk":
backendSettings.accessToken = settings.kakaoAccessToken;
backendSettings.message =
settings.kakaoMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
break;
case "discord":
backendSettings.webhookUrl = settings.discordWebhookUrl;
backendSettings.message =
settings.discordMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
backendSettings.username = settings.discordUsername;
break;
case "generic":
default:
backendSettings.url = settings.apiUrl;
backendSettings.method = settings.httpMethod || "POST";
try {
backendSettings.headers = settings.headers ? JSON.parse(settings.headers) : {};
} catch (error) {
console.warn("Headers JSON 파싱 실패, 기본값 사용:", error);
backendSettings.headers = {};
}
backendSettings.body = settings.bodyTemplate || "{}";
break;
}
}
// 로딩 토스트 시작
loadingToastId = toast.loading("외부 호출 테스트 중...", {
duration: 12000, // 12초 후 자동으로 사라짐
});
// 타임아웃을 위한 Promise.race 사용
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("테스트 요청이 10초 내에 완료되지 않았습니다.")), 10000);
});
const testPromise = ExternalCallAPI.testExternalCall({
settings: backendSettings,
templateData: {
recordCount: 5,
tableName: "test_table",
timestamp: new Date().toISOString(),
message: "데이터플로우 테스트 실행",
},
});
const result = await Promise.race([testPromise, timeoutPromise]);
// 로딩 토스트 제거
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
if (result.success && result.result?.success) {
toast.success("외부 호출 테스트 성공!", {
description: `응답 시간: ${result.result.executionTime}ms`,
duration: 4000,
});
} else {
toast.error("외부 호출 테스트 실패", {
description: result.result?.error || result.error || "알 수 없는 오류",
duration: 6000,
});
}
} catch (error) {
console.error("테스트 실행 중 오류:", error);
// 로딩 토스트 제거
if (loadingToastId) {
toast.dismiss(loadingToastId);
}
if (error instanceof Error) {
toast.error("테스트 실행 중 오류가 발생했습니다.", {
description: error.message,
duration: 6000,
});
} else {
toast.error("테스트 실행 중 알 수 없는 오류가 발생했습니다.", {
duration: 6000,
});
}
}
};
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
return (
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleTestExternalCall(settings)}
className="h-7 px-2 text-xs"
>
</Button>
</div>
<div className="space-y-3">
<div>
@ -27,7 +146,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
</Label>
<Select
value={settings.callType}
onValueChange={(value: "rest-api" | "email" | "webhook" | "kakao-talk" | "ftp" | "queue") =>
onValueChange={(value: "rest-api" | "email" | "ftp" | "queue") =>
onSettingsChange({ ...settings, callType: value })
}
>
@ -36,9 +155,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
</SelectTrigger>
<SelectContent>
<SelectItem value="rest-api">REST API </SelectItem>
<SelectItem value="kakao-talk"> </SelectItem>
<SelectItem value="email"> </SelectItem>
<SelectItem value="webhook"></SelectItem>
<SelectItem value="ftp">FTP </SelectItem>
<SelectItem value="queue"> </SelectItem>
</SelectContent>
@ -48,124 +165,211 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
{settings.callType === "rest-api" && (
<>
<div>
<Label htmlFor="apiUrl" className="text-sm">
API URL
<Label htmlFor="apiType" className="text-sm">
API
</Label>
<Input
id="apiUrl"
value={settings.apiUrl}
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
placeholder="https://api.example.com/webhook"
className="text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="httpMethod" className="text-sm">
HTTP Method
</Label>
<Select
value={settings.httpMethod}
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
onSettingsChange({ ...settings, httpMethod: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="headers" className="text-sm">
Headers
</Label>
<Textarea
id="headers"
value={settings.headers}
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
placeholder="{}"
rows={1}
className="text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="bodyTemplate" className="text-sm">
Body Template
</Label>
<Textarea
id="bodyTemplate"
value={settings.bodyTemplate}
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
placeholder="{}"
rows={2}
className="text-sm"
/>
</div>
</>
)}
{settings.callType === "kakao-talk" && (
<>
<div>
<Label htmlFor="kakaoAccessToken" className="text-sm">
<span className="text-red-500">*</span>
</Label>
<Input
id="kakaoAccessToken"
type="password"
value={settings.kakaoAccessToken || ""}
onChange={(e) =>
onSettingsChange({
...settings,
kakaoAccessToken: e.target.value,
})
<Select
value={settings.apiType || "generic"}
onValueChange={(value: "slack" | "kakao-talk" | "discord" | "generic") =>
onSettingsChange({ ...settings, apiType: value })
}
placeholder="카카오 개발자 센터에서 발급받은 토큰"
className="text-sm"
/>
<p className="mt-1 text-xs text-gray-600">
💡{" "}
<a
href="https://developers.kakao.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
</a>
</p>
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="slack"></SelectItem>
<SelectItem value="kakao-talk"></SelectItem>
<SelectItem value="discord"></SelectItem>
<SelectItem value="generic"> ( API)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="kakaoMessage" className="text-sm">
릿 <span className="text-red-500">*</span>
</Label>
<Textarea
id="kakaoMessage"
value={settings.bodyTemplate || ""}
onChange={(e) =>
onSettingsChange({
...settings,
bodyTemplate: e.target.value,
})
}
placeholder="안녕하세요! {{customer_name}}님의 주문({{order_id}})이 처리되었습니다."
rows={3}
className="text-sm"
/>
<p className="mt-1 text-xs text-gray-600">
💡 {"{{"} {"}"} (: {"{{"} user_name {"}"}, {"{{"} amount{" "}
{"}"})
</p>
</div>
{/* 슬랙 설정 */}
{settings.apiType === "slack" && (
<>
<div>
<Label htmlFor="slackWebhookUrl" className="text-sm">
URL
</Label>
<Input
id="slackWebhookUrl"
value={settings.slackWebhookUrl || ""}
onChange={(e) => onSettingsChange({ ...settings, slackWebhookUrl: e.target.value })}
placeholder="https://hooks.slack.com/services/..."
className="text-sm"
/>
</div>
<div>
<Label htmlFor="slackChannel" className="text-sm">
</Label>
<Input
id="slackChannel"
value={settings.slackChannel || ""}
onChange={(e) => onSettingsChange({ ...settings, slackChannel: e.target.value })}
placeholder="#general"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="slackMessage" className="text-sm">
릿
</Label>
<Textarea
id="slackMessage"
value={settings.slackMessage || ""}
onChange={(e) => onSettingsChange({ ...settings, slackMessage: e.target.value })}
placeholder="데이터 처리가 완료되었습니다. 총 {{recordCount}}건이 처리되었습니다."
rows={2}
className="text-sm"
/>
</div>
</>
)}
{/* 카카오톡 설정 */}
{settings.apiType === "kakao-talk" && (
<>
<div>
<Label htmlFor="kakaoAccessToken" className="text-sm">
<span className="text-red-500">*</span>
</Label>
<Input
id="kakaoAccessToken"
type="password"
value={settings.kakaoAccessToken || ""}
onChange={(e) => onSettingsChange({ ...settings, kakaoAccessToken: e.target.value })}
placeholder="카카오 API 액세스 토큰을 입력하세요"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="kakaoMessage" className="text-sm">
</Label>
<Textarea
id="kakaoMessage"
value={settings.kakaoMessage || ""}
onChange={(e) => onSettingsChange({ ...settings, kakaoMessage: e.target.value })}
placeholder="데이터 처리 완료! 총 {{recordCount}}건 처리되었습니다."
rows={2}
className="text-sm"
/>
</div>
</>
)}
{/* 디스코드 설정 */}
{settings.apiType === "discord" && (
<>
<div>
<Label htmlFor="discordWebhookUrl" className="text-sm">
URL <span className="text-red-500">*</span>
</Label>
<Input
id="discordWebhookUrl"
value={settings.discordWebhookUrl || ""}
onChange={(e) => onSettingsChange({ ...settings, discordWebhookUrl: e.target.value })}
placeholder="https://discord.com/api/webhooks/..."
className="text-sm"
/>
</div>
<div>
<Label htmlFor="discordUsername" className="text-sm">
</Label>
<Input
id="discordUsername"
value={settings.discordUsername || ""}
onChange={(e) => onSettingsChange({ ...settings, discordUsername: e.target.value })}
placeholder="ERP 시스템"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="discordMessage" className="text-sm">
</Label>
<Textarea
id="discordMessage"
value={settings.discordMessage || ""}
onChange={(e) => onSettingsChange({ ...settings, discordMessage: e.target.value })}
placeholder="데이터 처리가 완료되었습니다! 🎉"
rows={2}
className="text-sm"
/>
</div>
</>
)}
{/* 일반 API 설정 */}
{settings.apiType === "generic" && (
<>
<div>
<Label htmlFor="apiUrl" className="text-sm">
API URL
</Label>
<Input
id="apiUrl"
value={settings.apiUrl || ""}
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
placeholder="https://api.example.com/webhook"
className="text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="httpMethod" className="text-sm">
HTTP Method
</Label>
<Select
value={settings.httpMethod}
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
onSettingsChange({ ...settings, httpMethod: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="headers" className="text-sm">
Headers
</Label>
<Textarea
id="headers"
value={settings.headers}
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
placeholder="{}"
rows={1}
className="text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="bodyTemplate" className="text-sm">
Body Template
</Label>
<Textarea
id="bodyTemplate"
value={settings.bodyTemplate}
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
placeholder="{}"
rows={2}
className="text-sm"
/>
</div>
</>
)}
</>
)}
</div>

View File

@ -0,0 +1,275 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ExternalCallConfigAPI, ExternalCallConfig } from "@/lib/api/externalCallConfig";
import { ExternalCallAPI } from "@/lib/api/externalCall";
import { toast } from "sonner";
import { Globe, TestTube, RefreshCw } from "lucide-react";
// 단순화된 외부 호출 설정 타입
export interface SimpleExternalCallSettings {
configId?: number; // 선택된 외부 호출 설정 ID
configName?: string; // 설정 이름 (표시용)
message: string; // 메시지 템플릿
}
interface SimpleExternalCallSettingsProps {
settings: SimpleExternalCallSettings;
onSettingsChange: (settings: SimpleExternalCallSettings) => void;
}
export function SimpleExternalCallSettings({ settings, onSettingsChange }: SimpleExternalCallSettingsProps) {
const [availableConfigs, setAvailableConfigs] = useState<ExternalCallConfig[]>([]);
const [loading, setLoading] = useState(true);
const [testLoading, setTestLoading] = useState(false);
// 사용 가능한 외부 호출 설정 목록 조회
const fetchAvailableConfigs = async () => {
try {
setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({
is_active: "Y", // 활성화된 설정만 조회
});
if (response.success) {
setAvailableConfigs(response.data || []);
} else {
toast.error("외부 호출 설정 목록 조회 실패");
}
} catch (error) {
console.error("외부 호출 설정 목록 조회 오류:", error);
toast.error("외부 호출 설정을 불러올 수 없습니다.");
} finally {
setLoading(false);
}
};
// 컴포넌트 마운트 시 설정 목록 조회
useEffect(() => {
fetchAvailableConfigs();
}, []);
// 외부 호출 설정 선택
const handleConfigChange = (configId: string) => {
if (configId === "none") {
onSettingsChange({
...settings,
configId: undefined,
configName: undefined,
});
return;
}
const selectedConfig = availableConfigs.find((config) => config.id?.toString() === configId);
if (selectedConfig) {
onSettingsChange({
...settings,
configId: selectedConfig.id,
configName: selectedConfig.config_name,
});
}
};
// 메시지 변경
const handleMessageChange = (message: string) => {
onSettingsChange({
...settings,
message,
});
};
// 테스트 실행
const handleTest = async () => {
if (!settings.configId) {
toast.error("외부 호출 설정을 선택해주세요.");
return;
}
if (!settings.message.trim()) {
toast.error("메시지를 입력해주세요.");
return;
}
try {
setTestLoading(true);
// 선택된 설정 정보 가져오기
const configResponse = await ExternalCallConfigAPI.getConfigById(settings.configId);
if (!configResponse.success || !configResponse.data) {
toast.error("외부 호출 설정을 찾을 수 없습니다.");
return;
}
const config = configResponse.data;
const configData = config.config_data as any;
// 백엔드 API용 설정 변환
const backendSettings: Record<string, unknown> = {
callType: config.call_type,
apiType: config.api_type,
message: settings.message,
timeout: 10000,
};
// API 타입별 설정 추가
switch (config.api_type) {
case "discord":
backendSettings.webhookUrl = configData.webhookUrl;
backendSettings.username = configData.username;
if (configData.avatarUrl) {
backendSettings.avatarUrl = configData.avatarUrl;
}
break;
case "slack":
backendSettings.webhookUrl = configData.webhookUrl;
if (configData.channel) {
backendSettings.channel = configData.channel;
}
if (configData.username) {
backendSettings.username = configData.username;
}
break;
case "kakao-talk":
backendSettings.accessToken = configData.accessToken;
if (configData.templateId) {
backendSettings.templateId = configData.templateId;
}
break;
case "generic":
backendSettings.url = configData.url;
backendSettings.method = configData.method || "POST";
backendSettings.headers = configData.headers || {};
if (configData.timeout) {
backendSettings.timeout = configData.timeout;
}
break;
}
// 테스트 실행 (10초 타임아웃)
const testPromise = ExternalCallAPI.testExternalCall({
settings: backendSettings,
templateData: { recordCount: 5, timestamp: new Date().toISOString() },
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("테스트가 10초 내에 완료되지 않았습니다.")), 10000),
);
const result = await Promise.race([testPromise, timeoutPromise]);
if ((result as any).success) {
toast.success(`테스트 성공: ${config.config_name}`);
} else {
toast.error(`테스트 실패: ${(result as any).message || "알 수 없는 오류"}`);
}
} catch (error) {
console.error("외부 호출 테스트 오류:", error);
if (error instanceof Error && error.message.includes("10초")) {
toast.error("테스트 타임아웃: 10초 내에 응답이 없습니다.");
} else {
toast.error("테스트 실행 중 오류가 발생했습니다.");
}
} finally {
setTestLoading(false);
}
};
// 선택된 설정 정보
const selectedConfig = availableConfigs.find((config) => config.id === settings.configId);
return (
<div className="space-y-4">
<div className="mb-4 flex items-center gap-2">
<Globe size={18} />
<h3 className="text-lg font-medium"> </h3>
</div>
{/* 외부 호출 설정 선택 */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label htmlFor="external-call-config"> *</Label>
<Button variant="ghost" size="sm" onClick={fetchAvailableConfigs} disabled={loading} className="h-6 px-2">
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</Button>
</div>
<Select value={settings.configId?.toString() || "none"} onValueChange={handleConfigChange} disabled={loading}>
<SelectTrigger>
<SelectValue placeholder={loading ? "로딩 중..." : "외부 호출 설정을 선택하세요"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{availableConfigs.map((config) => (
<SelectItem key={config.id} value={config.id!.toString()}>
<div className="flex flex-col">
<span className="font-medium">{config.config_name}</span>
<span className="text-muted-foreground text-xs">
{config.call_type === "rest-api" && config.api_type
? `${config.call_type} - ${config.api_type}`
: config.call_type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 선택된 설정 정보 표시 */}
{selectedConfig && (
<div className="bg-muted/50 mt-2 rounded-lg p-3 text-sm">
<div className="font-medium">{selectedConfig.config_name}</div>
{selectedConfig.description && (
<div className="text-muted-foreground mt-1">{selectedConfig.description}</div>
)}
</div>
)}
</div>
{/* 메시지 템플릿 */}
<div>
<Label htmlFor="external-call-message"> 릿 *</Label>
<Textarea
id="external-call-message"
value={settings.message}
onChange={(e) => handleMessageChange(e.target.value)}
placeholder="데이터 처리가 완료되었습니다! 총 {{recordCount}}건이 처리되었습니다."
rows={3}
className="text-sm"
/>
<div className="text-muted-foreground mt-1 text-xs">
릿 : {{ recordCount }}, {{ timestamp }}, {{ tableName }} .
</div>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!settings.configId || !settings.message.trim() || testLoading}
className="flex items-center gap-2"
>
<TestTube size={14} />
{testLoading ? "테스트 중..." : "테스트"}
</Button>
</div>
{/* 설정이 없는 경우 안내 */}
{!loading && availableConfigs.length === 0 && (
<div className="text-muted-foreground rounded-lg border p-4 text-center">
<Globe size={32} className="mx-auto mb-2 opacity-50" />
<p className="font-medium"> .</p>
<p className="text-sm"> .</p>
</div>
)}
</div>
);
}

View File

@ -15,6 +15,7 @@ export interface ConditionNode {
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value?: string | number | boolean;
dataType?: string;
tableType?: "from" | "to"; // 어느 테이블의 필드인지 구분
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)

View File

@ -0,0 +1,196 @@
/**
* API
*/
// 백엔드 타입과 동일한 인터페이스 정의
export interface ExternalCallResult {
success: boolean;
statusCode?: number;
response?: string;
error?: string;
executionTime: number;
timestamp: string;
}
export interface ExternalCallTestRequest {
settings: Record<string, unknown>;
templateData?: Record<string, unknown>;
}
export interface ExternalCallExecuteRequest {
diagramId: number;
relationshipId: string;
settings: Record<string, unknown>;
templateData?: Record<string, unknown>;
}
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* API
*/
// API URL 동적 설정 - 기존 client.ts와 동일한 로직
const getApiBaseUrl = (): string => {
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
const currentPort = window.location.port;
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
if (
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
(currentPort === "9771" || currentPort === "3000")
) {
return "http://localhost:8080/api";
}
// 서버 환경에서 localhost:5555 → 39.117.244.52:8080
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "5555") {
return "http://39.117.244.52:8080/api";
}
// 기타 서버 환경 (내부/외부 IP): → 39.117.244.52:8080
return "http://39.117.244.52:8080/api";
}
// 서버 사이드 렌더링 기본값
return "http://39.117.244.52:8080/api";
};
export class ExternalCallAPI {
private static readonly BASE_URL = `${getApiBaseUrl()}/external-calls`;
/**
*
*/
static async testExternalCall(request: ExternalCallTestRequest): Promise<{
success: boolean;
result?: ExternalCallResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/test`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
// 응답이 JSON인지 확인
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
throw new Error(`서버에서 JSON이 아닌 응답을 반환했습니다: ${text.substring(0, 100)}...`);
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("외부 호출 테스트 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
*
*/
static async executeExternalCall(request: ExternalCallExecuteRequest): Promise<{
success: boolean;
result?: ExternalCallResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("외부 호출 실행 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
*
*/
static async getSupportedTypes(): Promise<{
success: boolean;
supportedTypes?: Record<string, any>;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/types`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("지원 타입 조회 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
/**
*
*/
static async validateSettings(settings: Record<string, unknown>): Promise<{
success: boolean;
validation?: ValidationResult;
error?: string;
}> {
try {
const response = await fetch(`${this.BASE_URL}/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ settings }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error("설정 검증 실패:", error);
return {
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
};
}
}
}

View File

@ -0,0 +1,150 @@
import { apiClient, ApiResponse } from "./client";
// 외부 호출 설정 타입 정의
export interface ExternalCallConfig {
id?: number;
config_name: string;
call_type: string;
api_type?: string;
config_data: Record<string, unknown>;
description?: string;
company_code?: string;
is_active?: string;
created_date?: string;
created_by?: string;
updated_date?: string;
updated_by?: string;
}
export interface ExternalCallConfigFilter {
company_code?: string;
call_type?: string;
api_type?: string;
is_active?: string;
search?: string;
}
export interface ExternalCallConfigTestResult {
success: boolean;
message: string;
}
/**
* API
*/
export class ExternalCallConfigAPI {
private static readonly BASE_URL = "/external-call-configs";
/**
*
*/
static async getConfigs(filter?: ExternalCallConfigFilter): Promise<ApiResponse<ExternalCallConfig[]>> {
try {
const params = new URLSearchParams();
if (filter?.company_code) params.append("company_code", filter.company_code);
if (filter?.call_type) params.append("call_type", filter.call_type);
if (filter?.api_type) params.append("api_type", filter.api_type);
if (filter?.is_active) params.append("is_active", filter.is_active);
if (filter?.search) params.append("search", filter.search);
const url = params.toString() ? `${this.BASE_URL}?${params.toString()}` : this.BASE_URL;
const response = await apiClient.get(url);
return response.data;
} catch (error) {
console.error("외부 호출 설정 목록 조회 실패:", error);
throw error;
}
}
/**
*
*/
static async getConfigById(id: number): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.get(`${this.BASE_URL}/${id}`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async createConfig(
config: Omit<ExternalCallConfig, "id" | "created_date" | "updated_date">,
): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.post(this.BASE_URL, config);
return response.data;
} catch (error) {
console.error("외부 호출 설정 생성 실패:", error);
throw error;
}
}
/**
*
*/
static async updateConfig(id: number, config: Partial<ExternalCallConfig>): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.put(`${this.BASE_URL}/${id}`, config);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async deleteConfig(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`${this.BASE_URL}/${id}`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async testConfig(id: number): Promise<ApiResponse<ExternalCallConfigTestResult>> {
try {
const response = await apiClient.post(`${this.BASE_URL}/${id}/test`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error);
throw error;
}
}
}
// 호출 타입 옵션
export const CALL_TYPE_OPTIONS = [
{ value: "rest-api", label: "REST API" },
{ value: "email", label: "이메일" },
{ value: "ftp", label: "FTP" },
{ value: "queue", label: "큐" },
];
// API 타입 옵션 (REST API 전용)
export const API_TYPE_OPTIONS = [
{ value: "discord", label: "Discord" },
{ value: "slack", label: "Slack" },
{ value: "kakao-talk", label: "카카오톡" },
{ value: "mattermost", label: "Mattermost" },
{ value: "generic", label: "기타 (일반 API)" },
];
// 활성화 상태 옵션
export const ACTIVE_STATUS_OPTIONS = [
{ value: "Y", label: "활성" },
{ value: "N", label: "비활성" },
];

View File

@ -0,0 +1,259 @@
// 외부 DB 연결 API 클라이언트
// 작성일: 2024-12-17
// API 기본 설정
const getApiBaseUrl = () => {
if (process.env.NODE_ENV === "development") {
return "http://localhost:8080/api";
}
return "/api";
};
// 타입 정의
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<string, unknown>;
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<T> {
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: "ALL", label: "전체" },
];
// API 클라이언트 클래스
export class ExternalDbConnectionAPI {
private static getAuthHeaders() {
const token = localStorage.getItem("authToken");
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
}
private static async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
try {
// 응답이 JSON인지 확인
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new Error(`서버에서 JSON이 아닌 응답을 받았습니다: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!response.ok) {
return {
success: false,
message: data.message || `HTTP ${response.status}: ${response.statusText}`,
error: data.error,
};
}
return data;
} catch (error) {
console.error("API 응답 처리 오류:", error);
return {
success: false,
message: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* DB
*/
static async getConnections(filter?: ExternalDbConnectionFilter): Promise<ApiResponse<ExternalDbConnection[]>> {
try {
const params = new URLSearchParams();
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value && value.trim()) {
params.append(key, value.trim());
}
});
}
const url = `${getApiBaseUrl()}/external-db-connections${params.toString() ? `?${params.toString()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.getAuthHeaders(),
});
return this.handleResponse<ExternalDbConnection[]>(response);
} catch (error) {
console.error("외부 DB 연결 목록 조회 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* DB
*/
static async getConnectionById(id: number): Promise<ApiResponse<ExternalDbConnection>> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "GET",
headers: this.getAuthHeaders(),
});
return this.handleResponse<ExternalDbConnection>(response);
} catch (error) {
console.error("외부 DB 연결 조회 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* DB
*/
static async createConnection(data: ExternalDbConnection): Promise<ApiResponse<ExternalDbConnection>> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse<ExternalDbConnection>(response);
} catch (error) {
console.error("외부 DB 연결 생성 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* DB
*/
static async updateConnection(
id: number,
data: Partial<ExternalDbConnection>,
): Promise<ApiResponse<ExternalDbConnection>> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
return this.handleResponse<ExternalDbConnection>(response);
} catch (error) {
console.error("외부 DB 연결 수정 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* DB
*/
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "DELETE",
headers: this.getAuthHeaders(),
});
return this.handleResponse<void>(response);
} catch (error) {
console.error("외부 DB 연결 삭제 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* DB
*/
static async getSupportedTypes(): Promise<
ApiResponse<{ types: typeof DB_TYPE_OPTIONS; defaults: typeof DB_TYPE_DEFAULTS }>
> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/types/supported`, {
method: "GET",
headers: this.getAuthHeaders(),
});
return this.handleResponse<{ types: typeof DB_TYPE_OPTIONS; defaults: typeof DB_TYPE_DEFAULTS }>(response);
} catch (error) {
console.error("지원 DB 타입 조회 오류:", error);
return {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
};
}
}
}

View File

@ -18,17 +18,7 @@ const nextConfig = {
outputFileTracingRoot: undefined,
},
async rewrites() {
// 개발 환경과 운영 환경에 따른 백엔드 URL 설정
const backendUrl = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "http://backend:8080";
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
// 프록시 설정 제거 - 모든 API가 직접 백엔드 호출
// 개발 환경에서 CORS 처리
async headers() {

View File

@ -67,15 +67,37 @@ export interface DataSaveSettings {
// 외부 호출 설정
export interface ExternalCallSettings {
callType: "rest-api" | "email" | "webhook" | "kakao-talk" | "ftp" | "queue";
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;
kakaoRecipient?: string;
kakaoMessage?: string;
// 디스코드 전용 설정
discordWebhookUrl?: string;
discordMessage?: string;
discordUsername?: string;
}
// 단순화된 외부 호출 설정 (새로운 버전)
export interface SimpleExternalCallSettings {
configId?: number; // 선택된 외부 호출 설정 ID
configName?: string; // 설정 이름 (표시용)
message: string; // 메시지 템플릿
}
// ConnectionSetupModal Props 타입