db커넥션, 제어관리 조건 선택 수정 #32
|
|
@ -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)
|
||||
}
|
||||
|
||||
model external_call_configs {
|
||||
id Int @id @default(autoincrement())
|
||||
config_name String @db.VarChar(100)
|
||||
call_type String @db.VarChar(20)
|
||||
api_type String? @db.VarChar(20)
|
||||
config_data Json
|
||||
description String? @db.Text
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([company_code])
|
||||
@@index([call_type, api_type])
|
||||
@@index([is_active])
|
||||
}
|
||||
|
||||
model external_db_connections {
|
||||
id Int @id @default(autoincrement())
|
||||
connection_name String @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
db_type String @db.VarChar(20)
|
||||
host String @db.VarChar(255)
|
||||
port Int
|
||||
database_name String @db.VarChar(100)
|
||||
username String @db.VarChar(100)
|
||||
password String @db.Text
|
||||
connection_timeout Int? @default(30)
|
||||
query_timeout Int? @default(60)
|
||||
max_connections Int? @default(10)
|
||||
ssl_enabled String @default("N") @db.Char(1)
|
||||
ssl_cert_path String? @db.VarChar(500)
|
||||
connection_options Json?
|
||||
company_code String @default("*") @db.VarChar(20)
|
||||
is_active String @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
@@index([company_code])
|
||||
@@index([is_active])
|
||||
@@index([db_type])
|
||||
@@index([connection_name])
|
||||
}
|
||||
|
||||
model admin_supply_mng {
|
||||
objid Decimal @id @default(0) @db.Decimal
|
||||
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import templateStandardRoutes from "./routes/templateStandardRoutes";
|
|||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
||||
|
|
@ -119,6 +122,9 @@ app.use("/api/admin/component-standards", componentStandardRoutes);
|
|||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/external-calls", externalCallRoutes);
|
||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
DataSaveSettings,
|
||||
ExternalCallSettings,
|
||||
SimpleExternalCallSettings,
|
||||
ConnectionSetupModalProps,
|
||||
} from "@/types/connectionTypes";
|
||||
import { isConditionalConnection } from "@/utils/connectionUtils";
|
||||
|
|
@ -20,7 +21,7 @@ import { ConditionalSettings } from "./condition/ConditionalSettings";
|
|||
import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
|
||||
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
|
||||
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
|
||||
import { ExternalCallSettings as ExternalCallSettingsComponent } from "./connection/ExternalCallSettings";
|
||||
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||
|
|
@ -47,12 +48,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
actions: [],
|
||||
});
|
||||
|
||||
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
|
||||
callType: "rest-api",
|
||||
apiUrl: "",
|
||||
httpMethod: "POST",
|
||||
headers: "{}",
|
||||
bodyTemplate: "{}",
|
||||
const [externalCallSettings, setExternalCallSettings] = useState<SimpleExternalCallSettings>({
|
||||
message: "",
|
||||
});
|
||||
|
||||
// 테이블 및 컬럼 선택을 위한 상태들
|
||||
|
|
@ -87,13 +84,17 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
} else if (connectionType === "data-save") {
|
||||
// data-save 설정 로드 - 안전하게 처리 (다양한 구조 지원)
|
||||
let actionsData: Record<string, unknown>[] = [];
|
||||
const settingsRecord = settings as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray((settings as any).actions)) {
|
||||
if (Array.isArray(settingsRecord.actions)) {
|
||||
// 직접 actions 배열이 있는 경우
|
||||
actionsData = (settings as any).actions;
|
||||
} else if ((settings as any).plan && Array.isArray((settings as any).plan.actions)) {
|
||||
actionsData = settingsRecord.actions as Record<string, unknown>[];
|
||||
} else if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
|
||||
// plan 객체 안에 actions가 있는 경우
|
||||
actionsData = (settings as any).plan.actions;
|
||||
const planRecord = settingsRecord.plan as Record<string, unknown>;
|
||||
if (Array.isArray(planRecord.actions)) {
|
||||
actionsData = planRecord.actions as Record<string, unknown>[];
|
||||
}
|
||||
} else if (Array.isArray(settings)) {
|
||||
// settings 자체가 actions 배열인 경우
|
||||
actionsData = settings as Record<string, unknown>[];
|
||||
|
|
@ -130,26 +131,34 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
});
|
||||
|
||||
// control 설정도 로드 (전체 실행 조건)
|
||||
if (
|
||||
(settings as any).control &&
|
||||
(settings as any).control.conditionTree &&
|
||||
Array.isArray((settings as any).control.conditionTree)
|
||||
) {
|
||||
const conditionTree = (settings as any).control.conditionTree as ConditionNode[];
|
||||
setConditions(
|
||||
conditionTree.map((condition) => ({
|
||||
...condition,
|
||||
operator: condition.operator || "=", // 기본값 보장
|
||||
})),
|
||||
);
|
||||
if (settingsRecord.control && typeof settingsRecord.control === "object" && settingsRecord.control !== null) {
|
||||
const controlRecord = settingsRecord.control as Record<string, unknown>;
|
||||
if (Array.isArray(controlRecord.conditionTree)) {
|
||||
const conditionTree = controlRecord.conditionTree as ConditionNode[];
|
||||
setConditions(
|
||||
conditionTree.map((condition) => ({
|
||||
...condition,
|
||||
operator: condition.operator || "=", // 기본값 보장
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (connectionType === "external-call") {
|
||||
// 외부 호출 설정은 plan에서 로드
|
||||
const settingsRecord = settings as Record<string, unknown>;
|
||||
let externalCallData: Record<string, unknown> = {};
|
||||
|
||||
if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
|
||||
const planRecord = settingsRecord.plan as Record<string, unknown>;
|
||||
if (planRecord.externalCall && typeof planRecord.externalCall === "object") {
|
||||
externalCallData = planRecord.externalCall as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
setExternalCallSettings({
|
||||
callType: (settings.callType as "rest-api" | "webhook") || "rest-api",
|
||||
apiUrl: (settings.apiUrl as string) || "",
|
||||
httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST",
|
||||
headers: (settings.headers as string) || "{}",
|
||||
bodyTemplate: (settings.bodyTemplate as string) || "{}",
|
||||
configId: (externalCallData.configId as number) || undefined,
|
||||
configName: (externalCallData.configName as string) || undefined,
|
||||
message: (externalCallData.message as string) || "",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -326,6 +335,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
|
||||
// 연결 종류별 설정을 준비
|
||||
let settings = {};
|
||||
let plan = {}; // plan 변수 선언
|
||||
|
||||
switch (config.connectionType) {
|
||||
case "simple-key":
|
||||
|
|
@ -335,7 +345,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
settings = dataSaveSettings;
|
||||
break;
|
||||
case "external-call":
|
||||
settings = externalCallSettings;
|
||||
// 외부 호출은 plan에 저장
|
||||
plan = {
|
||||
externalCall: {
|
||||
configId: externalCallSettings.configId,
|
||||
configName: externalCallSettings.configName,
|
||||
message: externalCallSettings.message,
|
||||
},
|
||||
};
|
||||
settings = {}; // 외부 호출은 settings에 저장하지 않음
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +427,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
settings: {
|
||||
...settings,
|
||||
...conditionalSettings, // 조건부 연결 설정 추가
|
||||
...plan, // 외부 호출 plan 추가
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -457,6 +476,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
availableTables={availableTables}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={selectedFromTable}
|
||||
toTableName={selectedToTable}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
/>
|
||||
);
|
||||
|
|
@ -509,22 +530,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
|
||||
|
||||
case "external-call":
|
||||
// 외부 호출: 호출 타입과 필수 설정이 있어야 함
|
||||
const hasCallType = !!externalCallSettings.callType;
|
||||
if (!hasCallType) return true;
|
||||
|
||||
switch (externalCallSettings.callType) {
|
||||
case "rest-api":
|
||||
return !externalCallSettings.apiUrl?.trim();
|
||||
case "kakao-talk":
|
||||
return !externalCallSettings.kakaoAccessToken?.trim() || !externalCallSettings.bodyTemplate?.trim();
|
||||
case "email":
|
||||
return !externalCallSettings.apiUrl?.trim(); // 이메일 서버 URL 필요
|
||||
case "webhook":
|
||||
return !externalCallSettings.apiUrl?.trim();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
||||
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||
|
||||
default:
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
|
|
@ -16,6 +24,9 @@ interface ActionConditionRendererProps {
|
|||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
fromTableName?: string;
|
||||
toTableName?: string;
|
||||
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +37,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
|||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
fromTableName,
|
||||
toTableName,
|
||||
getActionCurrentGroupLevel,
|
||||
}) => {
|
||||
const removeConditionGroup = (groupId: string) => {
|
||||
|
|
@ -53,7 +67,9 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
|||
};
|
||||
|
||||
const renderConditionValue = () => {
|
||||
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||
// 선택된 테이블 타입에 따라 컬럼 찾기
|
||||
const targetColumns = condition.tableType === "from" ? fromTableColumns : toTableColumns;
|
||||
const selectedColumn = targetColumns.find((col) => col.columnName === condition.field);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
const inputType = getInputTypeForDataType(dataType);
|
||||
|
||||
|
|
@ -167,16 +183,46 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
|||
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
||||
}}
|
||||
>
|
||||
<Select value={condition.field || ""} onValueChange={(value) => updateCondition("field", value)}>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
{/* 1단계: 테이블 선택 */}
|
||||
<Select
|
||||
value={condition.tableType || ""}
|
||||
onValueChange={(value: "from" | "to") => {
|
||||
updateCondition("tableType", value);
|
||||
// 테이블이 변경되면 필드 초기화
|
||||
updateCondition("field", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 text-xs">
|
||||
<SelectValue placeholder="테이블" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{fromTableColumns.length > 0 && <SelectItem value="from">{fromTableName || "From 테이블"}</SelectItem>}
|
||||
{toTableColumns.length > 0 && <SelectItem value="to">{toTableName || "To 테이블"}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 2단계: 선택된 테이블의 컬럼 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => updateCondition("field", value)}
|
||||
disabled={!condition.tableType}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder={condition.tableType ? "컬럼" : "테이블을 먼저 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{condition.tableType === "from" &&
|
||||
fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
{condition.tableType === "to" &&
|
||||
toTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ interface ActionConditionsSectionProps {
|
|||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
fromTableName?: string;
|
||||
toTableName?: string;
|
||||
}
|
||||
|
||||
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
|
||||
|
|
@ -23,6 +26,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
|||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
fromTableName,
|
||||
toTableName,
|
||||
}) => {
|
||||
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
||||
|
||||
|
|
@ -39,6 +45,7 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
|||
operator: "=" as const,
|
||||
value: "",
|
||||
dataType: "string",
|
||||
tableType: undefined, // 사용자가 직접 선택하도록
|
||||
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||
...(currentConditions.length > 0 &&
|
||||
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
|
||||
|
|
@ -119,6 +126,9 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
|||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ interface DataSaveSettingsProps {
|
|||
availableTables: TableInfo[];
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
fromTableName?: string;
|
||||
toTableName?: string;
|
||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +29,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
|||
availableTables,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
fromTableName,
|
||||
toTableName,
|
||||
tableColumnsCache,
|
||||
}) => {
|
||||
const addAction = () => {
|
||||
|
|
@ -126,6 +130,9 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
|||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
/>
|
||||
|
||||
{/* 데이터 분할 설정 */}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalCallAPI } from "@/lib/api/externalCall";
|
||||
import { toast } from "sonner";
|
||||
import { Globe } from "lucide-react";
|
||||
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
|
||||
|
||||
|
|
@ -13,12 +16,128 @@ interface ExternalCallSettingsProps {
|
|||
onSettingsChange: (settings: ExternalCallSettingsType) => void;
|
||||
}
|
||||
|
||||
const handleTestExternalCall = async (settings: ExternalCallSettingsType) => {
|
||||
let loadingToastId: string | number | undefined;
|
||||
|
||||
try {
|
||||
// 설정을 백엔드 형식으로 변환
|
||||
const backendSettings: Record<string, unknown> = {
|
||||
callType: settings.callType,
|
||||
timeout: 10000, // 10초 타임아웃 설정
|
||||
};
|
||||
|
||||
if (settings.callType === "rest-api") {
|
||||
backendSettings.apiType = settings.apiType;
|
||||
|
||||
switch (settings.apiType) {
|
||||
case "slack":
|
||||
backendSettings.webhookUrl = settings.slackWebhookUrl;
|
||||
backendSettings.message =
|
||||
settings.slackMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
|
||||
backendSettings.channel = settings.slackChannel;
|
||||
break;
|
||||
case "kakao-talk":
|
||||
backendSettings.accessToken = settings.kakaoAccessToken;
|
||||
backendSettings.message =
|
||||
settings.kakaoMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
|
||||
break;
|
||||
case "discord":
|
||||
backendSettings.webhookUrl = settings.discordWebhookUrl;
|
||||
backendSettings.message =
|
||||
settings.discordMessage || "테스트 메시지: {{recordCount}}건의 데이터가 처리되었습니다.";
|
||||
backendSettings.username = settings.discordUsername;
|
||||
break;
|
||||
case "generic":
|
||||
default:
|
||||
backendSettings.url = settings.apiUrl;
|
||||
backendSettings.method = settings.httpMethod || "POST";
|
||||
try {
|
||||
backendSettings.headers = settings.headers ? JSON.parse(settings.headers) : {};
|
||||
} catch (error) {
|
||||
console.warn("Headers JSON 파싱 실패, 기본값 사용:", error);
|
||||
backendSettings.headers = {};
|
||||
}
|
||||
backendSettings.body = settings.bodyTemplate || "{}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 토스트 시작
|
||||
loadingToastId = toast.loading("외부 호출 테스트 중...", {
|
||||
duration: 12000, // 12초 후 자동으로 사라짐
|
||||
});
|
||||
|
||||
// 타임아웃을 위한 Promise.race 사용
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("테스트 요청이 10초 내에 완료되지 않았습니다.")), 10000);
|
||||
});
|
||||
|
||||
const testPromise = ExternalCallAPI.testExternalCall({
|
||||
settings: backendSettings,
|
||||
templateData: {
|
||||
recordCount: 5,
|
||||
tableName: "test_table",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "데이터플로우 테스트 실행",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await Promise.race([testPromise, timeoutPromise]);
|
||||
|
||||
// 로딩 토스트 제거
|
||||
if (loadingToastId) {
|
||||
toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
if (result.success && result.result?.success) {
|
||||
toast.success("외부 호출 테스트 성공!", {
|
||||
description: `응답 시간: ${result.result.executionTime}ms`,
|
||||
duration: 4000,
|
||||
});
|
||||
} else {
|
||||
toast.error("외부 호출 테스트 실패", {
|
||||
description: result.result?.error || result.error || "알 수 없는 오류",
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테스트 실행 중 오류:", error);
|
||||
|
||||
// 로딩 토스트 제거
|
||||
if (loadingToastId) {
|
||||
toast.dismiss(loadingToastId);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.", {
|
||||
description: error.message,
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
toast.error("테스트 실행 중 알 수 없는 오류가 발생했습니다.", {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium">외부 호출 설정</span>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium">외부 호출 설정</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestExternalCall(settings)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
테스트
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
@ -27,7 +146,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
|||
</Label>
|
||||
<Select
|
||||
value={settings.callType}
|
||||
onValueChange={(value: "rest-api" | "email" | "webhook" | "kakao-talk" | "ftp" | "queue") =>
|
||||
onValueChange={(value: "rest-api" | "email" | "ftp" | "queue") =>
|
||||
onSettingsChange({ ...settings, callType: value })
|
||||
}
|
||||
>
|
||||
|
|
@ -36,9 +155,7 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rest-api">REST API 호출</SelectItem>
|
||||
<SelectItem value="kakao-talk"> 카카오톡 알림</SelectItem>
|
||||
<SelectItem value="email">이메일 전송</SelectItem>
|
||||
<SelectItem value="webhook">웹훅</SelectItem>
|
||||
<SelectItem value="ftp">FTP 업로드</SelectItem>
|
||||
<SelectItem value="queue">메시지 큐</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -48,124 +165,211 @@ export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ sett
|
|||
{settings.callType === "rest-api" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="apiUrl" className="text-sm">
|
||||
API URL
|
||||
<Label htmlFor="apiType" className="text-sm">
|
||||
API 종류
|
||||
</Label>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="httpMethod" className="text-sm">
|
||||
HTTP Method
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.httpMethod}
|
||||
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
|
||||
onSettingsChange({ ...settings, httpMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="headers" className="text-sm">
|
||||
Headers
|
||||
</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
value={settings.headers}
|
||||
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={1}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="bodyTemplate" className="text-sm">
|
||||
Body Template
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bodyTemplate"
|
||||
value={settings.bodyTemplate}
|
||||
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.callType === "kakao-talk" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="kakaoAccessToken" className="text-sm">
|
||||
카카오 액세스 토큰 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="kakaoAccessToken"
|
||||
type="password"
|
||||
value={settings.kakaoAccessToken || ""}
|
||||
onChange={(e) =>
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
kakaoAccessToken: e.target.value,
|
||||
})
|
||||
<Select
|
||||
value={settings.apiType || "generic"}
|
||||
onValueChange={(value: "slack" | "kakao-talk" | "discord" | "generic") =>
|
||||
onSettingsChange({ ...settings, apiType: value })
|
||||
}
|
||||
placeholder="카카오 개발자 센터에서 발급받은 토큰"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
💡{" "}
|
||||
<a
|
||||
href="https://developers.kakao.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
카카오 개발자 센터
|
||||
</a>
|
||||
에서 앱 등록 후 토큰을 발급받으세요
|
||||
</p>
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="slack">슬랙</SelectItem>
|
||||
<SelectItem value="kakao-talk">카카오톡</SelectItem>
|
||||
<SelectItem value="discord">디스코드</SelectItem>
|
||||
<SelectItem value="generic">기타 (일반 API)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="kakaoMessage" className="text-sm">
|
||||
메시지 템플릿 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="kakaoMessage"
|
||||
value={settings.bodyTemplate || ""}
|
||||
onChange={(e) =>
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
bodyTemplate: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="안녕하세요! {{customer_name}}님의 주문({{order_id}})이 처리되었습니다."
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
💡 {"{{"} 필드명 {"}"} 형태로 데이터를 삽입할 수 있습니다 (예: {"{{"} user_name {"}"}, {"{{"} amount{" "}
|
||||
{"}"})
|
||||
</p>
|
||||
</div>
|
||||
{/* 슬랙 설정 */}
|
||||
{settings.apiType === "slack" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="slackWebhookUrl" className="text-sm">
|
||||
슬랙 웹훅 URL
|
||||
</Label>
|
||||
<Input
|
||||
id="slackWebhookUrl"
|
||||
value={settings.slackWebhookUrl || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, slackWebhookUrl: e.target.value })}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slackChannel" className="text-sm">
|
||||
채널
|
||||
</Label>
|
||||
<Input
|
||||
id="slackChannel"
|
||||
value={settings.slackChannel || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, slackChannel: e.target.value })}
|
||||
placeholder="#general"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slackMessage" className="text-sm">
|
||||
메시지 템플릿
|
||||
</Label>
|
||||
<Textarea
|
||||
id="slackMessage"
|
||||
value={settings.slackMessage || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, slackMessage: e.target.value })}
|
||||
placeholder="데이터 처리가 완료되었습니다. 총 {{recordCount}}건이 처리되었습니다."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 카카오톡 설정 */}
|
||||
{settings.apiType === "kakao-talk" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="kakaoAccessToken" className="text-sm">
|
||||
카카오 액세스 토큰 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="kakaoAccessToken"
|
||||
type="password"
|
||||
value={settings.kakaoAccessToken || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, kakaoAccessToken: e.target.value })}
|
||||
placeholder="카카오 API 액세스 토큰을 입력하세요"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="kakaoMessage" className="text-sm">
|
||||
메시지 내용
|
||||
</Label>
|
||||
<Textarea
|
||||
id="kakaoMessage"
|
||||
value={settings.kakaoMessage || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, kakaoMessage: e.target.value })}
|
||||
placeholder="데이터 처리 완료! 총 {{recordCount}}건 처리되었습니다."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 디스코드 설정 */}
|
||||
{settings.apiType === "discord" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="discordWebhookUrl" className="text-sm">
|
||||
디스코드 웹훅 URL <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="discordWebhookUrl"
|
||||
value={settings.discordWebhookUrl || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, discordWebhookUrl: e.target.value })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="discordUsername" className="text-sm">
|
||||
이름
|
||||
</Label>
|
||||
<Input
|
||||
id="discordUsername"
|
||||
value={settings.discordUsername || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, discordUsername: e.target.value })}
|
||||
placeholder="ERP 시스템"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="discordMessage" className="text-sm">
|
||||
메시지 내용
|
||||
</Label>
|
||||
<Textarea
|
||||
id="discordMessage"
|
||||
value={settings.discordMessage || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, discordMessage: e.target.value })}
|
||||
placeholder="데이터 처리가 완료되었습니다! 🎉"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 일반 API 설정 */}
|
||||
{settings.apiType === "generic" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="apiUrl" className="text-sm">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
value={settings.apiUrl || ""}
|
||||
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="httpMethod" className="text-sm">
|
||||
HTTP Method
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.httpMethod}
|
||||
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
|
||||
onSettingsChange({ ...settings, httpMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="headers" className="text-sm">
|
||||
Headers
|
||||
</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
value={settings.headers}
|
||||
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={1}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="bodyTemplate" className="text-sm">
|
||||
Body Template
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bodyTemplate"
|
||||
value={settings.bodyTemplate}
|
||||
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
value?: string | number | boolean;
|
||||
dataType?: string;
|
||||
tableType?: "from" | "to"; // 어느 테이블의 필드인지 구분
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
// 개발 환경과 운영 환경에 따른 백엔드 URL 설정
|
||||
const backendUrl = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "http://backend:8080";
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${backendUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
// 프록시 설정 제거 - 모든 API가 직접 백엔드 호출
|
||||
|
||||
// 개발 환경에서 CORS 처리
|
||||
async headers() {
|
||||
|
|
|
|||
|
|
@ -67,15 +67,37 @@ export interface DataSaveSettings {
|
|||
|
||||
// 외부 호출 설정
|
||||
export interface ExternalCallSettings {
|
||||
callType: "rest-api" | "email" | "webhook" | "kakao-talk" | "ftp" | "queue";
|
||||
callType: "rest-api" | "email" | "ftp" | "queue";
|
||||
|
||||
// REST API 세부 종류
|
||||
apiType?: "slack" | "kakao-talk" | "discord" | "generic";
|
||||
|
||||
// 일반 REST API 설정
|
||||
apiUrl?: string;
|
||||
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
headers?: string;
|
||||
bodyTemplate?: string;
|
||||
|
||||
// 슬랙 전용 설정
|
||||
slackWebhookUrl?: string;
|
||||
slackChannel?: string;
|
||||
slackMessage?: string;
|
||||
|
||||
// 카카오톡 전용 설정
|
||||
kakaoAccessToken?: string;
|
||||
kakaoRecipient?: string;
|
||||
kakaoMessage?: string;
|
||||
|
||||
// 디스코드 전용 설정
|
||||
discordWebhookUrl?: string;
|
||||
discordMessage?: string;
|
||||
discordUsername?: string;
|
||||
}
|
||||
|
||||
// 단순화된 외부 호출 설정 (새로운 버전)
|
||||
export interface SimpleExternalCallSettings {
|
||||
configId?: number; // 선택된 외부 호출 설정 ID
|
||||
configName?: string; // 설정 이름 (표시용)
|
||||
message: string; // 메시지 템플릿
|
||||
}
|
||||
|
||||
// ConnectionSetupModal Props 타입
|
||||
|
|
|
|||
Loading…
Reference in New Issue