Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
29f506fb27
|
|
@ -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();
|
|
||||||
|
|
@ -20,6 +20,54 @@ model dynamic_form_data {
|
||||||
company_code String @db.VarChar(20)
|
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 {
|
model admin_supply_mng {
|
||||||
objid Decimal @id @default(0) @db.Decimal
|
objid Decimal @id @default(0) @db.Decimal
|
||||||
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
import layoutRoutes from "./routes/layoutRoutes";
|
import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
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 userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -125,6 +128,9 @@ app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||||
app.use("/api/layouts", layoutRoutes);
|
app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
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/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
- [ ] 오류 처리 및 재시도 메커니즘
|
||||||
|
|
||||||
|
### 배포 준비 체크
|
||||||
|
|
||||||
|
- [ ] 환경변수 설정
|
||||||
|
- [ ] 데이터베이스 마이그레이션
|
||||||
|
- [ ] 로그 시스템 설정
|
||||||
|
- [ ] 모니터링 설정
|
||||||
|
- [ ] 백업 계획 수립
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 계획서를 바탕으로 단계별로 구현해나가면 안정적이고 확장 가능한 외부 호출 시스템을 구축할 수 있을 것입니다! 🚀
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
SimpleKeySettings,
|
SimpleKeySettings,
|
||||||
DataSaveSettings,
|
DataSaveSettings,
|
||||||
ExternalCallSettings,
|
ExternalCallSettings,
|
||||||
|
SimpleExternalCallSettings,
|
||||||
ConnectionSetupModalProps,
|
ConnectionSetupModalProps,
|
||||||
} from "@/types/connectionTypes";
|
} from "@/types/connectionTypes";
|
||||||
import { isConditionalConnection } from "@/utils/connectionUtils";
|
import { isConditionalConnection } from "@/utils/connectionUtils";
|
||||||
|
|
@ -20,7 +21,7 @@ import { ConditionalSettings } from "./condition/ConditionalSettings";
|
||||||
import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
|
import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
|
||||||
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
|
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
|
||||||
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
|
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";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
|
|
@ -47,12 +48,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
actions: [],
|
actions: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
|
const [externalCallSettings, setExternalCallSettings] = useState<SimpleExternalCallSettings>({
|
||||||
callType: "rest-api",
|
message: "",
|
||||||
apiUrl: "",
|
|
||||||
httpMethod: "POST",
|
|
||||||
headers: "{}",
|
|
||||||
bodyTemplate: "{}",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 테이블 및 컬럼 선택을 위한 상태들
|
// 테이블 및 컬럼 선택을 위한 상태들
|
||||||
|
|
@ -87,13 +84,17 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
} else if (connectionType === "data-save") {
|
} else if (connectionType === "data-save") {
|
||||||
// data-save 설정 로드 - 안전하게 처리 (다양한 구조 지원)
|
// data-save 설정 로드 - 안전하게 처리 (다양한 구조 지원)
|
||||||
let actionsData: Record<string, unknown>[] = [];
|
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 배열이 있는 경우
|
// 직접 actions 배열이 있는 경우
|
||||||
actionsData = (settings as any).actions;
|
actionsData = settingsRecord.actions as Record<string, unknown>[];
|
||||||
} else if ((settings as any).plan && Array.isArray((settings as any).plan.actions)) {
|
} else if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
|
||||||
// plan 객체 안에 actions가 있는 경우
|
// 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)) {
|
} else if (Array.isArray(settings)) {
|
||||||
// settings 자체가 actions 배열인 경우
|
// settings 자체가 actions 배열인 경우
|
||||||
actionsData = settings as Record<string, unknown>[];
|
actionsData = settings as Record<string, unknown>[];
|
||||||
|
|
@ -130,26 +131,34 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// control 설정도 로드 (전체 실행 조건)
|
// control 설정도 로드 (전체 실행 조건)
|
||||||
if (
|
if (settingsRecord.control && typeof settingsRecord.control === "object" && settingsRecord.control !== null) {
|
||||||
(settings as any).control &&
|
const controlRecord = settingsRecord.control as Record<string, unknown>;
|
||||||
(settings as any).control.conditionTree &&
|
if (Array.isArray(controlRecord.conditionTree)) {
|
||||||
Array.isArray((settings as any).control.conditionTree)
|
const conditionTree = controlRecord.conditionTree as ConditionNode[];
|
||||||
) {
|
setConditions(
|
||||||
const conditionTree = (settings as any).control.conditionTree as ConditionNode[];
|
conditionTree.map((condition) => ({
|
||||||
setConditions(
|
...condition,
|
||||||
conditionTree.map((condition) => ({
|
operator: condition.operator || "=", // 기본값 보장
|
||||||
...condition,
|
})),
|
||||||
operator: condition.operator || "=", // 기본값 보장
|
);
|
||||||
})),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (connectionType === "external-call") {
|
} 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({
|
setExternalCallSettings({
|
||||||
callType: (settings.callType as "rest-api" | "webhook") || "rest-api",
|
configId: (externalCallData.configId as number) || undefined,
|
||||||
apiUrl: (settings.apiUrl as string) || "",
|
configName: (externalCallData.configName as string) || undefined,
|
||||||
httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST",
|
message: (externalCallData.message as string) || "",
|
||||||
headers: (settings.headers as string) || "{}",
|
|
||||||
bodyTemplate: (settings.bodyTemplate as string) || "{}",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -326,6 +335,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
|
|
||||||
// 연결 종류별 설정을 준비
|
// 연결 종류별 설정을 준비
|
||||||
let settings = {};
|
let settings = {};
|
||||||
|
let plan = {}; // plan 변수 선언
|
||||||
|
|
||||||
switch (config.connectionType) {
|
switch (config.connectionType) {
|
||||||
case "simple-key":
|
case "simple-key":
|
||||||
|
|
@ -335,7 +345,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
settings = dataSaveSettings;
|
settings = dataSaveSettings;
|
||||||
break;
|
break;
|
||||||
case "external-call":
|
case "external-call":
|
||||||
settings = externalCallSettings;
|
// 외부 호출은 plan에 저장
|
||||||
|
plan = {
|
||||||
|
externalCall: {
|
||||||
|
configId: externalCallSettings.configId,
|
||||||
|
configName: externalCallSettings.configName,
|
||||||
|
message: externalCallSettings.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
settings = {}; // 외부 호출은 settings에 저장하지 않음
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,6 +427,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
settings: {
|
settings: {
|
||||||
...settings,
|
...settings,
|
||||||
...conditionalSettings, // 조건부 연결 설정 추가
|
...conditionalSettings, // 조건부 연결 설정 추가
|
||||||
|
...plan, // 외부 호출 plan 추가
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -457,6 +476,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
availableTables={availableTables}
|
availableTables={availableTables}
|
||||||
fromTableColumns={fromTableColumns}
|
fromTableColumns={fromTableColumns}
|
||||||
toTableColumns={toTableColumns}
|
toTableColumns={toTableColumns}
|
||||||
|
fromTableName={selectedFromTable}
|
||||||
|
toTableName={selectedToTable}
|
||||||
tableColumnsCache={tableColumnsCache}
|
tableColumnsCache={tableColumnsCache}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -509,22 +530,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
|
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
|
||||||
|
|
||||||
case "external-call":
|
case "external-call":
|
||||||
// 외부 호출: 호출 타입과 필수 설정이 있어야 함
|
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
||||||
const hasCallType = !!externalCallSettings.callType;
|
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Trash2 } from "lucide-react";
|
||||||
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
|
|
@ -16,6 +24,9 @@ interface ActionConditionRendererProps {
|
||||||
settings: DataSaveSettings;
|
settings: DataSaveSettings;
|
||||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
fromTableColumns: ColumnInfo[];
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
fromTableName?: string;
|
||||||
|
toTableName?: string;
|
||||||
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
|
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +37,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
||||||
settings,
|
settings,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
fromTableColumns,
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
fromTableName,
|
||||||
|
toTableName,
|
||||||
getActionCurrentGroupLevel,
|
getActionCurrentGroupLevel,
|
||||||
}) => {
|
}) => {
|
||||||
const removeConditionGroup = (groupId: string) => {
|
const removeConditionGroup = (groupId: string) => {
|
||||||
|
|
@ -53,7 +67,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderConditionValue = () => {
|
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 dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||||
const inputType = getInputTypeForDataType(dataType);
|
const inputType = getInputTypeForDataType(dataType);
|
||||||
|
|
||||||
|
|
@ -167,16 +183,46 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
||||||
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select value={condition.field || ""} onValueChange={(value) => updateCondition("field", value)}>
|
{/* 1단계: 테이블 선택 */}
|
||||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
<Select
|
||||||
<SelectValue placeholder="필드" />
|
value={condition.tableType || ""}
|
||||||
|
onValueChange={(value: "from" | "to") => {
|
||||||
|
updateCondition("tableType", value);
|
||||||
|
// 테이블이 변경되면 필드 초기화
|
||||||
|
updateCondition("field", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-20 text-xs">
|
||||||
|
<SelectValue placeholder="테이블" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fromTableColumns.map((column) => (
|
{fromTableColumns.length > 0 && <SelectItem value="from">{fromTableName || "From 테이블"}</SelectItem>}
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
{toTableColumns.length > 0 && <SelectItem value="to">{toTableName || "To 테이블"}</SelectItem>}
|
||||||
{column.columnName}
|
</SelectContent>
|
||||||
</SelectItem>
|
</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ interface ActionConditionsSectionProps {
|
||||||
settings: DataSaveSettings;
|
settings: DataSaveSettings;
|
||||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
fromTableColumns: ColumnInfo[];
|
fromTableColumns: ColumnInfo[];
|
||||||
|
toTableColumns: ColumnInfo[];
|
||||||
|
fromTableName?: string;
|
||||||
|
toTableName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
|
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
|
||||||
|
|
@ -23,6 +26,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
settings,
|
settings,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
fromTableColumns,
|
fromTableColumns,
|
||||||
|
toTableColumns,
|
||||||
|
fromTableName,
|
||||||
|
toTableName,
|
||||||
}) => {
|
}) => {
|
||||||
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
||||||
|
|
||||||
|
|
@ -39,6 +45,7 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
operator: "=" as const,
|
operator: "=" as const,
|
||||||
value: "",
|
value: "",
|
||||||
dataType: "string",
|
dataType: "string",
|
||||||
|
tableType: undefined, // 사용자가 직접 선택하도록
|
||||||
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||||
...(currentConditions.length > 0 &&
|
...(currentConditions.length > 0 &&
|
||||||
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
|
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
|
||||||
|
|
@ -119,6 +126,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
fromTableColumns={fromTableColumns}
|
fromTableColumns={fromTableColumns}
|
||||||
|
toTableColumns={toTableColumns}
|
||||||
|
fromTableName={fromTableName}
|
||||||
|
toTableName={toTableName}
|
||||||
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
|
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ interface DataSaveSettingsProps {
|
||||||
availableTables: TableInfo[];
|
availableTables: TableInfo[];
|
||||||
fromTableColumns: ColumnInfo[];
|
fromTableColumns: ColumnInfo[];
|
||||||
toTableColumns: ColumnInfo[];
|
toTableColumns: ColumnInfo[];
|
||||||
|
fromTableName?: string;
|
||||||
|
toTableName?: string;
|
||||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +29,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
availableTables,
|
availableTables,
|
||||||
fromTableColumns,
|
fromTableColumns,
|
||||||
toTableColumns,
|
toTableColumns,
|
||||||
|
fromTableName,
|
||||||
|
toTableName,
|
||||||
tableColumnsCache,
|
tableColumnsCache,
|
||||||
}) => {
|
}) => {
|
||||||
const addAction = () => {
|
const addAction = () => {
|
||||||
|
|
@ -126,6 +130,9 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
fromTableColumns={fromTableColumns}
|
fromTableColumns={fromTableColumns}
|
||||||
|
toTableColumns={toTableColumns}
|
||||||
|
fromTableName={fromTableName}
|
||||||
|
toTableName={toTableName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 데이터 분할 설정 */}
|
{/* 데이터 분할 설정 */}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Globe } from "lucide-react";
|
||||||
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
|
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
|
||||||
|
|
||||||
|
|
@ -13,12 +16,128 @@ interface ExternalCallSettingsProps {
|
||||||
onSettingsChange: (settings: ExternalCallSettingsType) => void;
|
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 }) => {
|
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
|
<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">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Globe className="h-4 w-4 text-orange-500" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">외부 호출 설정</span>
|
<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>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -27,7 +146,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={settings.callType}
|
value={settings.callType}
|
||||||
onValueChange={(value: "rest-api" | "email" | "webhook" | "kakao-talk" | "ftp" | "queue") =>
|
onValueChange={(value: "rest-api" | "email" | "ftp" | "queue") =>
|
||||||
onSettingsChange({ ...settings, callType: value })
|
onSettingsChange({ ...settings, callType: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -36,9 +155,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="rest-api">REST API 호출</SelectItem>
|
<SelectItem value="rest-api">REST API 호출</SelectItem>
|
||||||
<SelectItem value="kakao-talk"> 카카오톡 알림</SelectItem>
|
|
||||||
<SelectItem value="email">이메일 전송</SelectItem>
|
<SelectItem value="email">이메일 전송</SelectItem>
|
||||||
<SelectItem value="webhook">웹훅</SelectItem>
|
|
||||||
<SelectItem value="ftp">FTP 업로드</SelectItem>
|
<SelectItem value="ftp">FTP 업로드</SelectItem>
|
||||||
<SelectItem value="queue">메시지 큐</SelectItem>
|
<SelectItem value="queue">메시지 큐</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -48,124 +165,211 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
||||||
{settings.callType === "rest-api" && (
|
{settings.callType === "rest-api" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="apiUrl" className="text-sm">
|
<Label htmlFor="apiType" className="text-sm">
|
||||||
API URL
|
API 종류
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Select
|
||||||
id="apiUrl"
|
value={settings.apiType || "generic"}
|
||||||
value={settings.apiUrl}
|
onValueChange={(value: "slack" | "kakao-talk" | "discord" | "generic") =>
|
||||||
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
|
onSettingsChange({ ...settings, apiType: 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
placeholder="카카오 개발자 센터에서 발급받은 토큰"
|
>
|
||||||
className="text-sm"
|
<SelectTrigger className="text-sm">
|
||||||
/>
|
<SelectValue />
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
</SelectTrigger>
|
||||||
💡{" "}
|
<SelectContent>
|
||||||
<a
|
<SelectItem value="slack">슬랙</SelectItem>
|
||||||
href="https://developers.kakao.com"
|
<SelectItem value="kakao-talk">카카오톡</SelectItem>
|
||||||
target="_blank"
|
<SelectItem value="discord">디스코드</SelectItem>
|
||||||
rel="noopener noreferrer"
|
<SelectItem value="generic">기타 (일반 API)</SelectItem>
|
||||||
className="text-blue-500 hover:underline"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
카카오 개발자 센터
|
|
||||||
</a>
|
|
||||||
에서 앱 등록 후 토큰을 발급받으세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* 슬랙 설정 */}
|
||||||
<Label htmlFor="kakaoMessage" className="text-sm">
|
{settings.apiType === "slack" && (
|
||||||
메시지 템플릿 <span className="text-red-500">*</span>
|
<>
|
||||||
</Label>
|
<div>
|
||||||
<Textarea
|
<Label htmlFor="slackWebhookUrl" className="text-sm">
|
||||||
id="kakaoMessage"
|
슬랙 웹훅 URL
|
||||||
value={settings.bodyTemplate || ""}
|
</Label>
|
||||||
onChange={(e) =>
|
<Input
|
||||||
onSettingsChange({
|
id="slackWebhookUrl"
|
||||||
...settings,
|
value={settings.slackWebhookUrl || ""}
|
||||||
bodyTemplate: e.target.value,
|
onChange={(e) => onSettingsChange({ ...settings, slackWebhookUrl: e.target.value })}
|
||||||
})
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
}
|
className="text-sm"
|
||||||
placeholder="안녕하세요! {{customer_name}}님의 주문({{order_id}})이 처리되었습니다."
|
/>
|
||||||
rows={3}
|
</div>
|
||||||
className="text-sm"
|
<div>
|
||||||
/>
|
<Label htmlFor="slackChannel" className="text-sm">
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
채널
|
||||||
💡 {"{{"} 필드명 {"}"} 형태로 데이터를 삽입할 수 있습니다 (예: {"{{"} user_name {"}"}, {"{{"} amount{" "}
|
</Label>
|
||||||
{"}"})
|
<Input
|
||||||
</p>
|
id="slackChannel"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ export interface ConditionNode {
|
||||||
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||||
value?: string | number | boolean;
|
value?: string | number | boolean;
|
||||||
dataType?: string;
|
dataType?: string;
|
||||||
|
tableType?: "from" | "to"; // 어느 테이블의 필드인지 구분
|
||||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||||
|
|
|
||||||
|
|
@ -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 : "알 수 없는 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: "비활성" },
|
||||||
|
];
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,17 +18,7 @@ const nextConfig = {
|
||||||
outputFileTracingRoot: undefined,
|
outputFileTracingRoot: undefined,
|
||||||
},
|
},
|
||||||
|
|
||||||
async rewrites() {
|
// 프록시 설정 제거 - 모든 API가 직접 백엔드 호출
|
||||||
// 개발 환경과 운영 환경에 따른 백엔드 URL 설정
|
|
||||||
const backendUrl = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "http://backend:8080";
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/:path*",
|
|
||||||
destination: `${backendUrl}/api/:path*`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
// 개발 환경에서 CORS 처리
|
// 개발 환경에서 CORS 처리
|
||||||
async headers() {
|
async headers() {
|
||||||
|
|
|
||||||
|
|
@ -67,15 +67,37 @@ export interface DataSaveSettings {
|
||||||
|
|
||||||
// 외부 호출 설정
|
// 외부 호출 설정
|
||||||
export interface ExternalCallSettings {
|
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;
|
apiUrl?: string;
|
||||||
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
headers?: string;
|
headers?: string;
|
||||||
bodyTemplate?: string;
|
bodyTemplate?: string;
|
||||||
|
|
||||||
|
// 슬랙 전용 설정
|
||||||
|
slackWebhookUrl?: string;
|
||||||
|
slackChannel?: string;
|
||||||
|
slackMessage?: string;
|
||||||
|
|
||||||
// 카카오톡 전용 설정
|
// 카카오톡 전용 설정
|
||||||
kakaoAccessToken?: 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 타입
|
// ConnectionSetupModal Props 타입
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue