Compare commits

..

No commits in common. "e057c4d960d2b7c8ac3134a05e39f43e355e13e0" and "0fca8cd90b2d0177ae080df1b7ec2f5bb60d46a6" have entirely different histories.

40 changed files with 1929 additions and 10360 deletions

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.16.2",
"@prisma/client": "^5.7.1",
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
@ -46,6 +46,7 @@
"nodemailer": "^6.9.7",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"prisma": "^5.7.1",
"redis": "^4.6.10",
"winston": "^3.11.0"
},
@ -72,7 +73,6 @@
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"prettier": "^3.1.0",
"prisma": "^6.16.2",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",

View File

@ -50,27 +50,27 @@ model db_type_categories {
}
model external_db_connections {
id Int @id @default(autoincrement())
connection_name String @db.VarChar(100)
id Int @id @default(autoincrement())
connection_name String @db.VarChar(100)
description String?
db_type String @db.VarChar(20)
host String @db.VarChar(255)
db_type String @db.VarChar(20)
host String @db.VarChar(255)
port Int
database_name String @db.VarChar(100)
username String @db.VarChar(100)
database_name String @db.VarChar(100)
username String @db.VarChar(100)
password String
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_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)
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)
// 관계
db_type_category db_type_categories? @relation(fields: [db_type], references: [type_code])
@ -80,83 +80,6 @@ model external_db_connections {
@@index([db_type], map: "idx_external_db_connections_db_type")
}
model batch_configs {
id Int @id @default(autoincrement())
batch_name String @db.VarChar(100)
description String?
cron_schedule String @db.VarChar(50)
is_active String? @default("Y") @db.Char(1)
company_code String? @default("*") @db.VarChar(20)
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)
// 관계 설정
batch_mappings batch_mappings[]
execution_logs batch_execution_logs[]
@@index([batch_name], map: "idx_batch_configs_name")
@@index([is_active], map: "idx_batch_configs_active")
}
model batch_mappings {
id Int @id @default(autoincrement())
batch_config_id Int
from_connection_type String @db.VarChar(20)
from_connection_id Int?
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
from_column_type String? @db.VarChar(50)
from_api_url String? @db.VarChar(500)
from_api_key String? @db.VarChar(200)
from_api_method String? @db.VarChar(10)
to_connection_type String @db.VarChar(20)
to_connection_id Int?
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
to_column_type String? @db.VarChar(50)
to_api_url String? @db.VarChar(500)
to_api_key String? @db.VarChar(200)
to_api_method String? @db.VarChar(10)
to_api_body String? @db.Text
mapping_order Int? @default(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
// 관계 설정
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
@@index([batch_config_id], map: "idx_batch_mappings_config")
@@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from")
@@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to")
@@index([from_connection_type, from_api_url], map: "idx_batch_mappings_from_api")
@@index([to_connection_type, to_api_url], map: "idx_batch_mappings_to_api")
}
model batch_execution_logs {
id Int @id @default(autoincrement())
batch_config_id Int
execution_status String @db.VarChar(20)
start_time DateTime @default(now()) @db.Timestamp(6)
end_time DateTime? @db.Timestamp(6)
duration_ms Int?
total_records Int? @default(0)
success_records Int? @default(0)
failed_records Int? @default(0)
error_message String?
error_details String?
server_name String? @db.VarChar(100)
process_id String? @db.VarChar(50)
// 관계 설정
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
@@index([batch_config_id], map: "idx_batch_execution_logs_config")
@@index([execution_status], map: "idx_batch_execution_logs_status")
@@index([start_time], map: "idx_batch_execution_logs_start_time")
}
model admin_supply_mng {
objid Decimal @id @default(0) @db.Decimal
supply_code String? @default("NULL::character varying") @db.VarChar(100)

View File

@ -33,17 +33,12 @@ import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import screenFileRoutes from "./routes/screenFileRoutes";
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import batchRoutes from "./routes/batchRoutes";
import batchManagementRoutes from "./routes/batchManagementRoutes";
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
// import userRoutes from './routes/userRoutes';
@ -149,10 +144,7 @@ app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
@ -179,19 +171,11 @@ app.use(errorHandler);
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, async () => {
app.listen(PORT, HOST, () => {
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
logger.info(`📊 Environment: ${config.nodeEnv}`);
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 배치 스케줄러 초기화
try {
await BatchSchedulerService.initialize();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
}
});
export default app;

View File

@ -1,281 +1,294 @@
// 배치관리 컨트롤러
// 작성일: 2024-12-24
// 배치 관리 컨트롤러
// 작성일: 2024-12-23
import { Request, Response } from "express";
import { BatchService } from "../services/batchService";
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
username: string;
companyCode: string;
};
}
import { Request, Response } from 'express';
import { BatchService } from '../services/batchService';
import { BatchJob, BatchJobFilter } from '../types/batchManagement';
import { AuthenticatedRequest } from '../middleware/authMiddleware';
export class BatchController {
/**
*
* GET /api/batch-configs
*
*/
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { page = 1, limit = 10, search, isActive } = req.query;
const filter: BatchConfigFilter = {
page: Number(page),
limit: Number(limit),
search: search as string,
is_active: isActive as string
const filter: BatchJobFilter = {
job_name: req.query.job_name as string,
job_type: req.query.job_type as string,
is_active: req.query.is_active as string,
company_code: req.user?.companyCode || '*',
search: req.query.search as string,
};
const result = await BatchService.getBatchConfigs(filter);
res.json({
const jobs = await BatchService.getBatchJobs(filter);
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination
data: jobs,
message: '배치 작업 목록을 조회했습니다.',
});
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
console.error('배치 작업 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: "배치 설정 목록 조회에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.',
});
}
}
/**
*
* GET /api/batch-configs/connections
*
*/
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const result = await BatchService.getAvailableConnections();
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: '유효하지 않은 ID입니다.',
});
return;
}
const job = await BatchService.getBatchJobById(id);
if (!job) {
res.status(404).json({
success: false,
message: '배치 작업을 찾을 수 없습니다.',
});
return;
}
res.status(200).json({
success: true,
data: job,
message: '배치 작업을 조회했습니다.',
});
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
console.error('배치 작업 조회 오류:', error);
res.status(500).json({
success: false,
message: "커넥션 목록 조회에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.',
});
}
}
/**
* (/ DB)
* GET /api/batch-configs/connections/:type/tables
* GET /api/batch-configs/connections/:type/:id/tables
*
*/
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { type, id } = req.params;
if (!type || (type !== 'internal' && type !== 'external')) {
return res.status(400).json({
const data: BatchJob = {
...req.body,
company_code: req.user?.companyCode || '*',
created_by: req.user?.userId,
};
// 필수 필드 검증
if (!data.job_name || !data.job_type) {
res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
message: '필수 필드가 누락되었습니다.',
});
return;
}
const connectionId = type === 'external' ? Number(id) : undefined;
const result = await BatchService.getTablesFromConnection(type, connectionId);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "테이블 목록 조회에 실패했습니다."
});
}
}
const job = await BatchService.createBatchJob(data);
/**
* (/ DB)
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
*/
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
try {
const { type, id, tableName } = req.params;
if (!type || !tableName) {
return res.status(400).json({
success: false,
message: "연결 타입과 테이블명을 모두 지정해주세요."
});
}
if (type !== 'internal' && type !== 'external') {
return res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
});
}
const connectionId = type === 'external' ? Number(id) : undefined;
const result = await BatchService.getTableColumns(type, connectionId, tableName);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회에 실패했습니다."
});
}
}
/**
*
* GET /api/batch-configs/:id
*/
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const batchConfig = await BatchService.getBatchConfigById(Number(id));
if (!batchConfig) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다."
});
}
return res.json({
res.status(201).json({
success: true,
data: batchConfig
data: job,
message: '배치 작업을 생성했습니다.',
});
} catch (error) {
console.error("배치 설정 조회 오류:", error);
return res.status(500).json({
console.error('배치 작업 생성 오류:', error);
res.status(500).json({
success: false,
message: "배치 설정 조회에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.',
});
}
}
/**
*
* POST /api/batch-configs
*
*/
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { batchName, description, cronSchedule, mappings } = req.body;
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
return res.status(400).json({
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
message: '유효하지 않은 ID입니다.',
});
return;
}
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings
} as CreateBatchConfigRequest);
return res.status(201).json({
const data: Partial<BatchJob> = {
...req.body,
updated_by: req.user?.userId,
};
const job = await BatchService.updateBatchJob(id, data);
res.status(200).json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 생성되었습니다."
data: job,
message: '배치 작업을 수정했습니다.',
});
} catch (error) {
console.error("배치 설정 생성 오류:", error);
return res.status(500).json({
console.error('배치 작업 수정 오류:', error);
res.status(500).json({
success: false,
message: "배치 설정 생성에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.',
});
}
}
/**
*
* PUT /api/batch-configs/:id
*
*/
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
if (!batchName || !cronSchedule) {
return res.status(400).json({
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
message: '유효하지 않은 ID입니다.',
});
return;
}
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
batchName,
description,
cronSchedule,
mappings,
isActive
} as UpdateBatchConfigRequest);
if (!batchConfig) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다."
});
}
return res.json({
await BatchService.deleteBatchJob(id);
res.status(200).json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 수정되었습니다."
message: '배치 작업을 삭제했습니다.',
});
} catch (error) {
console.error("배치 설정 수정 오류:", error);
return res.status(500).json({
console.error('배치 작업 삭제 오류:', error);
res.status(500).json({
success: false,
message: "배치 설정 수정에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.',
});
}
}
/**
* ( )
* DELETE /api/batch-configs/:id
*
*/
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const result = await BatchService.deleteBatchConfig(Number(id));
if (!result) {
return res.status(404).json({
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "배치 설정을 찾을 수 없습니다."
message: '유효하지 않은 ID입니다.',
});
return;
}
return res.json({
const execution = await BatchService.executeBatchJob(id);
res.status(200).json({
success: true,
message: "배치 설정이 성공적으로 삭제되었습니다."
data: execution,
message: '배치 작업을 실행했습니다.',
});
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
return res.status(500).json({
console.error('배치 작업 실행 오류:', error);
res.status(500).json({
success: false,
message: "배치 설정 삭제에 실패했습니다."
message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.',
});
}
}
}
/**
*
*/
static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const jobId = req.query.job_id ? parseInt(req.query.job_id as string) : undefined;
const executions = await BatchService.getBatchExecutions(jobId);
res.status(200).json({
success: true,
data: executions,
message: '배치 실행 목록을 조회했습니다.',
});
} catch (error) {
console.error('배치 실행 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.',
});
}
}
/**
*
*/
static async getBatchMonitoring(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const monitoring = await BatchService.getBatchMonitoring();
res.status(200).json({
success: true,
data: monitoring,
message: '배치 모니터링 정보를 조회했습니다.',
});
} catch (error) {
console.error('배치 모니터링 조회 오류:', error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 모니터링 조회에 실패했습니다.',
});
}
}
/**
*
*/
static async getSupportedJobTypes(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { BATCH_JOB_TYPE_OPTIONS } = await import('../types/batchManagement');
res.status(200).json({
success: true,
data: {
types: BATCH_JOB_TYPE_OPTIONS,
},
message: '지원하는 작업 타입 목록을 조회했습니다.',
});
} catch (error) {
console.error('작업 타입 조회 오류:', error);
res.status(500).json({
success: false,
message: '작업 타입 조회에 실패했습니다.',
});
}
}
/**
*
*/
static async getSchedulePresets(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { SCHEDULE_PRESETS } = await import('../types/batchManagement');
res.status(200).json({
success: true,
data: {
presets: SCHEDULE_PRESETS,
},
message: '스케줄 프리셋 목록을 조회했습니다.',
});
} catch (error) {
console.error('스케줄 프리셋 조회 오류:', error);
res.status(500).json({
success: false,
message: '스케줄 프리셋 조회에 실패했습니다.',
});
}
}
}

View File

@ -1,179 +0,0 @@
// 배치 실행 로그 컨트롤러
// 작성일: 2024-12-24
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes";
export class BatchExecutionLogController {
/**
*
*/
static async getExecutionLogs(req: AuthenticatedRequest, res: Response) {
try {
const {
batch_config_id,
execution_status,
start_date,
end_date,
page,
limit
} = req.query;
const filter: BatchExecutionLogFilter = {
batch_config_id: batch_config_id ? Number(batch_config_id) : undefined,
execution_status: execution_status as string,
start_date: start_date ? new Date(start_date as string) : undefined,
end_date: end_date ? new Date(end_date as string) : undefined,
page: page ? Number(page) : undefined,
limit: limit ? Number(limit) : undefined
};
const result = await BatchExecutionLogService.getExecutionLogs(filter);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const data: CreateBatchExecutionLogRequest = req.body;
const result = await BatchExecutionLogService.createExecutionLog(data);
if (result.success) {
res.status(201).json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 생성 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async updateExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const data: UpdateBatchExecutionLogRequest = req.body;
const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 업데이트 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const result = await BatchExecutionLogService.deleteExecutionLog(Number(id));
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 로그 삭제 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
try {
const { batchConfigId } = req.params;
const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId));
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("최신 배치 실행 로그 조회 오류:", error);
res.status(500).json({
success: false,
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
try {
const {
batch_config_id,
start_date,
end_date
} = req.query;
const result = await BatchExecutionLogService.getExecutionStats(
batch_config_id ? Number(batch_config_id) : undefined,
start_date ? new Date(start_date as string) : undefined,
end_date ? new Date(end_date as string) : undefined
);
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("배치 실행 통계 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
}

View File

@ -1,619 +0,0 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchExternalDbService } from "../services/batchExternalDbService";
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
export class BatchManagementController {
/**
*
*/
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
try {
const result = await BatchManagementService.getAvailableConnections();
if (result.success) {
res.json(result);
} else {
res.status(500).json(result);
}
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "커넥션 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
try {
const { type, id } = req.params;
if (type !== 'internal' && type !== 'external') {
return res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
});
}
const connectionId = type === 'external' ? Number(id) : undefined;
const result = await BatchManagementService.getTablesFromConnection(type, connectionId);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
*/
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
try {
const { type, id, tableName } = req.params;
if (type !== 'internal' && type !== 'external') {
return res.status(400).json({
success: false,
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
});
}
const connectionId = type === 'external' ? Number(id) : undefined;
const result = await BatchManagementService.getTableColumns(type, connectionId, tableName);
if (result.success) {
return res.json(result);
} else {
return res.status(500).json(result);
}
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
* POST /api/batch-management/batch-configs
*/
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
});
}
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings,
isActive: isActive !== undefined ? isActive : true
} as CreateBatchConfigRequest);
return res.status(201).json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 생성되었습니다."
});
} catch (error) {
console.error("배치 설정 생성 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 생성에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
* GET /api/batch-management/batch-configs/:id
*/
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
console.log("🔍 배치 설정 조회 요청:", id);
const result = await BatchService.getBatchConfigById(Number(id));
if (!result.success) {
return res.status(404).json({
success: false,
message: result.message || "배치 설정을 찾을 수 없습니다."
});
}
console.log("📋 조회된 배치 설정:", result.data);
return res.json({
success: true,
data: result.data
});
} catch (error) {
console.error("❌ 배치 설정 조회 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
* GET /api/batch-management/batch-configs
*/
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
try {
const { page = 1, limit = 10, search, isActive } = req.query;
const filter = {
page: Number(page),
limit: Number(limit),
search: search as string,
is_active: isActive as string
};
const result = await BatchService.getBatchConfigs(filter);
res.json({
success: true,
data: result.data,
pagination: result.pagination
});
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "배치 설정 목록 조회에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
* POST /api/batch-management/batch-configs/:id/execute
*/
static async executeBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
if (!id || isNaN(Number(id))) {
return res.status(400).json({
success: false,
message: "올바른 배치 설정 ID를 제공해주세요."
});
}
// 배치 설정 조회
const batchConfigResult = await BatchService.getBatchConfigById(Number(id));
if (!batchConfigResult.success || !batchConfigResult.data) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다."
});
}
const batchConfig = batchConfigResult.data as BatchConfig;
// 배치 실행 로직 (간단한 버전)
const startTime = new Date();
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
try {
console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
// 실행 로그 생성
const executionLog = await BatchService.createExecutionLog({
batch_config_id: Number(id),
execution_status: 'RUNNING',
start_time: startTime,
total_records: 0,
success_records: 0,
failed_records: 0
});
// 실제 배치 실행 (매핑이 있는 경우)
if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
// 테이블별로 매핑을 그룹화
const tableGroups = new Map<string, typeof batchConfig.batch_mappings>();
for (const mapping of batchConfig.batch_mappings) {
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) {
tableGroups.set(key, []);
}
tableGroups.get(key)!.push(mapping);
}
// 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) {
try {
const firstMapping = mappings[0];
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
let fromData: any[] = [];
// FROM 데이터 조회 (DB 또는 REST API)
if (firstMapping.from_connection_type === 'restapi') {
// REST API에서 데이터 조회
console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
console.log(`API 설정:`, {
url: firstMapping.from_api_url,
key: firstMapping.from_api_key ? '***' : 'null',
method: firstMapping.from_api_method,
endpoint: firstMapping.from_table_name
});
try {
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
firstMapping.from_table_name,
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
mappings.map(m => m.from_column_name)
);
console.log(`API 조회 결과:`, {
success: apiResult.success,
dataCount: apiResult.data ? apiResult.data.length : 0,
message: apiResult.message
});
if (apiResult.success && apiResult.data) {
fromData = apiResult.data;
} else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
}
} catch (error) {
console.error(`REST API 조회 오류:`, error);
throw error;
}
} else {
// DB에서 데이터 조회
const fromColumns = mappings.map(m => m.from_column_name);
fromData = await BatchService.getDataFromTableWithColumns(
firstMapping.from_table_name,
fromColumns,
firstMapping.from_connection_type as 'internal' | 'external',
firstMapping.from_connection_id || undefined
);
}
totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
const mappedData = fromData.map(row => {
const mappedRow: any = {};
for (const mapping of mappings) {
// DB → REST API 배치인지 확인
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
} else {
// 기존 로직: to_column_name을 키로 사용
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
}
}
return mappedRow;
});
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
let insertResult: { successCount: number; failedCount: number };
if (firstMapping.to_connection_type === 'restapi') {
// REST API로 데이터 전송
console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
const hasTemplate = mappings.some(m => m.to_api_body);
if (hasTemplate) {
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
const templateBody = firstMapping.to_api_body || '{}';
console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
// URL 경로 컬럼 찾기 (PUT/DELETE용)
const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
templateBody,
mappedData,
urlPathColumn
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
}
} else {
// 기존 REST API 전송 (REST API → DB 배치)
const apiResult = await BatchExternalDbService.sendDataToRestApi(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' || 'POST',
mappedData
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
}
}
} else {
// DB에 데이터 삽입
insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name,
mappedData,
firstMapping.to_connection_type as 'internal' | 'external',
firstMapping.to_connection_id || undefined
);
}
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
} catch (error) {
console.error(`테이블 처리 실패: ${tableKey}`, error);
failedRecords += 1;
}
}
} else {
console.log("매핑이 없어서 데이터 처리를 건너뜁니다.");
}
// 실행 로그 업데이트 (성공)
await BatchService.updateExecutionLog(executionLog.id, {
execution_status: 'SUCCESS',
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: totalRecords,
success_records: successRecords,
failed_records: failedRecords
});
return res.json({
success: true,
message: "배치가 성공적으로 실행되었습니다.",
data: {
batchId: id,
totalRecords,
successRecords,
failedRecords,
duration: Date.now() - startTime.getTime()
}
});
} catch (error) {
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error);
return res.status(500).json({
success: false,
message: "배치 실행에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
} catch (error) {
console.error("배치 실행 오류:", error);
return res.status(500).json({
success: false,
message: "배치 실행 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
*
* PUT /api/batch-management/batch-configs/:id
*/
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const updateData = req.body;
if (!id || isNaN(Number(id))) {
return res.status(400).json({
success: false,
message: "올바른 배치 설정 ID를 제공해주세요."
});
}
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
// 스케줄러에서 배치 스케줄 업데이트
await BatchSchedulerService.updateBatchSchedule(Number(id));
return res.json({
success: true,
data: batchConfig,
message: "배치 설정이 성공적으로 업데이트되었습니다."
});
} catch (error) {
console.error("배치 설정 업데이트 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 업데이트에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
* REST API
*/
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
try {
const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body;
if (!apiUrl || !apiKey || !endpoint) {
return res.status(400).json({
success: false,
message: "API URL, API Key, 엔드포인트는 필수입니다."
});
}
// RestApiConnector 사용하여 데이터 조회
const { RestApiConnector } = await import('../database/RestApiConnector');
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
timeout: 30000
});
// 연결 테스트
await connector.connect();
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
const result = await connector.executeQuery(endpoint, method);
console.log(`[previewRestApiData] executeQuery 결과:`, {
rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : 'undefined',
firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data'
});
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
if (data.length > 0) {
// 첫 번째 객체에서 필드명 추출
const fields = Object.keys(data[0]);
console.log(`[previewRestApiData] 추출된 필드:`, fields);
return res.json({
success: true,
data: {
fields: fields,
samples: data,
totalCount: result.rowCount || data.length
},
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`
});
} else {
return res.json({
success: true,
data: {
fields: [],
samples: [],
totalCount: 0
},
message: "API에서 데이터를 가져올 수 없습니다."
});
}
} catch (error) {
console.error("REST API 미리보기 오류:", error);
return res.status(500).json({
success: false,
message: "REST API 데이터 미리보기 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
});
}
}
/**
* REST API
*/
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
try {
const {
batchName,
batchType,
cronSchedule,
description,
apiMappings
} = req.body;
if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다."
});
}
console.log("REST API 배치 저장 요청:", {
batchName,
batchType,
cronSchedule,
description,
apiMappings
});
// BatchService를 사용하여 배치 설정 저장
const batchConfig: CreateBatchConfigRequest = {
batchName: batchName,
description: description || '',
cronSchedule: cronSchedule,
mappings: apiMappings
};
const result = await BatchService.createBatchConfig(batchConfig);
if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅
try {
await BatchSchedulerService.scheduleBatchConfig(result.data);
console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`);
} catch (schedulerError) {
console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError);
// 스케줄러 등록 실패해도 배치 저장은 성공으로 처리
}
return res.json({
success: true,
message: "REST API 배치가 성공적으로 저장되었습니다.",
data: result.data
});
} else {
return res.status(500).json({
success: false,
message: result.message || "배치 저장에 실패했습니다."
});
}
} catch (error) {
console.error("REST API 배치 저장 오류:", error);
return res.status(500).json({
success: false,
message: "배치 저장 중 오류가 발생했습니다."
});
}
}
}

View File

@ -3,7 +3,6 @@ import { PostgreSQLConnector } from './PostgreSQLConnector';
import { MariaDBConnector } from './MariaDBConnector';
import { MSSQLConnector } from './MSSQLConnector';
import { OracleConnector } from './OracleConnector';
import { RestApiConnector, RestApiConfig } from './RestApiConnector';
export class DatabaseConnectorFactory {
private static connectors = new Map<string, DatabaseConnector>();
@ -34,9 +33,6 @@ export class DatabaseConnectorFactory {
case 'oracle':
connector = new OracleConnector(config);
break;
case 'restapi':
connector = new RestApiConnector(config as RestApiConfig);
break;
// Add other database types here
default:
throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`);

View File

@ -1,6 +1,5 @@
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mssql from 'mssql';
export class MSSQLConnector implements DatabaseConnector {

View File

@ -1,7 +1,10 @@
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mysql from 'mysql2/promise';
import {
DatabaseConnector,
ConnectionConfig,
QueryResult,
} from "../interfaces/DatabaseConnector";
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
import * as mysql from "mysql2/promise";
export class MariaDBConnector implements DatabaseConnector {
private connection: mysql.Connection | null = null;
@ -19,8 +22,18 @@ export class MariaDBConnector implements DatabaseConnector {
user: this.config.user,
password: this.config.password,
database: this.config.database,
connectTimeout: this.config.connectionTimeoutMillis,
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
// 🔧 MySQL2에서 지원하는 타임아웃 설정
connectTimeout: this.config.connectionTimeoutMillis || 30000, // 연결 타임아웃 30초
ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl,
// 🔧 MySQL2에서 지원하는 추가 설정
charset: "utf8mb4",
timezone: "Z",
supportBigNumbers: true,
bigNumberStrings: true,
// 🔧 연결 풀 설정 (단일 연결이지만 안정성을 위해)
dateStrings: true,
debug: false,
trace: false,
});
}
}
@ -36,7 +49,9 @@ export class MariaDBConnector implements DatabaseConnector {
const startTime = Date.now();
try {
await this.connect();
const [rows] = await this.connection!.query("SELECT VERSION() as version");
const [rows] = await this.connection!.query(
"SELECT VERSION() as version"
);
const version = (rows as any[])[0]?.version || "Unknown";
const responseTime = Date.now() - startTime;
await this.disconnect();
@ -64,7 +79,18 @@ export class MariaDBConnector implements DatabaseConnector {
async executeQuery(query: string): Promise<QueryResult> {
try {
await this.connect();
const [rows, fields] = await this.connection!.query(query);
// 🔧 쿼리 타임아웃 수동 구현 (60초)
const queryTimeout = this.config.queryTimeoutMillis || 60000;
const queryPromise = this.connection!.query(query);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("쿼리 실행 타임아웃")), queryTimeout);
});
const [rows, fields] = (await Promise.race([
queryPromise,
timeoutPromise,
])) as any;
await this.disconnect();
return {
rows: rows as any[],
@ -107,28 +133,54 @@ export class MariaDBConnector implements DatabaseConnector {
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
console.log(`🔍 MariaDB 컬럼 조회 시작: ${tableName}`);
await this.connect();
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
const [rows] = await this.connection!.query(`
// 🔧 컬럼 조회 타임아웃 수동 구현 (30초)
const queryTimeout = this.config.queryTimeoutMillis || 30000;
// 스키마명을 명시적으로 확인
const schemaQuery = `SELECT DATABASE() as schema_name`;
const [schemaResult] = await this.connection!.query(schemaQuery);
const schemaName =
(schemaResult as any[])[0]?.schema_name || this.config.database;
console.log(`📋 사용할 스키마: ${schemaName}`);
const query = `
SELECT
COLUMN_NAME as column_name,
DATA_TYPE as data_type,
IS_NULLABLE as is_nullable,
COLUMN_DEFAULT as column_default
COLUMN_DEFAULT as column_default,
COLUMN_COMMENT as column_comment
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
`, [tableName]);
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
`;
console.log(
`📋 실행할 쿼리: ${query.trim()}, 파라미터: [${schemaName}, ${tableName}]`
);
const queryPromise = this.connection!.query(query, [
schemaName,
tableName,
]);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("컬럼 조회 타임아웃")), queryTimeout);
});
const [rows] = (await Promise.race([
queryPromise,
timeoutPromise,
])) as any;
console.log(
`✅ MariaDB 컬럼 조회 완료: ${tableName}, ${rows ? rows.length : 0}개 컬럼`
);
await this.disconnect();
return rows as any[];
} catch (error: any) {
console.error(`[MariaDBConnector] getColumns 오류:`, error);
await this.disconnect();
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
}

View File

@ -1,4 +1,3 @@
// @ts-ignore
import * as oracledb from 'oracledb';
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
@ -101,7 +100,7 @@ export class OracleConnector implements DatabaseConnector {
// Oracle XE 21c 쿼리 실행 옵션
const options: any = {
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
maxRows: 10000, // XE 제한 고려
fetchArraySize: 100
};
@ -177,8 +176,6 @@ export class OracleConnector implements DatabaseConnector {
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`);
const query = `
SELECT
column_name,
@ -193,23 +190,16 @@ export class OracleConnector implements DatabaseConnector {
ORDER BY column_id
`;
console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`);
const result = await this.executeQuery(query, [tableName]);
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
const mappedResult = result.rows.map((row: any) => ({
return result.rows.map((row: any) => ({
column_name: row.COLUMN_NAME,
data_type: this.formatOracleDataType(row),
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
column_default: row.DATA_DEFAULT
}));
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
return mappedResult;
} catch (error: any) {
console.error('[OracleConnector] getColumns 오류:', error);
console.error('Oracle 테이블 컬럼 조회 실패:', error);
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
}
}

View File

@ -1,261 +0,0 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
export interface RestApiConfig {
baseUrl: string;
apiKey: string;
timeout?: number;
// ConnectionConfig 호환성을 위한 더미 필드들 (사용하지 않음)
host?: string;
port?: number;
database?: string;
user?: string;
password?: string;
}
export class RestApiConnector implements DatabaseConnector {
private httpClient: AxiosInstance;
private config: RestApiConfig;
constructor(config: RestApiConfig) {
this.config = config;
// Axios 인스턴스 생성
this.httpClient = axios.create({
baseURL: config.baseUrl,
timeout: config.timeout || 30000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
'Accept': 'application/json'
}
});
// 요청/응답 인터셉터 설정
this.setupInterceptors();
}
private setupInterceptors() {
// 요청 인터셉터
this.httpClient.interceptors.request.use(
(config) => {
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
console.error('[RestApiConnector] 요청 오류:', error);
return Promise.reject(error);
}
);
// 응답 인터셉터
this.httpClient.interceptors.response.use(
(response) => {
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
return response;
},
(error) => {
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
return Promise.reject(error);
}
);
}
async connect(): Promise<void> {
try {
// 연결 테스트 - 기본 엔드포인트 호출
await this.httpClient.get('/health', { timeout: 5000 });
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
} catch (error) {
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
if (axios.isAxiosError(error) && error.response?.status === 404) {
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
return;
}
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
}
async disconnect(): Promise<void> {
// REST API는 연결 해제가 필요 없음
console.log(`[RestApiConnector] 연결 해제: ${this.config.baseUrl}`);
}
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.connect();
return {
success: true,
message: 'REST API 연결이 성공했습니다.',
details: {
response_time: Date.now()
}
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
details: {
response_time: Date.now()
}
};
}
}
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
try {
const startTime = Date.now();
let response: AxiosResponse;
// HTTP 메서드에 따른 요청 실행
switch (method.toUpperCase()) {
case 'GET':
response = await this.httpClient.get(endpoint);
break;
case 'POST':
response = await this.httpClient.post(endpoint, data);
break;
case 'PUT':
response = await this.httpClient.put(endpoint, data);
break;
case 'DELETE':
response = await this.httpClient.delete(endpoint);
break;
default:
throw new Error(`지원하지 않는 HTTP 메서드: ${method}`);
}
const executionTime = Date.now() - startTime;
const responseData = response.data;
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
type: typeof responseData,
isArray: Array.isArray(responseData),
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
responseData: responseData
});
// 응답 데이터 처리
let rows: any[];
if (Array.isArray(responseData)) {
rows = responseData;
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
// API 응답이 {success: true, data: [...]} 형태인 경우
rows = responseData.data;
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
rows = [responseData.data];
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
// 단일 객체 응답인 경우
rows = [responseData];
} else {
rows = [];
}
console.log(`[RestApiConnector] 처리된 rows:`, {
rowsLength: rows.length,
firstRow: rows.length > 0 ? rows[0] : 'no data',
allRows: rows
});
console.log(`[RestApiConnector] API 호출 결과:`, {
endpoint,
method,
status: response.status,
rowCount: rows.length,
executionTime: `${executionTime}ms`
});
return {
rows: rows,
rowCount: rows.length,
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
};
} catch (error) {
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
if (axios.isAxiosError(error)) {
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
}
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
}
async getTables(): Promise<TableInfo[]> {
// REST API의 경우 "테이블"은 사용 가능한 엔드포인트를 의미
// 일반적인 REST API 엔드포인트들을 반환
return [
{
table_name: '/api/users',
columns: [],
description: '사용자 정보 API'
},
{
table_name: '/api/data',
columns: [],
description: '기본 데이터 API'
},
{
table_name: '/api/custom',
columns: [],
description: '사용자 정의 엔드포인트'
}
];
}
async getTableList(): Promise<TableInfo[]> {
return this.getTables();
}
async getColumns(endpoint: string): Promise<any[]> {
try {
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
const result = await this.executeQuery(endpoint, 'GET');
if (result.rows.length > 0) {
const sampleRow = result.rows[0];
return Object.keys(sampleRow).map(key => ({
column_name: key,
data_type: typeof sampleRow[key],
is_nullable: 'YES',
column_default: null,
description: `${key} 필드`
}));
}
return [];
} catch (error) {
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
return [];
}
}
async getTableColumns(endpoint: string): Promise<any[]> {
return this.getColumns(endpoint);
}
// REST API 전용 메서드들
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
const result = await this.executeQuery(endpoint + queryString, 'GET');
return result.rows;
}
async postData(endpoint: string, data: any): Promise<any> {
const result = await this.executeQuery(endpoint, 'POST', data);
return result.rows[0];
}
async putData(endpoint: string, data: any): Promise<any> {
const result = await this.executeQuery(endpoint, 'PUT', data);
return result.rows[0];
}
async deleteData(endpoint: string): Promise<any> {
const result = await this.executeQuery(endpoint, 'DELETE');
return result.rows[0];
}
}

View File

@ -1,47 +0,0 @@
// 배치 실행 로그 라우트
// 작성일: 2024-12-24
import { Router } from "express";
import { BatchExecutionLogController } from "../controllers/batchExecutionLogController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
/**
* GET /api/batch-execution-logs
*
*/
router.get("/", authenticateToken, BatchExecutionLogController.getExecutionLogs);
/**
* POST /api/batch-execution-logs
*
*/
router.post("/", authenticateToken, BatchExecutionLogController.createExecutionLog);
/**
* PUT /api/batch-execution-logs/:id
*
*/
router.put("/:id", authenticateToken, BatchExecutionLogController.updateExecutionLog);
/**
* DELETE /api/batch-execution-logs/:id
*
*/
router.delete("/:id", authenticateToken, BatchExecutionLogController.deleteExecutionLog);
/**
* GET /api/batch-execution-logs/latest/:batchConfigId
*
*/
router.get("/latest/:batchConfigId", authenticateToken, BatchExecutionLogController.getLatestExecutionLog);
/**
* GET /api/batch-execution-logs/stats
*
*/
router.get("/stats", authenticateToken, BatchExecutionLogController.getExecutionStats);
export default router;

View File

@ -1,82 +0,0 @@
// 배치관리 전용 라우트 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { Router } from "express";
import { BatchManagementController } from "../controllers/batchManagementController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
/**
* GET /api/batch-management/connections
*
*/
router.get("/connections", authenticateToken, BatchManagementController.getAvailableConnections);
/**
* GET /api/batch-management/connections/:type/tables
* DB
*/
router.get("/connections/:type/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
/**
* GET /api/batch-management/connections/:type/:id/tables
* DB
*/
router.get("/connections/:type/:id/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
/**
* GET /api/batch-management/connections/:type/tables/:tableName/columns
* DB
*/
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
/**
* GET /api/batch-management/connections/:type/:id/tables/:tableName/columns
* DB
*/
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
/**
* POST /api/batch-management/batch-configs
*
*/
router.post("/batch-configs", authenticateToken, BatchManagementController.createBatchConfig);
/**
* GET /api/batch-management/batch-configs
*
*/
router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs);
/**
* GET /api/batch-management/batch-configs/:id
*
*/
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
/**
* PUT /api/batch-management/batch-configs/:id
*
*/
router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig);
/**
* POST /api/batch-management/batch-configs/:id/execute
*
*/
router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig);
/**
* POST /api/batch-management/rest-api/preview
* REST API
*/
router.post("/rest-api/preview", authenticateToken, BatchManagementController.previewRestApiData);
/**
* POST /api/batch-management/rest-api/save
* REST API
*/
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
export default router;

View File

@ -1,70 +1,73 @@
// 배치관리 라우트
// 작성일: 2024-12-24
// 배치 관리 라우트
// 작성일: 2024-12-23
import { Router } from "express";
import { BatchController } from "../controllers/batchController";
import { authenticateToken } from "../middleware/authMiddleware";
import { Router } from 'express';
import { BatchController } from '../controllers/batchController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
/**
* GET /api/batch-configs
*
*/
router.get("/", authenticateToken, BatchController.getBatchConfigs);
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* GET /api/batch-configs/connections
*
* GET /api/batch
*
*/
router.get("/connections", BatchController.getAvailableConnections);
router.get('/', BatchController.getBatchJobs);
/**
* GET /api/batch-configs/connections/:type/tables
* DB
* GET /api/batch/:id
*
*/
router.get("/connections/:type/tables", authenticateToken, BatchController.getTablesFromConnection);
router.get('/:id', BatchController.getBatchJobById);
/**
* GET /api/batch-configs/connections/:type/:id/tables
* DB
* POST /api/batch
*
*/
router.get("/connections/:type/:id/tables", authenticateToken, BatchController.getTablesFromConnection);
router.post('/', BatchController.createBatchJob);
/**
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
* DB
* PUT /api/batch/:id
*
*/
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
router.put('/:id', BatchController.updateBatchJob);
/**
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
* DB
* DELETE /api/batch/:id
*
*/
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
router.delete('/:id', BatchController.deleteBatchJob);
/**
* GET /api/batch-configs/:id
*
* POST /api/batch/:id/execute
*
*/
router.get("/:id", authenticateToken, BatchController.getBatchConfigById);
router.post('/:id/execute', BatchController.executeBatchJob);
/**
* POST /api/batch-configs
*
* GET /api/batch/executions
*
*/
router.post("/", authenticateToken, BatchController.createBatchConfig);
router.get('/executions/list', BatchController.getBatchExecutions);
/**
* PUT /api/batch-configs/:id
*
* GET /api/batch/monitoring
*
*/
router.put("/:id", authenticateToken, BatchController.updateBatchConfig);
router.get('/monitoring/status', BatchController.getBatchMonitoring);
/**
* DELETE /api/batch-configs/:id
* ( )
* GET /api/batch/types/supported
*
*/
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
router.get('/types/supported', BatchController.getSupportedJobTypes);
export default router;
/**
* GET /api/batch/schedules/presets
*
*/
router.get('/schedules/presets', BatchController.getSchedulePresets);
export default router;

View File

@ -1,299 +0,0 @@
// 배치 실행 로그 서비스
// 작성일: 2024-12-24
import prisma from "../config/database";
import {
BatchExecutionLog,
CreateBatchExecutionLogRequest,
UpdateBatchExecutionLogRequest,
BatchExecutionLogFilter,
BatchExecutionLogWithConfig
} from "../types/batchExecutionLogTypes";
import { ApiResponse } from "../types/batchTypes";
export class BatchExecutionLogService {
/**
*
*/
static async getExecutionLogs(
filter: BatchExecutionLogFilter = {}
): Promise<ApiResponse<BatchExecutionLogWithConfig[]>> {
try {
const {
batch_config_id,
execution_status,
start_date,
end_date,
page = 1,
limit = 50
} = filter;
const skip = (page - 1) * limit;
const take = limit;
// WHERE 조건 구성
const where: any = {};
if (batch_config_id) {
where.batch_config_id = batch_config_id;
}
if (execution_status) {
where.execution_status = execution_status;
}
if (start_date || end_date) {
where.start_time = {};
if (start_date) {
where.start_time.gte = start_date;
}
if (end_date) {
where.start_time.lte = end_date;
}
}
// 로그 조회
const [logs, total] = await Promise.all([
prisma.batch_execution_logs.findMany({
where,
include: {
batch_config: {
select: {
id: true,
batch_name: true,
description: true,
cron_schedule: true,
is_active: true
}
}
},
orderBy: { start_time: 'desc' },
skip,
take
}),
prisma.batch_execution_logs.count({ where })
]);
return {
success: true,
data: logs as BatchExecutionLogWithConfig[],
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
} catch (error) {
console.error("배치 실행 로그 조회 실패:", error);
return {
success: false,
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async createExecutionLog(
data: CreateBatchExecutionLogRequest
): Promise<ApiResponse<BatchExecutionLog>> {
try {
const log = await prisma.batch_execution_logs.create({
data: {
batch_config_id: data.batch_config_id,
execution_status: data.execution_status,
start_time: data.start_time || new Date(),
end_time: data.end_time,
duration_ms: data.duration_ms,
total_records: data.total_records || 0,
success_records: data.success_records || 0,
failed_records: data.failed_records || 0,
error_message: data.error_message,
error_details: data.error_details,
server_name: data.server_name || process.env.HOSTNAME || 'unknown',
process_id: data.process_id || process.pid?.toString()
}
});
return {
success: true,
data: log as BatchExecutionLog,
message: "배치 실행 로그가 생성되었습니다."
};
} catch (error) {
console.error("배치 실행 로그 생성 실패:", error);
return {
success: false,
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async updateExecutionLog(
id: number,
data: UpdateBatchExecutionLogRequest
): Promise<ApiResponse<BatchExecutionLog>> {
try {
const log = await prisma.batch_execution_logs.update({
where: { id },
data: {
execution_status: data.execution_status,
end_time: data.end_time,
duration_ms: data.duration_ms,
total_records: data.total_records,
success_records: data.success_records,
failed_records: data.failed_records,
error_message: data.error_message,
error_details: data.error_details
}
});
return {
success: true,
data: log as BatchExecutionLog,
message: "배치 실행 로그가 업데이트되었습니다."
};
} catch (error) {
console.error("배치 실행 로그 업데이트 실패:", error);
return {
success: false,
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
try {
await prisma.batch_execution_logs.delete({
where: { id }
});
return {
success: true,
message: "배치 실행 로그가 삭제되었습니다."
};
} catch (error) {
console.error("배치 실행 로그 삭제 실패:", error);
return {
success: false,
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getLatestExecutionLog(
batchConfigId: number
): Promise<ApiResponse<BatchExecutionLog | null>> {
try {
const log = await prisma.batch_execution_logs.findFirst({
where: { batch_config_id: batchConfigId },
orderBy: { start_time: 'desc' }
});
return {
success: true,
data: log as BatchExecutionLog | null
};
} catch (error) {
console.error("최신 배치 실행 로그 조회 실패:", error);
return {
success: false,
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getExecutionStats(
batchConfigId?: number,
startDate?: Date,
endDate?: Date
): Promise<ApiResponse<{
total_executions: number;
success_count: number;
failed_count: number;
success_rate: number;
average_duration_ms: number;
total_records_processed: number;
}>> {
try {
const where: any = {};
if (batchConfigId) {
where.batch_config_id = batchConfigId;
}
if (startDate || endDate) {
where.start_time = {};
if (startDate) {
where.start_time.gte = startDate;
}
if (endDate) {
where.start_time.lte = endDate;
}
}
const logs = await prisma.batch_execution_logs.findMany({
where,
select: {
execution_status: true,
duration_ms: true,
total_records: true
}
});
const total_executions = logs.length;
const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length;
const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length;
const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0;
const validDurations = logs
.filter((log: any) => log.duration_ms !== null)
.map((log: any) => log.duration_ms!);
const average_duration_ms = validDurations.length > 0
? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length
: 0;
const total_records_processed = logs
.filter((log: any) => log.total_records !== null)
.reduce((sum: number, log: any) => sum + (log.total_records || 0), 0);
return {
success: true,
data: {
total_executions,
success_count,
failed_count,
success_rate,
average_duration_ms,
total_records_processed
}
};
} catch (error) {
console.error("배치 실행 통계 조회 실패:", error);
return {
success: false,
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
}

View File

@ -1,912 +0,0 @@
// 배치관리 전용 외부 DB 서비스
// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공
// 작성일: 2024-12-24
import prisma from "../config/database";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { RestApiConnector } from "../database/RestApiConnector";
import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes";
export class BatchExternalDbService {
/**
* DB
*/
static async getAvailableConnections(): Promise<ApiResponse<Array<{
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}>>> {
try {
const connections: Array<{
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}> = [];
// 내부 DB 추가
connections.push({
type: 'internal',
name: '내부 데이터베이스 (PostgreSQL)',
db_type: 'postgresql'
});
// 활성화된 외부 DB 연결 조회
const externalConnections = await prisma.external_db_connections.findMany({
where: { is_active: 'Y' },
select: {
id: true,
connection_name: true,
db_type: true,
description: true
},
orderBy: { connection_name: 'asc' }
});
// 외부 DB 연결 추가
externalConnections.forEach(conn => {
connections.push({
type: 'external',
id: conn.id,
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
db_type: conn.db_type || undefined
});
});
return {
success: true,
data: connections,
message: `${connections.length}개의 연결을 조회했습니다.`
};
} catch (error) {
console.error("배치관리 연결 목록 조회 실패:", error);
return {
success: false,
message: "연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
): Promise<ApiResponse<TableInfo[]>> {
try {
let tables: TableInfo[] = [];
if (connectionType === 'internal') {
// 내부 DB 테이블 조회
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`;
tables = result.map(row => ({
table_name: row.table_name,
columns: []
}));
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 테이블 조회
const tablesResult = await this.getExternalTables(connectionId);
if (tablesResult.success && tablesResult.data) {
tables = tablesResult.data;
}
}
return {
success: true,
data: tables,
message: `${tables.length}개의 테이블을 조회했습니다.`
};
} catch (error) {
console.error("배치관리 테이블 목록 조회 실패:", error);
return {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
connectionId: number | undefined,
tableName: string
): Promise<ApiResponse<ColumnInfo[]>> {
try {
console.log(`[BatchExternalDbService] getTableColumns 호출:`, {
connectionType,
connectionId,
tableName
});
let columns: ColumnInfo[] = [];
if (connectionType === 'internal') {
// 내부 DB 컬럼 조회
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`);
const result = await prisma.$queryRaw<Array<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null
}>>`
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
ORDER BY ordinal_position
`;
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result);
columns = result.map(row => ({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable,
column_default: row.column_default,
}));
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 컬럼 조회
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult);
if (columnsResult.success && columnsResult.data) {
columns = columnsResult.data;
}
}
console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns);
return {
success: true,
data: columns,
message: `${columns.length}개의 컬럼을 조회했습니다.`
};
} catch (error) {
console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error);
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB ( )
*/
private static async getExternalTables(connectionId: number): Promise<ApiResponse<TableInfo[]>> {
try {
// 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
return {
success: false,
message: "연결 정보를 찾을 수 없습니다."
};
}
// 비밀번호 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
if (!decryptedPassword) {
return {
success: false,
message: "비밀번호 복호화에 실패했습니다."
};
}
// 연결 설정 준비
const config = {
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
// DatabaseConnectorFactory를 통한 테이블 목록 조회
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
const tables = await connector.getTables();
return {
success: true,
message: "테이블 목록을 조회했습니다.",
data: tables
};
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
return {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB ( )
*/
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<ColumnInfo[]>> {
try {
console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
// 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
return {
success: false,
message: "연결 정보를 찾을 수 없습니다."
};
}
console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, {
id: connection.id,
connection_name: connection.connection_name,
db_type: connection.db_type,
host: connection.host,
port: connection.port,
database_name: connection.database_name
});
// 비밀번호 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// 연결 설정 준비
const config = {
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`);
// 데이터베이스 타입에 따른 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
// 컬럼 정보 조회
console.log(`[BatchExternalDbService] connector.getColumns 호출 전`);
const columns = await connector.getColumns(tableName);
console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns);
console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
const standardizedColumns: ColumnInfo[] = columns.map((col: any) => {
console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col);
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
if (col.name && col.dataType !== undefined) {
const result = {
column_name: col.name,
data_type: col.dataType,
is_nullable: col.isNullable ? 'YES' : 'NO',
column_default: col.defaultValue || null,
};
console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result);
return result;
}
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
else {
const result = {
column_name: col.column_name || col.COLUMN_NAME,
data_type: col.data_type || col.DATA_TYPE,
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
column_default: col.column_default || col.COLUMN_DEFAULT || null,
};
console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result);
return result;
}
});
console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns);
// 빈 배열인 경우 경고 로그
if (!standardizedColumns || standardizedColumns.length === 0) {
console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`);
console.warn(`[BatchExternalDbService] 연결 정보:`, {
db_type: connection.db_type,
host: connection.host,
port: connection.port,
database_name: connection.database_name,
username: connection.username
});
// 테이블 존재 여부 확인
console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`);
try {
const tables = await connector.getTables();
console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name));
// 테이블명이 정확한지 확인
const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase());
console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`);
// 정확한 테이블명 찾기
const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase());
if (exactTable) {
console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`);
}
// 모든 테이블명 출력
console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`));
// 테이블명 비교
console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`);
console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({
table_name: t.table_name,
matches: t.table_name.toLowerCase() === tableName.toLowerCase(),
exact_match: t.table_name === tableName
})));
// 정확한 테이블명으로 다시 시도
if (exactTable && exactTable.table_name !== tableName) {
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`);
try {
const correctColumns = await connector.getColumns(exactTable.table_name);
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns);
} catch (correctError) {
console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError);
}
}
} catch (tableError) {
console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError);
}
}
return {
success: true,
data: standardizedColumns,
message: "컬럼 정보를 조회했습니다."
};
} catch (error) {
console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error);
console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB
*/
static async getDataFromTable(
connectionId: number,
tableName: string,
limit: number = 100
): Promise<ApiResponse<any[]>> {
try {
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`);
// 외부 DB 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
return {
success: false,
message: "외부 DB 연결을 찾을 수 없습니다."
};
}
// 패스워드 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
};
// DB 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type || 'postgresql',
config,
connectionId
);
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
let query: string;
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
if (dbType === 'oracle') {
query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`;
} else {
query = `SELECT * FROM ${tableName} LIMIT ${limit}`;
}
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
const result = await connector.executeQuery(query);
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`);
return {
success: true,
data: result.rows
};
} catch (error) {
console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
return {
success: false,
message: "외부 DB 데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB
*/
static async getDataFromTableWithColumns(
connectionId: number,
tableName: string,
columns: string[],
limit: number = 100
): Promise<ApiResponse<any[]>> {
try {
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`);
// 외부 DB 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
return {
success: false,
message: "외부 DB 연결을 찾을 수 없습니다."
};
}
// 패스워드 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
};
// DB 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type || 'postgresql',
config,
connectionId
);
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
let query: string;
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
const columnList = columns.join(', ');
if (dbType === 'oracle') {
query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`;
} else {
query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
}
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
const result = await connector.executeQuery(query);
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`);
return {
success: true,
data: result.rows
};
} catch (error) {
console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
return {
success: false,
message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB
*/
static async insertDataToTable(
connectionId: number,
tableName: string,
data: any[]
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
try {
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`);
if (!data || data.length === 0) {
return {
success: true,
data: { successCount: 0, failedCount: 0 }
};
}
// 외부 DB 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
return {
success: false,
message: "외부 DB 연결을 찾을 수 없습니다."
};
}
// 패스워드 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
};
// DB 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type || 'postgresql',
config,
connectionId
);
let successCount = 0;
let failedCount = 0;
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
for (const record of data) {
try {
const columns = Object.keys(record);
const values = Object.values(record);
// 값들을 SQL 문자열로 변환 (타입별 처리)
const formattedValues = values.map(value => {
if (value === null || value === undefined) {
return 'NULL';
} else if (value instanceof Date) {
// Date 객체를 MySQL/MariaDB 형식으로 변환
return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`;
} else if (typeof value === 'string') {
// 문자열이 날짜 형식인지 확인
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
if (dateRegex.test(value)) {
// JavaScript Date 문자열을 MySQL 형식으로 변환
const date = new Date(value);
return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`;
} else {
return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프
}
} else if (typeof value === 'number') {
return String(value);
} else if (typeof value === 'boolean') {
return value ? '1' : '0';
} else {
// 기타 객체는 문자열로 변환
return `'${String(value).replace(/'/g, "''")}'`;
}
}).join(', ');
// Primary Key 컬럼 추정
const primaryKeyColumn = columns.includes('id') ? 'id' :
columns.includes('user_id') ? 'user_id' :
columns[0];
// UPDATE SET 절 생성 (Primary Key 제외)
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
let query: string;
const dbType = connection.db_type?.toLowerCase() || 'mysql';
if (dbType === 'mysql' || dbType === 'mariadb') {
// MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용
if (updateColumns.length > 0) {
const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', ');
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})
ON DUPLICATE KEY UPDATE ${updateSet}`;
} else {
// Primary Key만 있는 경우 IGNORE 사용
query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
}
} else {
// 다른 DB는 기본 INSERT 사용
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
}
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
console.log(`[BatchExternalDbService] 삽입할 데이터:`, record);
await connector.executeQuery(query);
successCount++;
} catch (error) {
console.error(`외부 DB 레코드 UPSERT 실패:`, error);
failedCount++;
}
}
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}`);
return {
success: true,
data: { successCount, failedCount }
};
} catch (error) {
console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
return {
success: false,
message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* REST API에서
*/
static async getDataFromRestApi(
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
columns?: string[],
limit: number = 100
): Promise<ApiResponse<any[]>> {
try {
console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`);
// REST API 커넥터 생성
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
timeout: 30000
});
// 연결 테스트
await connector.connect();
// 데이터 조회
const result = await connector.executeQuery(endpoint, method);
let data = result.rows;
// 컬럼 필터링 (지정된 컬럼만 추출)
if (columns && columns.length > 0) {
data = data.map(row => {
const filteredRow: any = {};
columns.forEach(col => {
if (row.hasOwnProperty(col)) {
filteredRow[col] = row[col];
}
});
return filteredRow;
});
}
// 제한 개수 적용
if (limit > 0) {
data = data.slice(0, limit);
}
console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
return {
success: true,
data: data
};
} catch (error) {
console.error(`[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, error);
return {
success: false,
message: "REST API 데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* 릿 REST API로 (DB REST API )
*/
static async sendDataToRestApiWithTemplate(
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'POST' | 'PUT' | 'DELETE' = 'POST',
templateBody: string,
data: any[],
urlPathColumn?: string // URL 경로에 사용할 컬럼명 (PUT/DELETE용)
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
try {
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
console.log(`[BatchExternalDbService] Request Body 템플릿:`, templateBody);
// REST API 커넥터 생성
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
timeout: 30000
});
// 연결 테스트
await connector.connect();
let successCount = 0;
let failedCount = 0;
// 각 레코드를 개별적으로 전송
for (const record of data) {
try {
// 템플릿 처리: {{컬럼명}} → 실제 값으로 치환
let processedBody = templateBody;
for (const [key, value] of Object.entries(record)) {
const placeholder = `{{${key}}}`;
let stringValue = '';
if (value !== null && value !== undefined) {
// Date 객체인 경우 다양한 포맷으로 변환
if (value instanceof Date) {
// ISO 형식: 2025-09-25T07:22:52.000Z
stringValue = value.toISOString();
// 다른 포맷이 필요한 경우 여기서 처리
// 예: YYYY-MM-DD 형식
// stringValue = value.toISOString().split('T')[0];
// 예: YYYY-MM-DD HH:mm:ss 형식
// stringValue = value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
} else {
stringValue = String(value);
}
}
processedBody = processedBody.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), stringValue);
}
console.log(`[BatchExternalDbService] 원본 레코드:`, record);
console.log(`[BatchExternalDbService] 처리된 Request Body:`, processedBody);
// JSON 파싱하여 객체로 변환
let requestData;
try {
requestData = JSON.parse(processedBody);
} catch (parseError) {
console.error(`[BatchExternalDbService] JSON 파싱 오류:`, parseError);
throw new Error(`Request Body JSON 파싱 실패: ${parseError}`);
}
// URL 경로 파라미터 처리 (PUT/DELETE용)
let finalEndpoint = endpoint;
if ((method === 'PUT' || method === 'DELETE') && urlPathColumn && record[urlPathColumn]) {
// /api/users → /api/users/user123
finalEndpoint = `${endpoint}/${record[urlPathColumn]}`;
}
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}`);
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
await connector.executeQuery(finalEndpoint, method, requestData);
successCount++;
} catch (error) {
console.error(`REST API 레코드 전송 실패:`, error);
failedCount++;
}
}
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}`);
return {
success: true,
data: { successCount, failedCount }
};
} catch (error) {
console.error(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, error);
return {
success: false,
message: `REST API 데이터 전송 실패: ${error}`,
data: { successCount: 0, failedCount: 0 }
};
}
}
/**
* REST API로 ( )
*/
static async sendDataToRestApi(
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'POST' | 'PUT' = 'POST',
data: any[]
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
try {
console.log(`[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
// REST API 커넥터 생성
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
timeout: 30000
});
// 연결 테스트
await connector.connect();
let successCount = 0;
let failedCount = 0;
// 각 레코드를 개별적으로 전송
for (const record of data) {
try {
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}`);
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
await connector.executeQuery(endpoint, method, record);
successCount++;
} catch (error) {
console.error(`REST API 레코드 전송 실패:`, error);
failedCount++;
}
}
console.log(`[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}`);
return {
success: true,
data: { successCount, failedCount }
};
} catch (error) {
console.error(`[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, error);
return {
success: false,
message: "REST API 데이터 전송 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
}

View File

@ -1,373 +0,0 @@
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import prisma from "../config/database";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
// 배치관리 전용 타입 정의
export interface BatchConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface BatchTableInfo {
table_name: string;
columns: BatchColumnInfo[];
description?: string | null;
}
export interface BatchColumnInfo {
column_name: string;
data_type: string;
is_nullable?: string;
column_default?: string | null;
}
export interface BatchApiResponse<T = unknown> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export class BatchManagementService {
/**
*
*/
static async getAvailableConnections(): Promise<BatchApiResponse<BatchConnectionInfo[]>> {
try {
const connections: BatchConnectionInfo[] = [];
// 내부 DB 추가
connections.push({
type: 'internal',
name: '내부 데이터베이스 (PostgreSQL)',
db_type: 'postgresql'
});
// 활성화된 외부 DB 연결 조회
const externalConnections = await prisma.external_db_connections.findMany({
where: { is_active: 'Y' },
select: {
id: true,
connection_name: true,
db_type: true,
description: true
},
orderBy: { connection_name: 'asc' }
});
// 외부 DB 연결 추가
externalConnections.forEach(conn => {
connections.push({
type: 'external',
id: conn.id,
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
db_type: conn.db_type || undefined
});
});
return {
success: true,
data: connections,
message: `${connections.length}개의 연결을 조회했습니다.`
};
} catch (error) {
console.error("배치관리 연결 목록 조회 실패:", error);
return {
success: false,
message: "연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
): Promise<BatchApiResponse<BatchTableInfo[]>> {
try {
let tables: BatchTableInfo[] = [];
if (connectionType === 'internal') {
// 내부 DB 테이블 조회
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`;
tables = result.map(row => ({
table_name: row.table_name,
columns: []
}));
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 테이블 조회
const tablesResult = await this.getExternalTables(connectionId);
if (tablesResult.success && tablesResult.data) {
tables = tablesResult.data;
}
}
return {
success: true,
data: tables,
message: `${tables.length}개의 테이블을 조회했습니다.`
};
} catch (error) {
console.error("배치관리 테이블 목록 조회 실패:", error);
return {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
connectionId: number | undefined,
tableName: string
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
try {
console.log(`[BatchManagementService] getTableColumns 호출:`, {
connectionType,
connectionId,
tableName
});
let columns: BatchColumnInfo[] = [];
if (connectionType === 'internal') {
// 내부 DB 컬럼 조회
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`);
const result = await prisma.$queryRaw<Array<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null
}>>`
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
ORDER BY ordinal_position
`;
console.log(`[BatchManagementService] 쿼리 결과:`, result);
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
columns = result.map(row => ({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable,
column_default: row.column_default,
}));
} else if (connectionType === 'external' && connectionId) {
// 외부 DB 컬럼 조회
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult);
if (columnsResult.success && columnsResult.data) {
columns = columnsResult.data;
}
}
console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns);
return {
success: true,
data: columns,
message: `${columns.length}개의 컬럼을 조회했습니다.`
};
} catch (error) {
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB ( )
*/
private static async getExternalTables(connectionId: number): Promise<BatchApiResponse<BatchTableInfo[]>> {
try {
// 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
return {
success: false,
message: "연결 정보를 찾을 수 없습니다."
};
}
// 비밀번호 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
if (!decryptedPassword) {
return {
success: false,
message: "비밀번호 복호화에 실패했습니다."
};
}
// 연결 설정 준비
const config = {
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
// DatabaseConnectorFactory를 통한 테이블 목록 조회
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
const tables = await connector.getTables();
return {
success: true,
message: "테이블 목록을 조회했습니다.",
data: tables
};
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
return {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
/**
* DB ( )
*/
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<BatchApiResponse<BatchColumnInfo[]>> {
try {
console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
// 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({
where: { id: connectionId }
});
if (!connection) {
console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
return {
success: false,
message: "연결 정보를 찾을 수 없습니다."
};
}
console.log(`[BatchManagementService] 연결 정보 조회 성공:`, {
id: connection.id,
connection_name: connection.connection_name,
db_type: connection.db_type,
host: connection.host,
port: connection.port,
database_name: connection.database_name
});
// 비밀번호 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// 연결 설정 준비
const config = {
host: connection.host,
port: connection.port,
database: connection.database_name,
user: connection.username,
password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
};
console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`);
// 데이터베이스 타입에 따른 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
// 컬럼 정보 조회
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
const columns = await connector.getColumns(tableName);
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
console.log(`[BatchManagementService] 컬럼 변환 중:`, col);
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
if (col.name && col.dataType !== undefined) {
const result = {
column_name: col.name,
data_type: col.dataType,
is_nullable: col.isNullable ? 'YES' : 'NO',
column_default: col.defaultValue || null,
};
console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result);
return result;
}
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
else {
const result = {
column_name: col.column_name || col.COLUMN_NAME,
data_type: col.data_type || col.DATA_TYPE,
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
column_default: col.column_default || col.COLUMN_DEFAULT || null,
};
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
return result;
}
});
console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns);
return {
success: true,
data: standardizedColumns,
message: "컬럼 정보를 조회했습니다."
};
} catch (error) {
console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error);
console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
return {
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
};
}
}
}

View File

@ -1,484 +0,0 @@
// 배치 스케줄러 서비스
// 작성일: 2024-12-24
import * as cron from 'node-cron';
import prisma from '../config/database';
import { BatchService } from './batchService';
import { BatchExecutionLogService } from './batchExecutionLogService';
import { logger } from '../utils/logger';
export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
private static isInitialized = false;
/**
*
*/
static async initialize() {
if (this.isInitialized) {
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
return;
}
try {
logger.info('배치 스케줄러 초기화 시작...');
// 활성화된 배치 설정들을 로드하여 스케줄 등록
await this.loadActiveBatchConfigs();
this.isInitialized = true;
logger.info('배치 스케줄러 초기화 완료');
} catch (error) {
logger.error('배치 스케줄러 초기화 실패:', error);
throw error;
}
}
/**
*
*/
private static async loadActiveBatchConfigs() {
try {
const activeConfigs = await prisma.batch_configs.findMany({
where: {
is_active: 'Y'
},
include: {
batch_mappings: true
}
});
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
for (const config of activeConfigs) {
await this.scheduleBatchConfig(config);
}
} catch (error) {
logger.error('활성화된 배치 설정 로드 실패:', error);
throw error;
}
}
/**
*
*/
static async scheduleBatchConfig(config: any) {
try {
const { id, batch_name, cron_schedule } = config;
// 기존 스케줄이 있다면 제거
if (this.scheduledTasks.has(id)) {
this.scheduledTasks.get(id)?.stop();
this.scheduledTasks.delete(id);
}
// cron 스케줄 유효성 검사
if (!cron.validate(cron_schedule)) {
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
return;
}
// 새로운 스케줄 등록
const task = cron.schedule(cron_schedule, async () => {
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
await this.executeBatchConfig(config);
});
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
task.start();
this.scheduledTasks.set(id, task);
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`);
} catch (error) {
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
}
}
/**
*
*/
static async unscheduleBatchConfig(batchConfigId: number) {
try {
if (this.scheduledTasks.has(batchConfigId)) {
this.scheduledTasks.get(batchConfigId)?.stop();
this.scheduledTasks.delete(batchConfigId);
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
}
} catch (error) {
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
}
}
/**
*
*/
static async updateBatchSchedule(configId: number) {
try {
// 기존 스케줄 제거
await this.unscheduleBatchConfig(configId);
// 업데이트된 배치 설정 조회
const config = await prisma.batch_configs.findUnique({
where: { id: configId },
include: { batch_mappings: true }
});
if (!config) {
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
return;
}
// 활성화된 배치만 다시 스케줄 등록
if (config.is_active === 'Y') {
await this.scheduleBatchConfig(config);
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
} else {
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
}
} catch (error) {
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
}
}
/**
*
*/
private static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
// 실행 로그 생성
const executionLogResponse = await BatchExecutionLogService.createExecutionLog({
batch_config_id: config.id,
execution_status: 'RUNNING',
start_time: startTime,
total_records: 0,
success_records: 0,
failed_records: 0
});
if (!executionLogResponse.success || !executionLogResponse.data) {
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
return;
}
executionLog = executionLogResponse.data;
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
const result = await this.executeBatchMappings(config);
// 실행 로그 업데이트 (성공)
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: 'SUCCESS',
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: result.totalRecords,
success_records: result.successRecords,
failed_records: result.failedRecords
});
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
} catch (error) {
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: 'FAILED',
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
error_message: error instanceof Error ? error.message : '알 수 없는 오류',
error_details: error instanceof Error ? error.stack : String(error)
});
}
}
}
/**
* ( )
*/
private static async executeBatchMappings(config: any) {
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
if (!config.batch_mappings || config.batch_mappings.length === 0) {
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
return { totalRecords, successRecords, failedRecords };
}
// 테이블별로 매핑을 그룹화
const tableGroups = new Map<string, typeof config.batch_mappings>();
for (const mapping of config.batch_mappings) {
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) {
tableGroups.set(key, []);
}
tableGroups.get(key)!.push(mapping);
}
// 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) {
try {
const firstMapping = mappings[0];
logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
let fromData: any[] = [];
// FROM 데이터 조회 (DB 또는 REST API)
if (firstMapping.from_connection_type === 'restapi') {
// REST API에서 데이터 조회
logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
const { BatchExternalDbService } = await import('./batchExternalDbService');
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
firstMapping.from_table_name,
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
mappings.map((m: any) => m.from_column_name)
);
if (apiResult.success && apiResult.data) {
fromData = apiResult.data;
} else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
}
} else {
// DB에서 데이터 조회
const fromColumns = mappings.map((m: any) => m.from_column_name);
fromData = await BatchService.getDataFromTableWithColumns(
firstMapping.from_table_name,
fromColumns,
firstMapping.from_connection_type as 'internal' | 'external',
firstMapping.from_connection_id || undefined
);
}
totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
const mappedData = fromData.map(row => {
const mappedRow: any = {};
for (const mapping of mappings) {
// DB → REST API 배치인지 확인
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
} else {
// 기존 로직: to_column_name을 키로 사용
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
}
}
return mappedRow;
});
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
let insertResult: { successCount: number; failedCount: number };
if (firstMapping.to_connection_type === 'restapi') {
// REST API로 데이터 전송
logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
const { BatchExternalDbService } = await import('./batchExternalDbService');
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
const hasTemplate = mappings.some((m: any) => m.to_api_body);
if (hasTemplate) {
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
const templateBody = firstMapping.to_api_body || '{}';
logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
// URL 경로 컬럼 찾기 (PUT/DELETE용)
const urlPathColumn = mappings.find((m: any) => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
templateBody,
mappedData,
urlPathColumn
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
}
} else {
// 기존 REST API 전송 (REST API → DB 배치)
const apiResult = await BatchExternalDbService.sendDataToRestApi(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' || 'POST',
mappedData
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
}
}
} else {
// DB에 데이터 삽입
insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name,
mappedData,
firstMapping.to_connection_type as 'internal' | 'external',
firstMapping.to_connection_id || undefined
);
}
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
} catch (error) {
logger.error(`테이블 처리 실패: ${tableKey}`, error);
failedRecords += 1;
}
}
return { totalRecords, successRecords, failedRecords };
}
/**
* ( - )
*/
private static async processBatchMappings(config: any) {
const { batch_mappings } = config;
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
if (!batch_mappings || batch_mappings.length === 0) {
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
return { totalRecords, successRecords, failedRecords };
}
for (const mapping of batch_mappings) {
try {
logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`);
// FROM 테이블에서 데이터 조회
const fromData = await this.getDataFromSource(mapping);
totalRecords += fromData.length;
// TO 테이블에 데이터 삽입
const insertResult = await this.insertDataToTarget(mapping, fromData);
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
} catch (error) {
logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error);
failedRecords += 1;
}
}
return { totalRecords, successRecords, failedRecords };
}
/**
* FROM
*/
private static async getDataFromSource(mapping: any) {
try {
if (mapping.from_connection_type === 'internal') {
// 내부 DB에서 조회
const result = await prisma.$queryRawUnsafe(
`SELECT * FROM ${mapping.from_table_name}`
);
return result as any[];
} else {
// 외부 DB에서 조회 (구현 필요)
logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.');
return [];
}
} catch (error) {
logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error);
throw error;
}
}
/**
* TO
*/
private static async insertDataToTarget(mapping: any, data: any[]) {
let successCount = 0;
let failedCount = 0;
try {
if (mapping.to_connection_type === 'internal') {
// 내부 DB에 삽입
for (const record of data) {
try {
// 매핑된 컬럼만 추출
const mappedData = this.mapColumns(record, mapping);
await prisma.$executeRawUnsafe(
`INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`,
...Object.values(mappedData)
);
successCount++;
} catch (error) {
logger.error(`레코드 삽입 실패:`, error);
failedCount++;
}
}
} else {
// 외부 DB에 삽입 (구현 필요)
logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.');
failedCount = data.length;
}
} catch (error) {
logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error);
throw error;
}
return { successCount, failedCount };
}
/**
*
*/
private static mapColumns(record: any, mapping: any) {
const mappedData: any = {};
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
return mappedData;
}
/**
*
*/
static async stopAllSchedules() {
try {
for (const [id, task] of this.scheduledTasks) {
task.stop();
logger.info(`배치 스케줄 중지: ID ${id}`);
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info('모든 배치 스케줄이 중지되었습니다.');
} catch (error) {
logger.error('배치 스케줄 중지 실패:', error);
}
}
/**
*
*/
static getScheduledTasks() {
return Array.from(this.scheduledTasks.keys());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -12,14 +12,21 @@ import { MultiConnectionQueryService } from "./multiConnectionQueryService";
import { logger } from "../utils/logger";
export interface EnhancedControlAction extends ControlAction {
// 🆕 기본 ControlAction 속성들 (상속됨)
id?: number;
actionType?: string;
fromTable: string;
// 🆕 커넥션 정보 추가
fromConnection?: {
connectionId?: number;
connectionName?: string;
dbType?: string;
};
toConnection?: {
connectionId?: number;
connectionName?: string;
dbType?: string;
};
// 🆕 추가 속성들
conditions?: ControlCondition[];
fieldMappings?: any[];
// 🆕 명시적 테이블 정보
fromTable?: string;
targetTable: string;
// 🆕 UPDATE 액션 관련 필드
updateConditions?: UpdateCondition[];
@ -165,20 +172,13 @@ export class EnhancedDataflowControlService extends DataflowControlService {
const enhancedAction = action as EnhancedControlAction;
let actionResult: any;
// 커넥션 ID 추출
const sourceConnectionId = enhancedAction.fromConnection?.connectionId || enhancedAction.fromConnection?.id || 0;
const targetConnectionId = enhancedAction.toConnection?.connectionId || enhancedAction.toConnection?.id || 0;
switch (enhancedAction.actionType) {
case "insert":
actionResult = await this.executeMultiConnectionInsert(
enhancedAction,
sourceData,
enhancedAction.fromTable,
enhancedAction.targetTable,
sourceConnectionId,
targetConnectionId,
null
targetConnectionId
);
break;
@ -186,11 +186,8 @@ export class EnhancedDataflowControlService extends DataflowControlService {
actionResult = await this.executeMultiConnectionUpdate(
enhancedAction,
sourceData,
enhancedAction.fromTable,
enhancedAction.targetTable,
sourceConnectionId,
targetConnectionId,
null
targetConnectionId
);
break;
@ -198,11 +195,8 @@ export class EnhancedDataflowControlService extends DataflowControlService {
actionResult = await this.executeMultiConnectionDelete(
enhancedAction,
sourceData,
enhancedAction.fromTable,
enhancedAction.targetTable,
sourceConnectionId,
targetConnectionId,
null
targetConnectionId
);
break;
@ -247,21 +241,20 @@ export class EnhancedDataflowControlService extends DataflowControlService {
/**
* 🆕 INSERT
*/
async executeMultiConnectionInsert(
private async executeMultiConnectionInsert(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceTable: string,
targetTable: string,
fromConnectionId: number,
toConnectionId: number,
multiConnService: any
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 INSERT 실행: action=${action.action}`);
logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
const fromConnId =
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
targetConnectionId || action.toConnection?.connectionId || 0;
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
let fromData = sourceData;
@ -294,7 +287,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
// 필드 매핑 적용
const mappedData = this.applyFieldMappings(
action.fieldMappings || [],
action.fieldMappings,
fromData
);
@ -317,21 +310,20 @@ export class EnhancedDataflowControlService extends DataflowControlService {
/**
* 🆕 UPDATE
*/
async executeMultiConnectionUpdate(
private async executeMultiConnectionUpdate(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceTable: string,
targetTable: string,
fromConnectionId: number,
toConnectionId: number,
multiConnService: any
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 UPDATE 실행: action=${action.action}`);
logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
const fromConnId =
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
targetConnectionId || action.toConnection?.connectionId || 0;
// UPDATE 조건 확인
if (!action.updateConditions || action.updateConditions.length === 0) {
@ -390,23 +382,20 @@ export class EnhancedDataflowControlService extends DataflowControlService {
/**
* 🆕 DELETE
*/
async executeMultiConnectionDelete(
private async executeMultiConnectionDelete(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceTable: string,
targetTable: string,
fromConnectionId: number,
toConnectionId: number,
multiConnService: any
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 DELETE 실행: action=${action.action}`);
logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId =
fromConnectionId || action.fromConnection?.connectionId || 0;
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
toConnectionId || action.toConnection?.connectionId || 0;
targetConnectionId || action.toConnection?.connectionId || 0;
// DELETE 조건 확인
if (!action.deleteConditions || action.deleteConditions.length === 0) {

View File

@ -1,7 +1,7 @@
// 외부 DB 연결 서비스
// 작성일: 2024-12-17
import prisma from "../config/database";
import { PrismaClient } from "@prisma/client";
import {
ExternalDbConnection,
ExternalDbConnectionFilter,
@ -11,6 +11,9 @@ import {
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
import prisma = require("../config/database");
export class ExternalDbConnectionService {
/**
* DB
@ -88,26 +91,23 @@ export class ExternalDbConnectionService {
try {
// 기본 연결 목록 조회
const connectionsResult = await this.getConnections(filter);
if (!connectionsResult.success || !connectionsResult.data) {
return {
success: false,
message: "연결 목록 조회에 실패했습니다."
message: "연결 목록 조회에 실패했습니다.",
};
}
// DB 타입 카테고리 정보 조회
const categories = await prisma.db_type_categories.findMany({
where: { is_active: true },
orderBy: [
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
orderBy: [{ sort_order: "asc" }, { display_name: "asc" }],
});
// DB 타입별로 그룹화
const groupedConnections: Record<string, any> = {};
// 카테고리 정보를 포함한 그룹 초기화
categories.forEach((category: any) => {
groupedConnections[category.type_code] = {
@ -116,36 +116,36 @@ export class ExternalDbConnectionService {
display_name: category.display_name,
icon: category.icon,
color: category.color,
sort_order: category.sort_order
sort_order: category.sort_order,
},
connections: []
connections: [],
};
});
// 연결을 해당 타입 그룹에 배치
connectionsResult.data.forEach(connection => {
connectionsResult.data.forEach((connection) => {
if (groupedConnections[connection.db_type]) {
groupedConnections[connection.db_type].connections.push(connection);
} else {
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
if (!groupedConnections['other']) {
groupedConnections['other'] = {
if (!groupedConnections["other"]) {
groupedConnections["other"] = {
category: {
type_code: 'other',
display_name: '기타',
icon: 'database',
color: '#6B7280',
sort_order: 999
type_code: "other",
display_name: "기타",
icon: "database",
color: "#6B7280",
sort_order: 999,
},
connections: []
connections: [],
};
}
groupedConnections['other'].connections.push(connection);
groupedConnections["other"].connections.push(connection);
}
});
// 연결이 없는 빈 그룹 제거
Object.keys(groupedConnections).forEach(key => {
Object.keys(groupedConnections).forEach((key) => {
if (groupedConnections[key].connections.length === 0) {
delete groupedConnections[key];
}
@ -154,14 +154,14 @@ export class ExternalDbConnectionService {
return {
success: true,
data: groupedConnections,
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
};
} catch (error) {
console.error("그룹화된 연결 목록 조회 실패:", error);
return {
success: false,
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
error: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}

View File

@ -1,64 +0,0 @@
// 배치 실행 로그 타입 정의
// 작성일: 2024-12-24
export interface BatchExecutionLog {
id?: number;
batch_config_id: number;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time: Date;
end_time?: Date | null;
duration_ms?: number | null;
total_records?: number | null;
success_records?: number | null;
failed_records?: number | null;
error_message?: string | null;
error_details?: string | null;
server_name?: string | null;
process_id?: string | null;
}
export interface CreateBatchExecutionLogRequest {
batch_config_id: number;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time?: Date;
end_time?: Date | null;
duration_ms?: number | null;
total_records?: number | null;
success_records?: number | null;
failed_records?: number | null;
error_message?: string | null;
error_details?: string | null;
server_name?: string | null;
process_id?: string | null;
}
export interface UpdateBatchExecutionLogRequest {
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
end_time?: Date | null;
duration_ms?: number | null;
total_records?: number | null;
success_records?: number | null;
failed_records?: number | null;
error_message?: string | null;
error_details?: string | null;
}
export interface BatchExecutionLogFilter {
batch_config_id?: number;
execution_status?: string;
start_date?: Date;
end_date?: Date;
page?: number;
limit?: number;
}
export interface BatchExecutionLogWithConfig extends BatchExecutionLog {
batch_config?: {
id: number;
batch_name: string;
description?: string | null;
cron_schedule: string;
is_active?: string | null;
};
}

View File

@ -1,139 +0,0 @@
// 배치관리 타입 정의
// 작성일: 2024-12-24
// 배치 타입 정의
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
export interface BatchTypeOption {
value: BatchType;
label: string;
description: string;
}
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_id?: number;
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
from_column_type?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
from_api_url?: string; // REST API 서버 URL
from_api_key?: string; // REST API 키
// TO 정보
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
to_column_type?: string;
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
to_api_url?: string; // REST API 서버 URL
to_api_key?: string; // REST API 키
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
page?: number;
limit?: number;
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface TableInfo {
table_name: string;
columns: ColumnInfo[];
description?: string | null;
}
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: string;
column_default?: string | null;
}
export interface BatchMappingRequest {
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
from_api_url?: string;
from_api_key?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
to_api_url?: string;
to_api_key?: string;
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
mapping_order?: number;
}
export interface CreateBatchConfigRequest {
batchName: string;
description?: string;
cronSchedule: string;
mappings: BatchMappingRequest[];
}
export interface UpdateBatchConfigRequest {
batchName?: string;
description?: string;
cronSchedule?: string;
mappings?: BatchMappingRequest[];
isActive?: string;
}
export interface BatchValidationResult {
isValid: boolean;
errors: string[];
warnings?: string[];
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}

View File

@ -1,18 +0,0 @@
declare module 'oracledb' {
export interface Connection {
execute(sql: string, bindParams?: any, options?: any): Promise<any>;
close(): Promise<void>;
}
export interface ConnectionConfig {
user: string;
password: string;
connectString: string;
}
export function getConnection(config: ConnectionConfig): Promise<Connection>;
export function createPool(config: any): Promise<any>;
export function getPool(): any;
export function close(): Promise<void>;
}

View File

@ -33,6 +33,6 @@
"@/validators/*": ["src/validators/*"]
}
},
"include": ["src/**/*", "src/types/**/*.d.ts"],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@ -16,5 +16,5 @@ COPY . .
# 포트 노출
EXPOSE 3000
# 개발 서버 시작 (Docker에서는 포트 3000 사용)
CMD ["npm", "run", "dev", "--", "-p", "3000"]
# 개발 서버 시작
CMD ["npm", "run", "dev"]

View File

@ -1,585 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>배치관리 매핑 시스템</title>
<style>
body {
font-family: 'Malgun Gothic', Arial, sans-serif;
margin: 20px;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.main-container {
background: white;
border-radius: 8px;
max-width: 1400px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
font-size: 24px;
font-weight: bold;
}
.input-section {
padding: 20px;
background-color: #f8f9fa;
border-bottom: 2px solid #e9ecef;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #495057;
}
.input-group input, .input-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.input-group textarea {
height: 60px;
resize: vertical;
}
.mapping-container {
display: flex;
padding: 20px;
gap: 20px;
min-height: 500px;
}
.db-section {
flex: 1;
border: 2px solid #dee2e6;
border-radius: 8px;
background: white;
}
.db-header {
background-color: #007bff;
color: white;
padding: 15px;
font-weight: bold;
text-align: center;
font-size: 18px;
}
.from-section .db-header {
background-color: #28a745;
}
.to-section .db-header {
background-color: #dc3545;
}
.selection-area {
padding: 20px;
}
.select-group {
margin-bottom: 20px;
}
.select-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #495057;
}
.select-group select {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background-color: white;
}
.columns-area {
margin-top: 20px;
min-height: 200px;
}
.table-info {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
}
.table-name {
font-weight: bold;
color: #007bff;
margin-bottom: 10px;
font-size: 16px;
}
.column-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.column-item {
padding: 10px 15px;
background-color: white;
border: 2px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.column-item:hover {
border-color: #007bff;
box-shadow: 0 2px 4px rgba(0,123,255,0.2);
}
.column-item.selected {
border-color: #007bff;
background-color: #e3f2fd;
font-weight: bold;
}
.column-item.mapped {
border-color: #28a745;
background-color: #d4edda;
}
.column-type {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
.mapping-display {
margin-top: 20px;
padding: 15px;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
}
.mapping-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #dee2e6;
}
.mapping-item:last-child {
border-bottom: none;
}
.mapping-arrow {
color: #007bff;
font-weight: bold;
margin: 0 10px;
}
.remove-mapping {
background-color: #dc3545;
color: white;
border: none;
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
}
.save-button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.save-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.instruction {
background-color: #d1ecf1;
border: 1px solid #bee5eb;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
font-size: 14px;
color: #0c5460;
}
</style>
</head>
<body>
<div class="main-container">
<div class="header">
배치관리 매핑 시스템
</div>
<div class="input-section">
<div class="input-group">
<label for="cronSchedule">실행주기 (크론탭 형식)</label>
<input type="text" id="cronSchedule" placeholder="예: 0 12 * * * (매일 12시)" value="1 11 3 * *">
</div>
<div class="input-group">
<label for="description">비고</label>
<textarea id="description" placeholder="하루한번 12시에 실행하는 인사정보 배치 등등...">하루한번 12시에 실행하는 인사정보 배치</textarea>
</div>
</div>
<div class="mapping-container">
<div class="db-section from-section">
<div class="db-header">FROM (원본 데이터베이스)</div>
<div class="selection-area">
<div class="instruction">
1단계: 컨넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
</div>
<div class="select-group">
<label for="fromConnection">컨넥션 선택</label>
<select id="fromConnection">
<option value="">컨넥션을 선택하세요</option>
<option value="oracle_db">Oracle_DB</option>
<option value="mes_db">MES_DB</option>
<option value="plm_db">PLM_DB</option>
<option value="erp_db">ERP_DB</option>
</select>
</div>
<div class="select-group">
<label for="fromTable">테이블 선택</label>
<select id="fromTable" disabled>
<option value="">먼저 컨넥션을 선택하세요</option>
</select>
</div>
<div class="columns-area" id="fromColumns">
<!-- 동적으로 컬럼들이 표시될 영역 -->
</div>
</div>
</div>
<div class="db-section to-section">
<div class="db-header">TO (대상 데이터베이스)</div>
<div class="selection-area">
<div class="instruction">
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
</div>
<div class="select-group">
<label for="toConnection">컨넥션 선택</label>
<select id="toConnection">
<option value="">컨넥션을 선택하세요</option>
<option value="oracle_db">Oracle_DB</option>
<option value="mes_db">MES_DB</option>
<option value="plm_db">PLM_DB</option>
<option value="erp_db">ERP_DB</option>
</select>
</div>
<div class="select-group">
<label for="toTable">테이블 선택</label>
<select id="toTable" disabled>
<option value="">먼저 컨넥션을 선택하세요</option>
</select>
</div>
<div class="columns-area" id="toColumns">
<!-- 동적으로 컬럼들이 표시될 영역 -->
</div>
</div>
</div>
</div>
<div class="mapping-display" id="mappingDisplay" style="margin: 20px; display: none;">
<h4>컬럼 매핑 현황</h4>
<div id="mappingList">
<!-- 매핑된 컬럼들이 표시될 영역 -->
</div>
</div>
<button class="save-button" onclick="saveMapping()">
배치 매핑 저장
</button>
</div>
<script>
// 샘플 데이터 - 실제로는 서버에서 가져올 데이터
const sampleData = {
oracle_db: {
employee: [
{name: 'user_id', type: 'VARCHAR2(20)'},
{name: 'user_name', type: 'VARCHAR2(100)'},
{name: 'department', type: 'VARCHAR2(50)'},
{name: 'email', type: 'VARCHAR2(200)'},
{name: 'created_date', type: 'DATE'}
],
department: [
{name: 'dept_id', type: 'VARCHAR2(10)'},
{name: 'dept_name', type: 'VARCHAR2(100)'},
{name: 'manager_id', type: 'VARCHAR2(20)'}
]
},
mes_db: {
user_info: [
{name: 'user_id', type: 'VARCHAR(20)'},
{name: 'user_name', type: 'VARCHAR(100)'},
{name: 'position', type: 'VARCHAR(50)'},
{name: 'phone', type: 'VARCHAR(20)'},
{name: 'hire_date', type: 'DATETIME'}
],
project: [
{name: 'project_id', type: 'VARCHAR(20)'},
{name: 'project_name', type: 'VARCHAR(200)'},
{name: 'start_date', type: 'DATETIME'},
{name: 'end_date', type: 'DATETIME'}
]
},
plm_db: {
product: [
{name: 'product_id', type: 'VARCHAR(30)'},
{name: 'product_name', type: 'VARCHAR(200)'},
{name: 'category', type: 'VARCHAR(50)'},
{name: 'price', type: 'DECIMAL(10,2)'}
]
},
erp_db: {
customer: [
{name: 'customer_id', type: 'VARCHAR(20)'},
{name: 'customer_name', type: 'VARCHAR(200)'},
{name: 'address', type: 'TEXT'},
{name: 'contact', type: 'VARCHAR(100)'}
]
}
};
let selectedFromColumn = null;
let mappings = [];
// 컨넥션 선택 이벤트 처리
document.getElementById('fromConnection').addEventListener('change', function() {
loadTables('from', this.value);
});
document.getElementById('toConnection').addEventListener('change', function() {
loadTables('to', this.value);
});
// 테이블 선택 이벤트 처리
document.getElementById('fromTable').addEventListener('change', function() {
loadColumns('from', document.getElementById('fromConnection').value, this.value);
});
document.getElementById('toTable').addEventListener('change', function() {
loadColumns('to', document.getElementById('toConnection').value, this.value);
});
// 테이블 목록 로드
function loadTables(side, connectionValue) {
const tableSelect = document.getElementById(side + 'Table');
tableSelect.innerHTML = '<option value="">테이블을 선택하세요</option>';
tableSelect.disabled = false;
if (connectionValue && sampleData[connectionValue]) {
Object.keys(sampleData[connectionValue]).forEach(tableName => {
const option = document.createElement('option');
option.value = tableName;
option.textContent = tableName.toUpperCase();
tableSelect.appendChild(option);
});
}
// 컬럼 영역 초기화
document.getElementById(side + 'Columns').innerHTML = '';
}
// 컬럼 목록 로드
function loadColumns(side, connectionValue, tableName) {
const columnsArea = document.getElementById(side + 'Columns');
if (!connectionValue || !tableName || !sampleData[connectionValue] || !sampleData[connectionValue][tableName]) {
columnsArea.innerHTML = '';
return;
}
const columns = sampleData[connectionValue][tableName];
columnsArea.innerHTML = `
<div class="table-info">
<div class="table-name">${tableName.toUpperCase()} 테이블</div>
<div class="column-list">
${columns.map(col => `
<div class="column-item" onclick="handleColumnClick('${side}', '${connectionValue}', '${tableName}', '${col.name}', '${col.type}')">
<div>${col.name}</div>
<div class="column-type">${col.type}</div>
</div>
`).join('')}
</div>
</div>
`;
}
// 컬럼 클릭 처리
function handleColumnClick(side, connection, table, columnName, columnType) {
if (side === 'from') {
// FROM 컬럼 선택
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
item.classList.remove('selected');
});
event.target.closest('.column-item').classList.add('selected');
selectedFromColumn = {
side: 'from',
connection: connection,
table: table,
column: columnName,
type: columnType
};
} else if (side === 'to' && selectedFromColumn) {
// TO 컬럼 선택하여 매핑 생성
const mapping = {
from: selectedFromColumn,
to: {
side: 'to',
connection: connection,
table: table,
column: columnName,
type: columnType
}
};
// 중복 매핑 체크
const existingMapping = mappings.find(m =>
m.from.column === mapping.from.column &&
m.to.column === mapping.to.column
);
if (!existingMapping) {
mappings.push(mapping);
updateMappingDisplay();
updateColumnStyles();
}
// FROM 선택 해제
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
item.classList.remove('selected');
});
selectedFromColumn = null;
}
}
// 매핑 표시 업데이트
function updateMappingDisplay() {
const mappingDisplay = document.getElementById('mappingDisplay');
const mappingList = document.getElementById('mappingList');
if (mappings.length === 0) {
mappingDisplay.style.display = 'none';
return;
}
mappingDisplay.style.display = 'block';
mappingList.innerHTML = mappings.map((mapping, index) => `
<div class="mapping-item">
<span>${mapping.from.table}.${mapping.from.column} (${mapping.from.type})</span>
<span class="mapping-arrow"></span>
<span>${mapping.to.table}.${mapping.to.column} (${mapping.to.type})</span>
<button class="remove-mapping" onclick="removeMapping(${index})">삭제</button>
</div>
`).join('');
}
// 컬럼 스타일 업데이트
function updateColumnStyles() {
// 모든 컬럼 아이템에서 mapped 클래스 제거
document.querySelectorAll('.column-item').forEach(item => {
item.classList.remove('mapped');
});
// 매핑된 컬럼들에 스타일 적용
mappings.forEach(mapping => {
const fromColumns = document.querySelectorAll('#fromColumns .column-item');
const toColumns = document.querySelectorAll('#toColumns .column-item');
fromColumns.forEach(item => {
if (item.textContent.includes(mapping.from.column)) {
item.classList.add('mapped');
}
});
toColumns.forEach(item => {
if (item.textContent.includes(mapping.to.column)) {
item.classList.add('mapped');
}
});
});
}
// 매핑 삭제
function removeMapping(index) {
mappings.splice(index, 1);
updateMappingDisplay();
updateColumnStyles();
}
// 매핑 저장
function saveMapping() {
const cronSchedule = document.getElementById('cronSchedule').value;
const description = document.getElementById('description').value;
if (!cronSchedule) {
alert('실행주기를 입력해주세요.');
return;
}
if (mappings.length === 0) {
alert('최소 하나 이상의 컬럼 매핑을 설정해주세요.');
return;
}
const batchConfig = {
cronSchedule: cronSchedule,
description: description,
mappings: mappings,
createdAt: new Date().toISOString()
};
// 실제로는 서버로 전송
console.log('저장될 배치 설정:', batchConfig);
alert('배치 매핑이 성공적으로 저장되었습니다!\n\n' +
`실행주기: ${cronSchedule}\n` +
`매핑 개수: ${mappings.length}개\n` +
`설명: ${description}`);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -35,17 +34,13 @@ import {
Trash2,
Play,
RefreshCw,
BarChart3,
ArrowRight,
Database,
Globe
BarChart3
} from "lucide-react";
import { toast } from "sonner";
import { BatchAPI, BatchJob } from "@/lib/api/batch";
import BatchJobModal from "@/components/admin/BatchJobModal";
export default function BatchManagementPage() {
const router = useRouter();
const [jobs, setJobs] = useState<BatchJob[]>([]);
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
const [isLoading, setIsLoading] = useState(false);
@ -57,7 +52,6 @@ export default function BatchManagementPage() {
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
useEffect(() => {
loadJobs();
@ -115,23 +109,8 @@ export default function BatchManagementPage() {
};
const handleCreate = () => {
setIsBatchTypeModalOpen(true);
};
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 배치 생성 모달 열기
console.log("DB → DB 배치 모달 열기");
setSelectedJob(null);
setIsModalOpen(true);
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
router.push('/admin/batch-management-new');
}
setSelectedJob(null);
setIsModalOpen(true);
};
const handleEdit = (job: BatchJob) => {
@ -206,11 +185,12 @@ export default function BatchManagementPage() {
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> </h1>
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
.
</p>
@ -442,61 +422,6 @@ export default function BatchManagementPage() {
</CardContent>
</Card>
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-2xl mx-4">
<CardHeader>
<CardTitle className="text-center"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* DB → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
onClick={() => handleBatchTypeSelect('db-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Database className="w-8 h-8 text-blue-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">DB DB</div>
<div className="text-sm text-gray-500"> </div>
</div>
</div>
{/* REST API → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Globe className="w-8 h-8 text-green-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-green-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">REST API DB</div>
<div className="text-sm text-gray-500">REST API에서 </div>
</div>
</div>
</div>
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* 배치 작업 모달 */}
<BatchJobModal
isOpen={isModalOpen}
@ -504,6 +429,7 @@ export default function BatchManagementPage() {
onSave={handleModalSave}
job={selectedJob}
/>
</div>
</div>
);
}

View File

@ -1,544 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Badge } from "@/components/ui/badge";
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
BatchAPI,
BatchMapping,
ConnectionInfo,
ColumnInfo,
BatchMappingRequest,
} from "@/lib/api/batch";
export default function BatchCreatePage() {
const router = useRouter();
// 기본 정보
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
// 커넥션 및 데이터
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
const [fromTables, setFromTables] = useState<string[]>([]);
const [toTables, setToTables] = useState<string[]>([]);
const [fromTable, setFromTable] = useState("");
const [toTable, setToTable] = useState("");
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
// 매핑 상태
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
const [mappings, setMappings] = useState<BatchMapping[]>([]);
// 로딩 상태
const [loading, setLoading] = useState(false);
const [loadingConnections, setLoadingConnections] = useState(false);
// 커넥션 목록 로드
useEffect(() => {
loadConnections();
}, []);
const loadConnections = async () => {
setLoadingConnections(true);
try {
const data = await BatchAPI.getConnections();
setConnections(Array.isArray(data) ? data : []);
} catch (error) {
console.error("커넥션 로드 실패:", error);
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
setConnections([]);
} finally {
setLoadingConnections(false);
}
};
// FROM 커넥션 변경
const handleFromConnectionChange = async (connectionId: string) => {
if (connectionId === 'unknown') return;
const connection = connections.find(conn => {
if (conn.type === 'internal') {
return connectionId === 'internal';
}
return conn.id ? conn.id.toString() === connectionId : false;
});
if (!connection) return;
setFromConnection(connection);
setFromTable("");
setFromTables([]);
setFromColumns([]);
setSelectedFromColumn(null);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setFromTables(Array.isArray(tables) ? tables : []);
} catch (error) {
console.error("FROM 테이블 목록 로드 실패:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
// TO 커넥션 변경
const handleToConnectionChange = async (connectionId: string) => {
if (connectionId === 'unknown') return;
const connection = connections.find(conn => {
if (conn.type === 'internal') {
return connectionId === 'internal';
}
return conn.id ? conn.id.toString() === connectionId : false;
});
if (!connection) return;
setToConnection(connection);
setToTable("");
setToTables([]);
setToColumns([]);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setToTables(Array.isArray(tables) ? tables : []);
} catch (error) {
console.error("TO 테이블 목록 로드 실패:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
// FROM 테이블 변경
const handleFromTableChange = async (tableName: string) => {
setFromTable(tableName);
setFromColumns([]);
setSelectedFromColumn(null);
if (!fromConnection || !tableName) return;
try {
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
setFromColumns(Array.isArray(columns) ? columns : []);
} catch (error) {
console.error("FROM 컬럼 목록 로드 실패:", error);
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
}
};
// TO 테이블 변경
const handleToTableChange = async (tableName: string) => {
setToTable(tableName);
setToColumns([]);
if (!toConnection || !tableName) return;
try {
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
setToColumns(Array.isArray(columns) ? columns : []);
} catch (error) {
console.error("TO 컬럼 목록 로드 실패:", error);
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
}
};
// FROM 컬럼 선택
const handleFromColumnClick = (column: ColumnInfo) => {
setSelectedFromColumn(column);
toast.info(`FROM 컬럼 선택됨: ${column.column_name}`);
};
// TO 컬럼 선택 (매핑 생성)
const handleToColumnClick = (toColumn: ColumnInfo) => {
if (!selectedFromColumn || !fromConnection || !toConnection) {
toast.error("먼저 FROM 컬럼을 선택해주세요.");
return;
}
// n:1 매핑 검사
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
const existingMapping = mappings.find(mapping => {
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
return existingToKey === toKey;
});
if (existingMapping) {
toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)");
return;
}
const newMapping: BatchMapping = {
from_connection_type: fromConnection.type,
from_connection_id: fromConnection.id || null,
from_table_name: fromTable,
from_column_name: selectedFromColumn.column_name,
from_column_type: selectedFromColumn.data_type || '',
to_connection_type: toConnection.type,
to_connection_id: toConnection.id || null,
to_table_name: toTable,
to_column_name: toColumn.column_name,
to_column_type: toColumn.data_type || '',
mapping_order: mappings.length + 1,
};
setMappings([...mappings, newMapping]);
setSelectedFromColumn(null);
toast.success(`매핑 생성: ${selectedFromColumn.column_name}${toColumn.column_name}`);
};
// 매핑 삭제
const removeMapping = (index: number) => {
const newMappings = mappings.filter((_, i) => i !== index);
const reorderedMappings = newMappings.map((mapping, i) => ({
...mapping,
mapping_order: i + 1
}));
setMappings(reorderedMappings);
toast.success("매핑이 삭제되었습니다.");
};
// 배치 설정 저장
const saveBatchConfig = async () => {
if (!batchName.trim()) {
toast.error("배치명을 입력해주세요.");
return;
}
if (!cronSchedule.trim()) {
toast.error("실행 스케줄을 입력해주세요.");
return;
}
if (mappings.length === 0) {
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
return;
}
setLoading(true);
try {
const request = {
batchName: batchName,
description: description || undefined,
cronSchedule: cronSchedule,
mappings: mappings,
isActive: true
};
await BatchAPI.createBatchConfig(request);
toast.success("배치 설정이 성공적으로 저장되었습니다!");
// 목록 페이지로 이동
router.push("/admin/batchmng");
} catch (error) {
console.error("배치 설정 저장 실패:", error);
toast.error("배치 설정 저장에 실패했습니다.");
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
className="flex items-center space-x-2"
>
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cronSchedule"> (Cron) *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * * (매일 12시)"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
rows={3}
/>
</div>
</CardContent>
</Card>
{/* 매핑 설정 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* FROM 섹션 */}
<Card className="border-green-200">
<CardHeader className="bg-green-50">
<CardTitle className="text-green-700">FROM ( )</CardTitle>
<p className="text-sm text-green-600">
1단계: 커넥션을 2단계: 테이블을 3단계: 컬럼을
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
onValueChange={handleFromConnectionChange}
disabled={loadingConnections}
>
<SelectTrigger>
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{Array.isArray(connections) && connections.map((conn) => (
<SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
>
{conn.name} ({conn.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={fromTable}
onValueChange={handleFromTableChange}
disabled={!fromConnection}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{fromTables.map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* FROM 컬럼 목록 */}
{fromTable && (
<div className="space-y-2">
<Label className="text-blue-600 font-semibold">{fromTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
{fromColumns.map((column) => (
<div
key={column.column_name}
onClick={() => handleFromColumnClick(column)}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedFromColumn?.column_name === column.column_name
? 'bg-green-100 border-green-300'
: 'hover:bg-gray-50 border-gray-200'
}`}
>
<div className="font-medium">{column.column_name}</div>
<div className="text-sm text-gray-500">{column.data_type}</div>
</div>
))}
{fromColumns.length === 0 && fromTable && (
<div className="text-center text-gray-500 py-4">
...
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* TO 섹션 */}
<Card className="border-red-200">
<CardHeader className="bg-red-50">
<CardTitle className="text-red-700">TO ( )</CardTitle>
<p className="text-sm text-red-600">
FROM에서 ,
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
onValueChange={handleToConnectionChange}
disabled={loadingConnections}
>
<SelectTrigger>
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{Array.isArray(connections) && connections.map((conn) => (
<SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
>
{conn.name} ({conn.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{toTables.map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* TO 컬럼 목록 */}
{toTable && (
<div className="space-y-2">
<Label className="text-blue-600 font-semibold">{toTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
{toColumns.map((column) => (
<div
key={column.column_name}
onClick={() => handleToColumnClick(column)}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedFromColumn
? 'hover:bg-red-50 border-gray-200'
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
}`}
>
<div className="font-medium">{column.column_name}</div>
<div className="text-sm text-gray-500">{column.data_type}</div>
</div>
))}
{toColumns.length === 0 && toTable && (
<div className="text-center text-gray-500 py-4">
...
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* 매핑 현황 */}
{mappings.length > 0 && (
<Card>
<CardHeader>
<CardTitle> ({mappings.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{mappings.map((mapping, index) => (
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
<div className="flex items-center space-x-4">
<div className="text-sm">
<div className="font-medium">
{mapping.from_table_name}.{mapping.from_column_name}
</div>
<div className="text-gray-500">
{mapping.from_column_type}
</div>
</div>
<ArrowRight className="h-4 w-4 text-gray-400" />
<div className="text-sm">
<div className="font-medium">
{mapping.to_table_name}.{mapping.to_column_name}
</div>
<div className="text-gray-500">
{mapping.to_column_type}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMapping(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 저장 버튼 */}
<div className="flex justify-end space-x-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
>
</Button>
<Button
onClick={saveBatchConfig}
disabled={loading || mappings.length === 0}
className="flex items-center space-x-2"
>
{loading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
</Button>
</div>
</div>
);
}

View File

@ -1,833 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
interface BatchColumnInfo {
column_name: string;
data_type: string;
is_nullable: string;
}
// 배치 타입 감지 함수
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
const fromType = mapping.from_connection_type;
const toType = mapping.to_connection_type;
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
return 'restapi-to-db';
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
return 'db-to-restapi';
} else {
return 'db-to-db';
}
};
export default function BatchEditPage() {
const params = useParams();
const router = useRouter();
const batchId = parseInt(params.id as string);
// 기본 상태
const [loading, setLoading] = useState(false);
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState("Y");
// 연결 정보
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
// 테이블 및 컬럼 정보
const [fromTables, setFromTables] = useState<string[]>([]);
const [toTables, setToTables] = useState<string[]>([]);
const [fromTable, setFromTable] = useState("");
const [toTable, setToTable] = useState("");
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
// 매핑 정보
const [mappings, setMappings] = useState<BatchMapping[]>([]);
// 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// 페이지 로드 시 배치 정보 조회
useEffect(() => {
if (batchId) {
loadBatchConfig();
loadConnections();
}
}, [batchId]);
// 연결 정보가 로드된 후 배치 설정의 연결 정보 설정
useEffect(() => {
if (batchConfig && connections.length > 0 && batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
const firstMapping = batchConfig.batch_mappings[0];
console.log("🔗 연결 정보 설정 시작:", firstMapping);
// FROM 연결 정보 설정
if (firstMapping.from_connection_type === 'internal') {
setFromConnection({ type: 'internal', name: '내부 DB' });
// 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables);
// 컬럼 정보도 로드
if (firstMapping.from_table_name) {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns);
});
}
});
} else if (firstMapping.from_connection_id) {
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
if (fromConn) {
setFromConnection(fromConn);
// 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables);
// 컬럼 정보도 로드
if (firstMapping.from_table_name) {
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns);
});
}
});
}
}
// TO 연결 정보 설정
if (firstMapping.to_connection_type === 'internal') {
setToConnection({ type: 'internal', name: '내부 DB' });
// 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
console.log("📋 TO 테이블 목록:", tables);
setToTables(tables);
// 컬럼 정보도 로드
if (firstMapping.to_table_name) {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns);
});
}
});
} else if (firstMapping.to_connection_id) {
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
if (toConn) {
setToConnection(toConn);
// 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(toConn).then(tables => {
console.log("📋 TO 테이블 목록:", tables);
setToTables(tables);
// 컬럼 정보도 로드
if (firstMapping.to_table_name) {
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns);
});
}
});
}
}
}
}, [batchConfig, connections]);
// 배치 설정 조회
const loadBatchConfig = async () => {
try {
setLoading(true);
console.log("🔍 배치 설정 조회 시작:", batchId);
const config = await BatchAPI.getBatchConfig(batchId);
console.log("📋 조회된 배치 설정:", config);
setBatchConfig(config);
setBatchName(config.batch_name);
setCronSchedule(config.cron_schedule);
setDescription(config.description || "");
setIsActive(config.is_active || "Y");
if (config.batch_mappings && config.batch_mappings.length > 0) {
console.log("📊 매핑 정보:", config.batch_mappings);
console.log("📊 매핑 개수:", config.batch_mappings.length);
config.batch_mappings.forEach((mapping, idx) => {
console.log(`📊 매핑 #${idx + 1}:`, {
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
type: mapping.mapping_type
});
});
setMappings(config.batch_mappings);
// 첫 번째 매핑에서 연결 및 테이블 정보 추출
const firstMapping = config.batch_mappings[0];
setFromTable(firstMapping.from_table_name);
setToTable(firstMapping.to_table_name);
// 배치 타입 감지
const detectedBatchType = detectBatchType(firstMapping);
setBatchType(detectedBatchType);
console.log("🎯 감지된 배치 타입:", detectedBatchType);
// FROM 연결 정보 설정
if (firstMapping.from_connection_type === 'internal') {
setFromConnection({ type: 'internal', name: '내부 DB' });
} else if (firstMapping.from_connection_id) {
// 외부 연결은 connections 로드 후 설정
setTimeout(() => {
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
if (fromConn) {
setFromConnection(fromConn);
}
}, 100);
}
// TO 연결 정보 설정
if (firstMapping.to_connection_type === 'internal') {
setToConnection({ type: 'internal', name: '내부 DB' });
} else if (firstMapping.to_connection_id) {
// 외부 연결은 connections 로드 후 설정
setTimeout(() => {
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
if (toConn) {
setToConnection(toConn);
}
}, 100);
}
console.log("🔗 테이블 정보 설정:", {
fromTable: firstMapping.from_table_name,
toTable: firstMapping.to_table_name,
fromConnectionType: firstMapping.from_connection_type,
toConnectionType: firstMapping.to_connection_type
});
}
} catch (error) {
console.error("❌ 배치 설정 조회 오류:", error);
toast.error("배치 설정을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
// 연결 정보 조회
const loadConnections = async () => {
try {
const connectionList = await BatchAPI.getConnections();
setConnections(connectionList);
} catch (error) {
console.error("연결 정보 조회 오류:", error);
toast.error("연결 정보를 불러오는데 실패했습니다.");
}
};
// FROM 연결 변경 시
const handleFromConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setFromConnection(connection);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setFromTables(tables);
setFromTable("");
setFromColumns([]);
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
}
};
// TO 연결 변경 시
const handleToConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setToConnection(connection);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setToTables(tables);
setToTable("");
setToColumns([]);
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
}
};
// FROM 테이블 변경 시
const handleFromTableChange = async (tableName: string) => {
setFromTable(tableName);
if (fromConnection && tableName) {
try {
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
setFromColumns(columns);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
}
}
};
// TO 테이블 변경 시
const handleToTableChange = async (tableName: string) => {
setToTable(tableName);
if (toConnection && tableName) {
try {
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
setToColumns(columns);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
}
}
};
// 매핑 추가
const addMapping = () => {
const newMapping: BatchMapping = {
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
from_table_name: fromTable,
from_column_name: '',
from_column_type: '',
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: '',
to_column_type: '',
mapping_type: 'direct',
mapping_order: mappings.length + 1
};
setMappings([...mappings, newMapping]);
};
// 매핑 삭제
const removeMapping = (index: number) => {
const updatedMappings = mappings.filter((_, i) => i !== index);
setMappings(updatedMappings);
};
// 매핑 업데이트
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
const updatedMappings = [...mappings];
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
setMappings(updatedMappings);
};
// 배치 설정 저장
const saveBatchConfig = async () => {
if (!batchName || !cronSchedule || mappings.length === 0) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
try {
setLoading(true);
await BatchAPI.updateBatchConfig(batchId, {
batchName,
description,
cronSchedule,
isActive,
mappings
});
toast.success("배치 설정이 성공적으로 수정되었습니다.");
router.push("/admin/batchmng");
} catch (error) {
console.error("배치 설정 수정 실패:", error);
toast.error("배치 설정 수정에 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading && !batchConfig) {
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin" />
<span className="ml-2"> ...</span>
</div>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<h1 className="text-3xl font-bold"> </h1>
</div>
<div className="flex space-x-2">
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={saveBatchConfig} disabled={loading}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
</div>
<div>
<Label htmlFor="cronSchedule"> (Cron) *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * *"
/>
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
rows={3}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={isActive === 'Y'}
onCheckedChange={(checked) => setIsActive(checked ? 'Y' : 'N')}
/>
<Label htmlFor="isActive"></Label>
</div>
</CardContent>
</Card>
{/* 배치 타입 표시 */}
{batchType && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<span> </span>
<Badge variant="outline">
{batchType === 'db-to-db' && 'DB → DB'}
{batchType === 'restapi-to-db' && 'REST API → DB'}
{batchType === 'db-to-restapi' && 'DB → REST API'}
</Badge>
</CardTitle>
</CardHeader>
</Card>
)}
{/* 연결 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{batchType === 'db-to-db' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* FROM 설정 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">FROM ()</h3>
<div>
<Label></Label>
<Select
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ''}
onValueChange={handleFromConnectionChange}
>
<SelectTrigger>
<SelectValue placeholder="소스 연결을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB</SelectItem>
{connections.filter(conn => conn.id).map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
{conn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select value={fromTable} onValueChange={handleFromTableChange}>
<SelectTrigger>
<SelectValue placeholder="소스 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{fromTables.map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* TO 설정 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">TO ()</h3>
<div>
<Label></Label>
<Select
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ''}
onValueChange={handleToConnectionChange}
>
<SelectTrigger>
<SelectValue placeholder="대상 연결을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB</SelectItem>
{connections.filter(conn => conn.id).map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
{conn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select value={toTable} onValueChange={handleToTableChange}>
<SelectTrigger>
<SelectValue placeholder="대상 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{toTables.map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{batchType === 'restapi-to-db' && (
<div className="space-y-6">
<div className="text-center py-4 bg-blue-50 rounded-lg">
<h3 className="text-lg font-semibold text-blue-800">REST API DB </h3>
<p className="text-sm text-blue-600"> REST API에서 .</p>
</div>
{mappings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>API URL</Label>
<Input value={mappings[0]?.from_api_url || ''} readOnly />
</div>
<div>
<Label>API </Label>
<Input value={mappings[0]?.from_table_name || ''} readOnly />
</div>
<div>
<Label>HTTP </Label>
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
</div>
<div>
<Label> </Label>
<Input value={mappings[0]?.to_table_name || ''} readOnly />
</div>
</div>
)}
</div>
)}
{batchType === 'db-to-restapi' && (
<div className="space-y-6">
<div className="text-center py-4 bg-green-50 rounded-lg">
<h3 className="text-lg font-semibold text-green-800">DB REST API </h3>
<p className="text-sm text-green-600"> REST API로 .</p>
</div>
{mappings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> </Label>
<Input value={mappings[0]?.from_table_name || ''} readOnly />
</div>
<div>
<Label>API URL</Label>
<Input value={mappings[0]?.to_api_url || ''} readOnly />
</div>
<div>
<Label>API </Label>
<Input value={mappings[0]?.to_table_name || ''} readOnly />
</div>
<div>
<Label>HTTP </Label>
<Input value={mappings[0]?.to_api_method || 'POST'} readOnly />
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* 컬럼 매핑 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{batchType === 'db-to-db' && '컬럼 매핑'}
{batchType === 'restapi-to-db' && 'API 필드 → DB 컬럼 매핑'}
{batchType === 'db-to-restapi' && 'DB 컬럼 → API 필드 매핑'}
{batchType === 'db-to-db' && (
<Button onClick={addMapping} size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
{mappings.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{batchType === 'db-to-db' && '매핑을 추가해주세요.'}
{batchType === 'restapi-to-db' && 'API 필드와 DB 컬럼 매핑 정보가 없습니다.'}
{batchType === 'db-to-restapi' && 'DB 컬럼과 API 필드 매핑 정보가 없습니다.'}
</div>
) : (
<div className="space-y-4">
{batchType === 'db-to-db' && mappings.map((mapping, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-medium"> #{index + 1}</h4>
{mapping.from_column_name && mapping.to_column_name && (
<p className="text-sm text-gray-600">
{mapping.from_column_name} {mapping.to_column_name}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeMapping(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>FROM </Label>
<Select
value={mapping.from_column_name || ''}
onValueChange={(value) => {
console.log(`📝 FROM 컬럼 변경: ${value}`);
updateMapping(index, 'from_column_name', value);
// 컬럼 타입도 함께 업데이트
const selectedColumn = fromColumns.find(col => col.column_name === value);
if (selectedColumn) {
updateMapping(index, 'from_column_type', selectedColumn.data_type);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="소스 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{fromColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
{column.column_name} ({column.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
{fromColumns.length === 0 && (
<p className="text-xs text-gray-500 mt-1">
.
</p>
)}
</div>
<div>
<Label>TO </Label>
<Select
value={mapping.to_column_name || ''}
onValueChange={(value) => {
console.log(`📝 TO 컬럼 변경: ${value}`);
updateMapping(index, 'to_column_name', value);
// 컬럼 타입도 함께 업데이트
const selectedColumn = toColumns.find(col => col.column_name === value);
if (selectedColumn) {
updateMapping(index, 'to_column_type', selectedColumn.data_type);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="대상 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{toColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
{column.column_name} ({column.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
{toColumns.length === 0 && (
<p className="text-xs text-gray-500 mt-1">
.
</p>
)}
</div>
</div>
</div>
))}
{batchType === 'restapi-to-db' && mappings.map((mapping, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-medium"> #{index + 1}</h4>
<p className="text-sm text-gray-600">
API : {mapping.from_column_name} DB : {mapping.to_column_name}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>API </Label>
<Input value={mapping.from_column_name || ''} readOnly />
</div>
<div>
<Label>DB </Label>
<Input value={mapping.to_column_name || ''} readOnly />
</div>
</div>
</div>
))}
{batchType === 'db-to-restapi' && mappings.map((mapping, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-medium"> #{index + 1}</h4>
<p className="text-sm text-gray-600">
DB : {mapping.from_column_name} API : {mapping.to_column_name}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>DB </Label>
<Input value={mapping.from_column_name || ''} readOnly />
</div>
<div>
<Label>API </Label>
<Input value={mapping.to_column_name || ''} readOnly />
</div>
</div>
{mapping.to_api_body && (
<div className="mt-4">
<Label>Request Body 릿</Label>
<Textarea
value={mapping.to_api_body}
readOnly
rows={3}
className="font-mono text-sm"
/>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 저장 버튼 */}
<div className="flex justify-end space-x-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
>
</Button>
<Button
onClick={saveBatchConfig}
disabled={loading || mappings.length === 0}
className="flex items-center space-x-2"
>
{loading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span>{loading ? "저장 중..." : "배치 설정 저장"}</span>
</Button>
</div>
</div>
);
}

View File

@ -1,450 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
Plus,
Search,
Play,
Pause,
Edit,
Trash2,
RefreshCw,
Clock,
Database,
ArrowRight,
Globe
} from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
BatchAPI,
BatchConfig,
BatchMapping,
} from "@/lib/api/batch";
export default function BatchManagementPage() {
const router = useRouter();
// 상태 관리
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
// 페이지 로드 시 배치 목록 조회
useEffect(() => {
loadBatchConfigs();
}, [currentPage, searchTerm]);
// 배치 설정 목록 조회
const loadBatchConfigs = async () => {
setLoading(true);
try {
const response = await BatchAPI.getBatchConfigs({
page: currentPage,
limit: 10,
search: searchTerm || undefined,
});
if (response.success && response.data) {
setBatchConfigs(response.data);
if (response.pagination) {
setTotalPages(response.pagination.totalPages);
}
} else {
setBatchConfigs([]);
}
} catch (error) {
console.error("배치 목록 조회 실패:", error);
toast.error("배치 목록을 불러오는데 실패했습니다.");
setBatchConfigs([]);
} finally {
setLoading(false);
}
};
// 배치 수동 실행
const executeBatch = async (batchId: number) => {
setExecutingBatch(batchId);
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
} else {
toast.error("배치 실행에 실패했습니다.");
}
} catch (error) {
console.error("배치 실행 실패:", error);
toast.error("배치 실행 중 오류가 발생했습니다.");
} finally {
setExecutingBatch(null);
}
};
// 배치 활성화/비활성화 토글
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try {
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("❌ 배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다.");
}
};
// 배치 삭제
const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
return;
}
try {
await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치가 삭제되었습니다.");
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다.");
}
};
// 검색 처리
const handleSearch = (value: string) => {
setSearchTerm(value);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 매핑 정보 요약 생성
const getMappingSummary = (mappings: BatchMapping[]) => {
if (!mappings || mappings.length === 0) {
return "매핑 없음";
}
const tableGroups = new Map<string, number>();
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
// 배치 추가 버튼 클릭 핸들러
const handleCreateBatch = () => {
setIsBatchTypeModalOpen(true);
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = '/admin/batch-management-new';
}
}
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Button
onClick={handleCreateBatch}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span> </span>
</Button>
</div>
{/* 검색 및 필터 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="flex items-center space-x-2"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span></span>
</Button>
</div>
</CardContent>
</Card>
{/* 배치 목록 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({batchConfigs.length})</span>
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
</CardTitle>
</CardHeader>
<CardContent>
{batchConfigs.length === 0 ? (
<div className="text-center py-12">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-muted-foreground mb-4">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span> </span>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{batchConfigs.map((batch) => (
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
{/* 배치 기본 정보 */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
{batch.is_active === 'Y' ? '활성' : '비활성'}
</Badge>
</div>
{batch.description && (
<p className="text-muted-foreground">{batch.description}</p>
)}
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<div className="flex items-center space-x-1">
<Clock className="h-4 w-4" />
<span>{batch.cron_schedule}</span>
</div>
<div>
: {new Date(batch.created_date).toLocaleDateString()}
</div>
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => executeBatch(batch.id)}
disabled={executingBatch === batch.id}
className="flex items-center space-x-1"
>
{executingBatch === batch.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId: batch.id, currentStatus: batch.is_active });
toggleBatchStatus(batch.id, batch.is_active);
}}
className="flex items-center space-x-1"
>
{batch.is_active === 'Y' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
className="flex items-center space-x-1"
>
<Edit className="h-4 w-4" />
<span></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => deleteBatch(batch.id, batch.batch_name)}
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
<span></span>
</Button>
</div>
</div>
{/* 매핑 정보 */}
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
({batch.batch_mappings.length})
</h4>
<div className="text-sm">
{getMappingSummary(batch.batch_mappings)}
</div>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex justify-center space-x-2">
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-2xl mx-4">
<CardHeader>
<CardTitle className="text-center"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* DB → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
onClick={() => handleBatchTypeSelect('db-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Database className="w-8 h-8 text-blue-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">DB DB</div>
<div className="text-sm text-gray-500"> </div>
</div>
</div>
{/* REST API → DB */}
<div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center justify-center mb-4">
<Globe className="w-8 h-8 text-green-600 mr-2" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
<Database className="w-8 h-8 text-green-600" />
</div>
<div className="text-center">
<div className="font-medium text-lg mb-2">REST API DB</div>
<div className="text-sm text-gray-500">REST API에서 </div>
</div>
</div>
</div>
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
>
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@ -23,7 +23,7 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { BatchAPI, BatchJob } from "@/lib/api/batch";
// import { CollectionAPI } from "@/lib/api/collection"; // 사용하지 않는 import 제거
import { CollectionAPI } from "@/lib/api/collection";
interface BatchJobModalProps {
isOpen: boolean;
@ -101,14 +101,12 @@ export default function BatchJobModal({
const loadCollectionConfigs = async () => {
try {
// 배치 설정 조회로 대체
const configs = await BatchAPI.getBatchConfigs({
const configs = await CollectionAPI.getCollectionConfigs({
is_active: "Y",
});
setCollectionConfigs(configs.data || []);
setCollectionConfigs(configs);
} catch (error) {
console.error("배치 설정 조회 오류:", error);
setCollectionConfigs([]);
console.error("수집 설정 조회 오류:", error);
}
};

View File

@ -1,92 +1,56 @@
// 배치관리 API 클라이언트 (새로운 API로 업데이트)
// 작성일: 2024-12-24
// 배치 관리 API 클라이언트
// 작성일: 2024-12-23
import { apiClient } from "./client";
export interface BatchConfig {
export interface BatchJob {
id?: number;
batch_name: string;
job_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
job_type: 'collection' | 'sync' | 'cleanup' | 'custom';
schedule_cron?: string;
is_active: string;
config_json?: Record<string, any>;
last_executed_at?: Date;
next_execution_at?: Date;
execution_count: number;
success_count: number;
failure_count: number;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
company_code: string;
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external';
from_connection_id?: number;
from_table_name: string;
from_column_name: string;
from_column_type?: string;
// TO 정보
to_connection_type: 'internal' | 'external';
to_connection_id?: number;
to_table_name: string;
to_column_name: string;
to_column_type?: string;
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
batch_name?: string;
export interface BatchJobFilter {
job_name?: string;
job_type?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface BatchJob {
id: number;
job_name: string;
job_type: string;
description?: string;
cron_schedule: string;
is_active: string;
last_execution?: Date;
next_execution?: Date;
status?: string;
created_date?: Date;
created_by?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external';
export interface BatchExecution {
id?: number;
name: string;
db_type?: string;
job_id: number;
execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
started_at?: Date;
completed_at?: Date;
execution_time_ms?: number;
result_data?: Record<string, any>;
error_message?: string;
log_details?: string;
created_date?: Date;
}
export interface ColumnInfo {
column_name: string;
data_type: string;
is_nullable?: boolean;
column_default?: string;
}
export interface TableInfo {
table_name: string;
columns: ColumnInfo[];
description?: string | null;
}
export interface BatchMappingRequest {
batchName: string;
description?: string;
cronSchedule: string;
mappings: BatchMapping[];
isActive?: boolean;
export interface BatchMonitoring {
total_jobs: number;
active_jobs: number;
running_jobs: number;
failed_jobs_today: number;
successful_jobs_today: number;
recent_executions: BatchExecution[];
}
export interface ApiResponse<T> {
@ -97,251 +61,25 @@ export interface ApiResponse<T> {
}
export class BatchAPI {
private static readonly BASE_PATH = "";
/**
*
*/
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<{
success: boolean;
data: BatchConfig[];
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
message?: string;
}> {
try {
const params = new URLSearchParams();
if (filter.is_active) params.append("is_active", filter.is_active);
if (filter.company_code) params.append("company_code", filter.company_code);
if (filter.search) params.append("search", filter.search);
if (filter.page) params.append("page", filter.page.toString());
if (filter.limit) params.append("limit", filter.limit.toString());
const response = await apiClient.get<{
success: boolean;
data: BatchConfig[];
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
message?: string;
}>(`/batch-configs?${params.toString()}`);
return response.data;
} catch (error) {
console.error("배치 설정 목록 조회 오류:", error);
return {
success: false,
data: [],
message: error instanceof Error ? error.message : "배치 설정 목록 조회에 실패했습니다."
};
}
}
/**
* ()
*/
static async getBatchConfig(id: number): Promise<BatchConfig> {
return this.getBatchConfigById(id);
}
/**
*
*/
static async getBatchConfigById(id: number): Promise<BatchConfig> {
try {
const response = await apiClient.get<ApiResponse<BatchConfig>>(
`/batch-management/batch-configs/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정을 찾을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
try {
const response = await apiClient.post<ApiResponse<BatchConfig>>(
`/batch-configs`,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 생성에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 생성 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 생성 오류:", error);
throw error;
}
}
/**
*
*/
static async updateBatchConfig(
id: number,
data: Partial<BatchMappingRequest>
): Promise<BatchConfig> {
try {
const response = await apiClient.put<ApiResponse<BatchConfig>>(
`/batch-management/batch-configs/${id}`,
data,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 수정에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 설정 수정 결과를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 설정 수정 오류:", error);
throw error;
}
}
/**
*
*/
static async deleteBatchConfig(id: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<void>>(
`/batch-configs/${id}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 설정 삭제에 실패했습니다.");
}
} catch (error) {
console.error("배치 설정 삭제 오류:", error);
throw error;
}
}
/**
*
*/
static async getConnections(): Promise<ConnectionInfo[]> {
try {
console.log("[BatchAPI] getAvailableConnections 호출 시작");
console.log("[BatchAPI] API URL:", `/batch-management/connections`);
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
`/batch-management/connections`,
);
console.log("[BatchAPI] API 응답:", response);
console.log("[BatchAPI] 응답 데이터:", response.data);
if (!response.data.success) {
console.error("[BatchAPI] API 응답 실패:", response.data);
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
}
const result = response.data.data || [];
console.log("[BatchAPI] 최종 결과:", result);
return result;
} catch (error) {
console.error("[BatchAPI] 커넥션 목록 조회 오류:", error);
console.error("[BatchAPI] 오류 상세:", {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
}
/**
*
*/
static async getTablesFromConnection(
connection: ConnectionInfo
): Promise<string[]> {
try {
let url = `/batch-management/connections/${connection.type}`;
if (connection.type === 'external' && connection.id) {
url += `/${connection.id}`;
}
url += '/tables';
const response = await apiClient.get<ApiResponse<TableInfo[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
}
// TableInfo[]에서 table_name만 추출하여 string[]로 변환
const tables = response.data.data || [];
return tables.map(table => table.table_name);
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTableColumns(
connection: ConnectionInfo,
tableName: string
): Promise<ColumnInfo[]> {
try {
let url = `/batch-management/connections/${connection.type}`;
if (connection.type === 'external' && connection.id) {
url += `/${connection.id}`;
}
url += `/tables/${tableName}/columns`;
const response = await apiClient.get<ApiResponse<ColumnInfo[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
throw error;
}
}
private static readonly BASE_PATH = "/batch";
/**
*
*/
static async getBatchJobs(): Promise<BatchJob[]> {
static async getBatchJobs(filter: BatchJobFilter = {}): Promise<BatchJob[]> {
try {
const response = await apiClient.get<ApiResponse<BatchJob[]>>('/batch-management/jobs');
const params = new URLSearchParams();
if (filter.job_name) params.append("job_name", filter.job_name);
if (filter.job_type) params.append("job_type", filter.job_type);
if (filter.is_active) params.append("is_active", filter.is_active);
if (filter.company_code) params.append("company_code", filter.company_code);
if (filter.search) params.append("search", filter.search);
const response = await apiClient.get<ApiResponse<BatchJob[]>>(
`${this.BASE_PATH}?${params.toString()}`
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
}
@ -354,39 +92,206 @@ export class BatchAPI {
}
/**
*
*
*/
static async executeBatchConfig(batchId: number): Promise<{
success: boolean;
message?: string;
data?: {
batchId: string;
totalRecords: number;
successRecords: number;
failedRecords: number;
duration: number;
};
}> {
static async getBatchJobById(id: number): Promise<BatchJob> {
try {
const response = await apiClient.post<{
success: boolean;
message?: string;
data?: {
batchId: string;
totalRecords: number;
successRecords: number;
failedRecords: number;
duration: number;
};
}>(`/batch-management/batch-configs/${batchId}/execute`);
const response = await apiClient.get<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`);
return response.data;
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 작업을 찾을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 실행 오류:", error);
console.error("배치 작업 조회 오류:", error);
throw error;
}
}
}
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
export { BatchJob };
/**
*
*/
static async createBatchJob(data: BatchJob): Promise<BatchJob> {
try {
const response = await apiClient.post<ApiResponse<BatchJob>>(this.BASE_PATH, data);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 생성에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("생성된 배치 작업 정보를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 작업 생성 오류:", error);
throw error;
}
}
/**
*
*/
static async updateBatchJob(id: number, data: Partial<BatchJob>): Promise<BatchJob> {
try {
const response = await apiClient.put<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`, data);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 수정에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("수정된 배치 작업 정보를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 작업 수정 오류:", error);
throw error;
}
}
/**
*
*/
static async deleteBatchJob(id: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
}
} catch (error) {
console.error("배치 작업 삭제 오류:", error);
throw error;
}
}
/**
*
*/
static async executeBatchJob(id: number): Promise<BatchExecution> {
try {
const response = await apiClient.post<ApiResponse<BatchExecution>>(`${this.BASE_PATH}/${id}/execute`);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 실행 정보를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 작업 실행 오류:", error);
throw error;
}
}
/**
*
*/
static async getBatchExecutions(jobId?: number): Promise<BatchExecution[]> {
try {
const params = new URLSearchParams();
if (jobId) params.append("job_id", jobId.toString());
const response = await apiClient.get<ApiResponse<BatchExecution[]>>(
`${this.BASE_PATH}/executions/list?${params.toString()}`
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 실행 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("배치 실행 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getBatchMonitoring(): Promise<BatchMonitoring> {
try {
const response = await apiClient.get<ApiResponse<BatchMonitoring>>(
`${this.BASE_PATH}/monitoring/status`
);
if (!response.data.success) {
throw new Error(response.data.message || "배치 모니터링 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 모니터링 정보를 받을 수 없습니다.");
}
return response.data.data;
} catch (error) {
console.error("배치 모니터링 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getSupportedJobTypes(): Promise<Array<{ value: string; label: string }>> {
try {
const response = await apiClient.get<ApiResponse<{ types: Array<{ value: string; label: string }> }>>(
`${this.BASE_PATH}/types/supported`
);
if (!response.data.success) {
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
}
return response.data.data?.types || [];
} catch (error) {
console.error("작업 타입 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getSchedulePresets(): Promise<Array<{ value: string; label: string }>> {
try {
const response = await apiClient.get<ApiResponse<{ presets: Array<{ value: string; label: string }> }>>(
`${this.BASE_PATH}/schedules/presets`
);
if (!response.data.success) {
throw new Error(response.data.message || "스케줄 프리셋 조회에 실패했습니다.");
}
return response.data.data?.presets || [];
} catch (error) {
console.error("스케줄 프리셋 조회 오류:", error);
throw error;
}
}
/**
*
*/
static getExecutionStatusOptions() {
return [
{ value: 'pending', label: '대기 중' },
{ value: 'running', label: '실행 중' },
{ value: 'completed', label: '완료' },
{ value: 'failed', label: '실패' },
{ value: 'cancelled', label: '취소됨' },
];
}
}

View File

@ -1,178 +0,0 @@
// 배치관리 전용 API 클라이언트 (기존 소스와 완전 분리)
// 작성일: 2024-12-24
import { apiClient } from "./client";
// 배치관리 전용 타입 정의
export interface BatchConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface BatchColumnInfo {
column_name: string;
data_type: string;
is_nullable?: string;
column_default?: string | null;
}
export interface BatchTableInfo {
table_name: string;
columns: BatchColumnInfo[];
description?: string | null;
}
export interface BatchApiResponse<T = unknown> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
class BatchManagementAPIClass {
private static readonly BASE_PATH = "/batch-management";
/**
*
*/
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
try {
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(
`${this.BASE_PATH}/connections`
);
if (!response.data.success) {
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("커넥션 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTablesFromConnection(
connectionType: 'internal' | 'external',
connectionId?: number
): Promise<string[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
url += `/${connectionId}`;
}
url += '/tables';
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
if (!response.data.success) {
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTableColumns(
connectionType: 'internal' | 'external',
tableName: string,
connectionId?: number
): Promise<BatchColumnInfo[]> {
try {
let url = `${this.BASE_PATH}/connections/${connectionType}`;
if (connectionType === 'external' && connectionId) {
url += `/${connectionId}`;
}
url += `/tables/${encodeURIComponent(tableName)}/columns`;
console.log("🔍 컬럼 조회 API 호출:", { url, connectionType, connectionId, tableName });
const response = await apiClient.get<BatchApiResponse<BatchColumnInfo[]>>(url);
console.log("🔍 컬럼 조회 API 응답:", response.data);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("❌ 컬럼 정보 조회 오류:", error);
throw error;
}
}
/**
* REST API
*/
static async previewRestApiData(
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'GET' = 'GET'
): Promise<{
fields: string[];
samples: any[];
totalCount: number;
}> {
try {
const response = await apiClient.post<BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
}>>(`${this.BASE_PATH}/rest-api/preview`, {
apiUrl,
apiKey,
endpoint,
method
});
if (!response.data.success) {
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
}
return response.data.data || { fields: [], samples: [], totalCount: 0 };
} catch (error) {
console.error("REST API 미리보기 오류:", error);
throw error;
}
}
/**
* REST API
*/
static async saveRestApiBatch(batchData: {
batchName: string;
batchType: string;
cronSchedule: string;
description?: string;
apiMappings: any[];
}): Promise<{ success: boolean; message: string; data?: any; }> {
try {
const response = await apiClient.post<BatchApiResponse<any>>(
`${this.BASE_PATH}/rest-api/save`, batchData
);
return {
success: response.data.success,
message: response.data.message || "",
data: response.data.data
};
} catch (error) {
console.error("REST API 배치 저장 오류:", error);
throw error;
}
}
}
export const BatchManagementAPI = BatchManagementAPIClass;

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsxImportSource": "react",
"incremental": true,
"plugins": [
{

113
package-lock.json generated
View File

@ -10,10 +10,6 @@
"axios": "^1.12.2",
"mssql": "^11.0.1",
"prisma": "^6.16.2"
},
"devDependencies": {
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5"
}
},
"node_modules/@azure-rest/core-client": {
@ -391,28 +387,6 @@
"undici-types": "~7.12.0"
}
},
"node_modules/@types/oracledb": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.9.1.tgz",
"integrity": "sha512-rXDnApyfaki0dvHuqzQvfirK6yHbtEO5nJ4CXKHrZYdwNAx4PjddqoCXdN1dZaEnZxXFwCy9xEWyIemL8EI/NQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.15.5",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz",
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.21",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.21.tgz",
@ -1369,40 +1343,6 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@ -1414,49 +1354,6 @@
"pathe": "^2.0.3"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prisma": {
"version": "6.16.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
@ -1694,16 +1591,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@ -5,9 +5,5 @@
"axios": "^1.12.2",
"mssql": "^11.0.1",
"prisma": "^6.16.2"
},
"devDependencies": {
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5"
}
}