lhj #73

Merged
hjlee merged 19 commits from lhj into dev 2025-09-29 17:22:50 +09:00
40 changed files with 10345 additions and 1914 deletions
Showing only changes of commit 2448f26bc3 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.7.1",
"@prisma/client": "^6.16.2",
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
@ -46,7 +46,6 @@
"nodemailer": "^6.9.7",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"prisma": "^5.7.1",
"redis": "^4.6.10",
"winston": "^3.11.0"
},
@ -73,6 +72,7 @@
"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,6 +80,83 @@ 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,11 +33,16 @@ 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 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 ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
// import userRoutes from './routes/userRoutes';
@ -143,7 +148,10 @@ 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/db-type-categories", dbTypeCategoryRoutes);
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/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
@ -169,11 +177,19 @@ app.use(errorHandler);
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, () => {
app.listen(PORT, HOST, async () => {
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,294 +1,281 @@
// 배치 관리 컨트롤러
// 작성일: 2024-12-23
// 배치관리 컨트롤러
// 작성일: 2024-12-24
import { Request, Response } from 'express';
import { BatchService } from '../services/batchService';
import { BatchJob, BatchJobFilter } from '../types/batchManagement';
import { AuthenticatedRequest } from '../middleware/authMiddleware';
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;
};
}
export class BatchController {
/**
*
*
* GET /api/batch-configs
*/
static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise<void> {
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
try {
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 { 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 jobs = await BatchService.getBatchJobs(filter);
res.status(200).json({
const result = await BatchService.getBatchConfigs(filter);
res.json({
success: true,
data: jobs,
message: '배치 작업 목록을 조회했습니다.',
data: result.data,
pagination: result.pagination
});
} catch (error) {
console.error('배치 작업 목록 조회 오류:', error);
console.error("배치 설정 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.',
message: "배치 설정 목록 조회에 실패했습니다."
});
}
}
/**
*
*
* GET /api/batch-configs/connections
*/
static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise<void> {
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
const result = await BatchService.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: "커넥션 목록 조회에 실패했습니다."
});
}
}
/**
* (/ DB)
* GET /api/batch-configs/connections/:type/tables
* GET /api/batch-configs/connections/:type/:id/tables
*/
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
try {
const { type, id } = req.params;
if (!type || (type !== 'internal' && type !== 'external')) {
return res.status(400).json({
success: false,
message: '유효하지 않은 ID입니다.',
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
});
return;
}
const job = await BatchService.getBatchJobById(id);
if (!job) {
res.status(404).json({
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: "테이블 목록 조회에 실패했습니다."
});
}
}
/**
* (/ 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: '배치 작업을 찾을 수 없습니다.',
message: "연결 타입과 테이블명을 모두 지정해주세요."
});
return;
}
res.status(200).json({
success: true,
data: job,
message: '배치 작업을 조회했습니다.',
});
} catch (error) {
console.error('배치 작업 조회 오류:', error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.',
});
}
}
/**
*
*/
static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
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({
if (type !== 'internal' && type !== 'external') {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다.',
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
});
return;
}
const job = await BatchService.createBatchJob(data);
res.status(201).json({
success: true,
data: job,
message: '배치 작업을 생성했습니다.',
});
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);
res.status(500).json({
console.error("컬럼 정보 조회 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.',
message: "컬럼 정보 조회에 실패했습니다."
});
}
}
/**
*
*
* GET /api/batch-configs/:id
*/
static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
const { id } = req.params;
const batchConfig = await BatchService.getBatchConfigById(Number(id));
if (!batchConfig) {
return res.status(404).json({
success: false,
message: '유효하지 않은 ID입니다.',
message: "배치 설정을 찾을 수 없습니다."
});
}
return res.json({
success: true,
data: batchConfig
});
} catch (error) {
console.error("배치 설정 조회 오류:", error);
return res.status(500).json({
success: false,
message: "배치 설정 조회에 실패했습니다."
});
}
}
/**
*
* POST /api/batch-configs
*/
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, description, cronSchedule, mappings } = req.body;
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
});
return;
}
const data: Partial<BatchJob> = {
...req.body,
updated_by: req.user?.userId,
};
const job = await BatchService.updateBatchJob(id, data);
res.status(200).json({
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings
} as CreateBatchConfigRequest);
return res.status(201).json({
success: true,
data: job,
message: '배치 작업을 수정했습니다.',
data: batchConfig,
message: "배치 설정이 성공적으로 생성되었습니다."
});
} catch (error) {
console.error('배치 작업 수정 오류:', error);
res.status(500).json({
console.error("배치 설정 생성 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.',
message: "배치 설정 생성에 실패했습니다."
});
}
}
/**
*
*
* PUT /api/batch-configs/:id
*/
static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
const { id } = req.params;
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
if (!batchName || !cronSchedule) {
return res.status(400).json({
success: false,
message: '유효하지 않은 ID입니다.',
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
});
return;
}
await BatchService.deleteBatchJob(id);
res.status(200).json({
success: true,
message: '배치 작업을 삭제했습니다.',
});
} catch (error) {
console.error('배치 작업 삭제 오류:', error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.',
});
}
}
/**
*
*/
static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
batchName,
description,
cronSchedule,
mappings,
isActive
} as UpdateBatchConfigRequest);
if (!batchConfig) {
return res.status(404).json({
success: false,
message: '유효하지 않은 ID입니다.',
message: "배치 설정을 찾을 수 없습니다."
});
return;
}
const execution = await BatchService.executeBatchJob(id);
res.status(200).json({
return res.json({
success: true,
data: execution,
message: '배치 작업을 실행했습니다.',
data: batchConfig,
message: "배치 설정이 성공적으로 수정되었습니다."
});
} catch (error) {
console.error('배치 작업 실행 오류:', error);
res.status(500).json({
console.error("배치 설정 수정 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.',
message: "배치 설정 수정에 실패했습니다."
});
}
}
/**
*
* ( )
* DELETE /api/batch-configs/:id
*/
static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise<void> {
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
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({
const { id } = req.params;
const result = await BatchService.deleteBatchConfig(Number(id));
if (!result) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다."
});
}
return res.json({
success: true,
data: executions,
message: '배치 실행 목록을 조회했습니다.',
message: "배치 설정이 성공적으로 삭제되었습니다."
});
} catch (error) {
console.error('배치 실행 목록 조회 오류:', error);
res.status(500).json({
console.error("배치 설정 삭제 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.',
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

@ -0,0 +1,179 @@
// 배치 실행 로그 컨트롤러
// 작성일: 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

@ -0,0 +1,619 @@
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
// 작성일: 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,6 +3,7 @@ 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>();
@ -33,6 +34,9 @@ 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,5 +1,6 @@
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,10 +1,7 @@
import {
DatabaseConnector,
ConnectionConfig,
QueryResult,
} from "../interfaces/DatabaseConnector";
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
import * as mysql from "mysql2/promise";
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
// @ts-ignore
import * as mysql from 'mysql2/promise';
export class MariaDBConnector implements DatabaseConnector {
private connection: mysql.Connection | null = null;
@ -22,18 +19,8 @@ export class MariaDBConnector implements DatabaseConnector {
user: this.config.user,
password: this.config.password,
database: this.config.database,
// 🔧 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,
connectTimeout: this.config.connectionTimeoutMillis,
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
});
}
}
@ -49,9 +36,7 @@ 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();
@ -79,18 +64,7 @@ export class MariaDBConnector implements DatabaseConnector {
async executeQuery(query: string): Promise<QueryResult> {
try {
await this.connect();
// 🔧 쿼리 타임아웃 수동 구현 (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;
const [rows, fields] = await this.connection!.query(query);
await this.disconnect();
return {
rows: rows as any[],
@ -133,54 +107,28 @@ export class MariaDBConnector implements DatabaseConnector {
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`🔍 MariaDB 컬럼 조회 시작: ${tableName}`);
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
await this.connect();
// 🔧 컬럼 조회 타임아웃 수동 구현 (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 = `
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
const [rows] = await this.connection!.query(`
SELECT
COLUMN_NAME as column_name,
DATA_TYPE as data_type,
IS_NULLABLE as is_nullable,
COLUMN_DEFAULT as column_default,
COLUMN_COMMENT as column_comment
COLUMN_DEFAULT as column_default
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
`;
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}개 컬럼`
);
`, [tableName]);
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
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,3 +1,4 @@
// @ts-ignore
import * as oracledb from 'oracledb';
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
@ -100,7 +101,7 @@ export class OracleConnector implements DatabaseConnector {
// Oracle XE 21c 쿼리 실행 옵션
const options: any = {
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
maxRows: 10000, // XE 제한 고려
fetchArraySize: 100
};
@ -176,6 +177,8 @@ export class OracleConnector implements DatabaseConnector {
async getColumns(tableName: string): Promise<any[]> {
try {
console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`);
const query = `
SELECT
column_name,
@ -190,16 +193,23 @@ export class OracleConnector implements DatabaseConnector {
ORDER BY column_id
`;
console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`);
const result = await this.executeQuery(query, [tableName]);
return result.rows.map((row: any) => ({
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
const mappedResult = 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('Oracle 테이블 컬럼 조회 실패:', error);
console.error('[OracleConnector] getColumns 오류:', error);
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
}
}

View File

@ -0,0 +1,261 @@
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

@ -0,0 +1,47 @@
// 배치 실행 로그 라우트
// 작성일: 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

@ -0,0 +1,82 @@
// 배치관리 전용 라우트 (기존 소스와 완전 분리)
// 작성일: 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,73 +1,70 @@
// 배치 관리 라우트
// 작성일: 2024-12-23
// 배치관리 라우트
// 작성일: 2024-12-24
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();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* GET /api/batch-configs
*
*/
router.get("/", authenticateToken, BatchController.getBatchConfigs);
/**
* GET /api/batch
*
* GET /api/batch-configs/connections
*
*/
router.get('/', BatchController.getBatchJobs);
router.get("/connections", BatchController.getAvailableConnections);
/**
* GET /api/batch/:id
*
* GET /api/batch-configs/connections/:type/tables
* DB
*/
router.get('/:id', BatchController.getBatchJobById);
router.get("/connections/:type/tables", authenticateToken, BatchController.getTablesFromConnection);
/**
* POST /api/batch
*
* GET /api/batch-configs/connections/:type/:id/tables
* DB
*/
router.post('/', BatchController.createBatchJob);
router.get("/connections/:type/:id/tables", authenticateToken, BatchController.getTablesFromConnection);
/**
* PUT /api/batch/:id
*
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
* DB
*/
router.put('/:id', BatchController.updateBatchJob);
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
/**
* DELETE /api/batch/:id
*
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
* DB
*/
router.delete('/:id', BatchController.deleteBatchJob);
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
/**
* POST /api/batch/:id/execute
*
* GET /api/batch-configs/:id
*
*/
router.post('/:id/execute', BatchController.executeBatchJob);
router.get("/:id", authenticateToken, BatchController.getBatchConfigById);
/**
* GET /api/batch/executions
*
* POST /api/batch-configs
*
*/
router.get('/executions/list', BatchController.getBatchExecutions);
router.post("/", authenticateToken, BatchController.createBatchConfig);
/**
* GET /api/batch/monitoring
*
* PUT /api/batch-configs/:id
*
*/
router.get('/monitoring/status', BatchController.getBatchMonitoring);
router.put("/:id", authenticateToken, BatchController.updateBatchConfig);
/**
* GET /api/batch/types/supported
*
* DELETE /api/batch-configs/:id
* ( )
*/
router.get('/types/supported', BatchController.getSupportedJobTypes);
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
/**
* GET /api/batch/schedules/presets
*
*/
router.get('/schedules/presets', BatchController.getSchedulePresets);
export default router;
export default router;

View File

@ -0,0 +1,299 @@
// 배치 실행 로그 서비스
// 작성일: 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

@ -0,0 +1,912 @@
// 배치관리 전용 외부 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

@ -0,0 +1,373 @@
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
// 작성일: 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

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

View File

@ -1,7 +1,7 @@
// 외부 DB 연결 서비스
// 작성일: 2024-12-17
import { PrismaClient } from "@prisma/client";
import prisma from "../config/database";
import {
ExternalDbConnection,
ExternalDbConnectionFilter,
@ -11,9 +11,6 @@ import {
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
import prisma = require("../config/database");
export class ExternalDbConnectionService {
/**
* DB
@ -91,23 +88,26 @@ 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

@ -0,0 +1,64 @@
// 배치 실행 로그 타입 정의
// 작성일: 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

@ -0,0 +1,139 @@
// 배치관리 타입 정의
// 작성일: 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;
};
}

18
backend-node/src/types/oracledb.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
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/**/*"],
"include": ["src/**/*", "src/types/**/*.d.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

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

585
docs/batch.html Normal file
View File

@ -0,0 +1,585 @@
<!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,6 +1,7 @@
"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";
@ -34,13 +35,17 @@ import {
Trash2,
Play,
RefreshCw,
BarChart3
BarChart3,
ArrowRight,
Database,
Globe
} 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);
@ -52,6 +57,7 @@ export default function BatchManagementPage() {
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
useEffect(() => {
loadJobs();
@ -109,8 +115,23 @@ export default function BatchManagementPage() {
};
const handleCreate = () => {
setSelectedJob(null);
setIsModalOpen(true);
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');
}
};
const handleEdit = (job: BatchJob) => {
@ -185,12 +206,11 @@ export default function BatchManagementPage() {
};
return (
<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>
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
.
</p>
@ -422,6 +442,61 @@ 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}
@ -429,7 +504,6 @@ export default function BatchManagementPage() {
onSave={handleModalSave}
job={selectedJob}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,544 @@
"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

@ -0,0 +1,833 @@
"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

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

View File

@ -1,56 +1,92 @@
// 배치 관리 API 클라이언트
// 작성일: 2024-12-23
// 배치관리 API 클라이언트 (새로운 API로 업데이트)
// 작성일: 2024-12-24
import { apiClient } from "./client";
export interface BatchJob {
export interface BatchConfig {
id?: number;
job_name: string;
batch_name: string;
description?: 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;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
company_code: string;
batch_mappings?: BatchMapping[];
}
export interface BatchJobFilter {
job_name?: string;
job_type?: 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;
is_active?: string;
company_code?: string;
search?: string;
}
export interface BatchExecution {
id?: number;
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;
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 BatchMonitoring {
total_jobs: number;
active_jobs: number;
running_jobs: number;
failed_jobs_today: number;
successful_jobs_today: number;
recent_executions: BatchExecution[];
export interface ConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
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 ApiResponse<T> {
@ -61,25 +97,251 @@ export interface ApiResponse<T> {
}
export class BatchAPI {
private static readonly BASE_PATH = "/batch";
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;
}
}
/**
*
*/
static async getBatchJobs(filter: BatchJobFilter = {}): Promise<BatchJob[]> {
static async getBatchJobs(): Promise<BatchJob[]> {
try {
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()}`
);
const response = await apiClient.get<ApiResponse<BatchJob[]>>('/batch-management/jobs');
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
}
@ -92,206 +354,39 @@ export class BatchAPI {
}
/**
*
*
*/
static async getBatchJobById(id: number): Promise<BatchJob> {
static async executeBatchConfig(batchId: number): Promise<{
success: boolean;
message?: string;
data?: {
batchId: string;
totalRecords: number;
successRecords: number;
failedRecords: number;
duration: number;
};
}> {
try {
const response = await apiClient.get<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`);
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`);
if (!response.data.success) {
throw new Error(response.data.message || "배치 작업 조회에 실패했습니다.");
}
if (!response.data.data) {
throw new Error("배치 작업을 찾을 수 없습니다.");
}
return response.data.data;
return response.data;
} catch (error) {
console.error("배치 작업 조회 오류:", error);
console.error("배치 실행 오류:", error);
throw error;
}
}
/**
*
*/
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: '취소됨' },
];
}
}
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
export { BatchJob };

View File

@ -0,0 +1,178 @@
// 배치관리 전용 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,6 +12,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsxImportSource": "react",
"incremental": true,
"plugins": [
{

113
package-lock.json generated
View File

@ -10,6 +10,10 @@
"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": {
@ -387,6 +391,28 @@
"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",
@ -1343,6 +1369,40 @@
"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",
@ -1354,6 +1414,49 @@
"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",
@ -1591,6 +1694,16 @@
"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,5 +5,9 @@
"axios": "^1.12.2",
"mssql": "^11.0.1",
"prisma": "^6.16.2"
},
"devDependencies": {
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5"
}
}