Merge branch 'main' into feature/batch-testing-updates

This commit is contained in:
2025-09-29 14:17:22 +09:00
31 changed files with 4722 additions and 90 deletions

View File

@@ -0,0 +1,34 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addDataMappingColumn() {
try {
console.log(
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
);
// data_mapping_config JSONB 컬럼 추가
await prisma.$executeRaw`
ALTER TABLE external_call_configs
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
`;
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
// 기존 레코드에 기본값 설정
await prisma.$executeRaw`
UPDATE external_call_configs
SET data_mapping_config = '{"direction": "none"}'::jsonb
WHERE data_mapping_config IS NULL
`;
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
} catch (error) {
console.error("❌ 컬럼 추가 실패:", error);
} finally {
await prisma.$disconnect();
}
}
addDataMappingColumn();

View File

@@ -42,6 +42,7 @@ import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -96,7 +97,7 @@ app.use(
// Rate Limiting (개발 환경에서는 완화)
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
message: {
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
},
@@ -156,6 +157,7 @@ app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@@ -727,3 +727,35 @@ function processDataflowInBackground(
}
}, 1000); // 1초 후 실행 시뮬레이션
}
/**
* 🔥 전체 관계 목록 조회 (버튼 제어용)
*/
export async function getAllRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
logger.info(`전체 관계 목록 조회 요청 - companyCode: ${companyCode}`);
// 모든 관계도에서 관계 목록을 가져옴
const allRelationships = await dataflowDiagramService.getAllRelationshipsForButtonControl(companyCode);
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
res.json({
success: true,
data: allRelationships,
message: `전체 관계 ${allRelationships.length}개 조회 완료`,
});
} catch (error) {
logger.error("전체 관계 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "전체 관계 목록 조회 실패",
errorCode: "GET_ALL_RELATIONSHIPS_ERROR",
});
}
}

View File

@@ -0,0 +1,235 @@
/**
* 🔥 데이터플로우 실행 컨트롤러
*
* 버튼 제어에서 관계 실행 시 사용되는 컨트롤러
*/
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { PrismaClient } from "@prisma/client";
import logger from "../utils/logger";
const prisma = new PrismaClient();
/**
* 데이터 액션 실행
*/
export async function executeDataAction(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data, actionType, connection } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info(`데이터 액션 실행 시작: ${actionType} on ${tableName}`, {
tableName,
actionType,
dataKeys: Object.keys(data),
connection: connection?.name,
});
// 연결 정보에 따라 다른 데이터베이스에 저장
let result;
if (connection && connection.id !== 0) {
// 외부 데이터베이스 연결
result = await executeExternalDatabaseAction(tableName, data, actionType, connection);
} else {
// 메인 데이터베이스 (현재 시스템)
result = await executeMainDatabaseAction(tableName, data, actionType, companyCode);
}
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
res.json({
success: true,
message: `데이터 액션 실행 완료: ${actionType}`,
data: result,
});
} catch (error: any) {
logger.error("데이터 액션 실행 실패:", error);
res.status(500).json({
success: false,
message: `데이터 액션 실행 실패: ${error.message}`,
errorCode: "DATA_ACTION_EXECUTION_ERROR",
});
}
}
/**
* 메인 데이터베이스에서 데이터 액션 실행
*/
async function executeMainDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
companyCode: string
): Promise<any> {
try {
// 회사 코드 추가
const dataWithCompany = {
...data,
company_code: companyCode,
};
switch (actionType.toLowerCase()) {
case 'insert':
return await executeInsert(tableName, dataWithCompany);
case 'update':
return await executeUpdate(tableName, dataWithCompany);
case 'upsert':
return await executeUpsert(tableName, dataWithCompany);
case 'delete':
return await executeDelete(tableName, dataWithCompany);
default:
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
}
} catch (error) {
logger.error(`메인 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
* 외부 데이터베이스에서 데이터 액션 실행
*/
async function executeExternalDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
connection: any
): Promise<any> {
try {
// TODO: 외부 데이터베이스 연결 및 실행 로직 구현
// 현재는 로그만 출력하고 성공으로 처리
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
// 임시 성공 응답
return {
success: true,
message: `외부 DB 액션 실행 완료: ${actionType} on ${tableName}`,
connection: connection.name,
affectedRows: 1,
};
} catch (error) {
logger.error(`외부 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
* INSERT 실행
*/
async function executeInsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 동적 테이블 접근을 위한 raw query 사용
const columns = Object.keys(data).join(', ');
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
logger.info(`INSERT 쿼리 실행:`, { query, values });
const result = await prisma.$queryRawUnsafe(query, ...values);
return {
success: true,
action: 'insert',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`INSERT 실행 오류:`, error);
throw error;
}
}
/**
* UPDATE 실행
*/
async function executeUpdate(tableName: string, data: Record<string, any>): Promise<any> {
try {
// ID 또는 기본키를 기준으로 업데이트
const { id, ...updateData } = data;
if (!id) {
throw new Error('UPDATE를 위한 ID가 필요합니다');
}
const setClause = Object.keys(updateData)
.map((key, index) => `${key} = $${index + 1}`)
.join(', ');
const values = Object.values(updateData);
const query = `UPDATE ${tableName} SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`;
logger.info(`UPDATE 쿼리 실행:`, { query, values: [...values, id] });
const result = await prisma.$queryRawUnsafe(query, ...values, id);
return {
success: true,
action: 'update',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`UPDATE 실행 오류:`, error);
throw error;
}
}
/**
* UPSERT 실행
*/
async function executeUpsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
try {
return await executeInsert(tableName, data);
} catch (insertError) {
// INSERT 실패 시 UPDATE 시도
logger.info(`INSERT 실패, UPDATE 시도:`, insertError);
return await executeUpdate(tableName, data);
}
} catch (error) {
logger.error(`UPSERT 실행 오류:`, error);
throw error;
}
}
/**
* DELETE 실행
*/
async function executeDelete(tableName: string, data: Record<string, any>): Promise<any> {
try {
const { id } = data;
if (!id) {
throw new Error('DELETE를 위한 ID가 필요합니다');
}
const query = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
logger.info(`DELETE 쿼리 실행:`, { query, values: [id] });
const result = await prisma.$queryRawUnsafe(query, id);
return {
success: true,
action: 'delete',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`DELETE 실행 오류:`, error);
throw error;
}
}

View File

@@ -0,0 +1,19 @@
/**
* 🔥 데이터플로우 실행 라우트
*
* 버튼 제어에서 관계 실행 시 사용되는 API 엔드포인트
*/
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { executeDataAction } from "../controllers/dataflowExecutionController";
const router = express.Router();
// 🔥 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 데이터 액션 실행
router.post("/execute-data-action", executeDataAction);
export default router;

View File

@@ -249,4 +249,80 @@ router.post("/:id/test", async (req: Request, res: Response) => {
}
});
/**
* 🔥 개선된 외부호출 실행 (데이터 매핑 통합)
* POST /api/external-call-configs/:id/execute
*/
router.post("/:id/execute", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
const { requestData, contextData } = req.body;
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
const companyCode = userInfo?.companyCode || "*";
const executionResult = await externalCallConfigService.executeConfigWithDataMapping(
id,
requestData || {},
{
...contextData,
userId,
companyCode,
executedAt: new Date().toISOString(),
}
);
return res.json({
success: executionResult.success,
message: executionResult.message,
data: executionResult.data,
executionTime: executionResult.executionTime,
error: executionResult.error,
});
} catch (error) {
logger.error("외부호출 실행 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 실행 실패",
errorCode: "EXTERNAL_CALL_EXECUTE_ERROR",
});
}
});
/**
* 🔥 버튼 제어용 외부호출 목록 조회 (간소화된 정보)
* GET /api/external-call-configs/for-button-control
*/
router.get("/for-button-control", async (req: Request, res: Response) => {
try {
const userInfo = (req as any).user;
const companyCode = userInfo?.companyCode || "*";
const configs = await externalCallConfigService.getConfigsForButtonControl(companyCode);
return res.json({
success: true,
data: configs,
message: `버튼 제어용 외부호출 설정 ${configs.length}개 조회 완료`,
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 설정 조회 실패",
errorCode: "EXTERNAL_CALL_BUTTON_CONTROL_LIST_ERROR",
});
}
});
export default router;

View File

@@ -14,6 +14,7 @@ import {
executeOptimizedButton,
executeSimpleDataflow,
getJobStatus,
getAllRelationships,
} from "../controllers/buttonDataflowController";
import { AuthenticatedRequest } from "../types/auth";
import config from "../config/environment";
@@ -52,6 +53,9 @@ if (config.nodeEnv !== "production") {
// 특정 관계도의 관계 목록 조회
router.get("/diagrams/:diagramId/relationships", getDiagramRelationships);
// 🔥 전체 관계 목록 조회 (버튼 제어용)
router.get("/relationships/all", getAllRelationships);
// 관계 미리보기 정보 조회
router.get(
"/diagrams/:diagramId/relationships/:relationshipId/preview",

View File

@@ -0,0 +1,575 @@
import { PrismaClient } from "@prisma/client";
import {
DataMappingConfig,
InboundMapping,
OutboundMapping,
FieldMapping,
DataMappingResult,
MappingValidationResult,
FieldTransform,
DataType,
} from "../types/dataMappingTypes";
export class DataMappingService {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
/**
* Inbound 데이터 매핑 처리 (외부 → 내부)
*/
async processInboundData(
externalData: any,
mapping: InboundMapping
): Promise<DataMappingResult> {
const startTime = Date.now();
const result: DataMappingResult = {
success: false,
direction: "inbound",
recordsProcessed: 0,
recordsInserted: 0,
recordsUpdated: 0,
recordsSkipped: 0,
errors: [],
executionTime: 0,
timestamp: new Date().toISOString(),
};
try {
console.log(`📥 [DataMappingService] Inbound 매핑 시작:`, {
targetTable: mapping.targetTable,
insertMode: mapping.insertMode,
fieldMappings: mapping.fieldMappings.length,
});
// 데이터 배열로 변환
const dataArray = Array.isArray(externalData)
? externalData
: [externalData];
result.recordsProcessed = dataArray.length;
// 각 레코드 처리
for (const record of dataArray) {
try {
const mappedData = await this.mapInboundRecord(record, mapping);
if (Object.keys(mappedData).length === 0) {
result.recordsSkipped!++;
continue;
}
// 데이터베이스에 저장
await this.saveInboundRecord(mappedData, mapping);
if (mapping.insertMode === "insert") {
result.recordsInserted!++;
} else {
result.recordsUpdated!++;
}
} catch (error) {
console.error(`❌ [DataMappingService] 레코드 처리 실패:`, error);
result.errors!.push(
`레코드 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
result.recordsSkipped!++;
}
}
result.success =
result.errors!.length === 0 ||
result.recordsInserted! > 0 ||
result.recordsUpdated! > 0;
} catch (error) {
console.error(`❌ [DataMappingService] Inbound 매핑 실패:`, error);
result.errors!.push(
`매핑 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
}
result.executionTime = Date.now() - startTime;
console.log(`✅ [DataMappingService] Inbound 매핑 완료:`, result);
return result;
}
/**
* Outbound 데이터 매핑 처리 (내부 → 외부)
*/
async processOutboundData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
console.log(`📤 [DataMappingService] Outbound 매핑 시작:`, {
sourceTable: mapping.sourceTable,
fieldMappings: mapping.fieldMappings.length,
filter,
});
try {
// 소스 데이터 조회
const sourceData = await this.getSourceData(mapping, filter);
if (
!sourceData ||
(Array.isArray(sourceData) && sourceData.length === 0)
) {
console.log(`⚠️ [DataMappingService] 소스 데이터가 없습니다.`);
return null;
}
// 데이터 매핑
const mappedData = Array.isArray(sourceData)
? await Promise.all(
sourceData.map((record) => this.mapOutboundRecord(record, mapping))
)
: await this.mapOutboundRecord(sourceData, mapping);
console.log(`✅ [DataMappingService] Outbound 매핑 완료:`, {
recordCount: Array.isArray(mappedData) ? mappedData.length : 1,
});
return mappedData;
} catch (error) {
console.error(`❌ [DataMappingService] Outbound 매핑 실패:`, error);
throw error;
}
}
/**
* 단일 Inbound 레코드 매핑
*/
private async mapInboundRecord(
sourceRecord: any,
mapping: InboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 필수 필드 체크
if (
fieldMapping.required &&
(sourceValue === undefined || sourceValue === null)
) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
} else {
throw new Error(
`필수 필드 '${fieldMapping.sourceField}'가 누락되었습니다.`
);
}
continue;
}
// 값이 없으면 기본값 사용
if (sourceValue === undefined || sourceValue === null) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
}
continue;
}
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
* 단일 Outbound 레코드 매핑
*/
private async mapOutboundRecord(
sourceRecord: any,
mapping: OutboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
* 필드 값 변환
*/
private async transformFieldValue(
value: any,
targetDataType: DataType,
transform?: FieldTransform
): Promise<any> {
let transformedValue = value;
// 1. 변환 함수 적용
if (transform) {
switch (transform.type) {
case "constant":
transformedValue = transform.value;
break;
case "format":
if (targetDataType === "date" && transform.format) {
transformedValue = this.formatDate(value, transform.format);
}
break;
case "function":
if (transform.functionName) {
transformedValue = await this.applyCustomFunction(
value,
transform.functionName
);
}
break;
}
}
// 2. 데이터 타입 변환
return this.convertDataType(transformedValue, targetDataType);
}
/**
* 데이터 타입 변환
*/
private convertDataType(value: any, targetType: DataType): any {
if (value === null || value === undefined) return value;
switch (targetType) {
case "string":
return String(value);
case "number":
const num = Number(value);
return isNaN(num) ? null : num;
case "boolean":
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return (
value.toLowerCase() === "true" || value === "1" || value === "Y"
);
}
return Boolean(value);
case "date":
return new Date(value);
case "json":
return typeof value === "string" ? JSON.parse(value) : value;
default:
return value;
}
}
/**
* 날짜 포맷 변환
*/
private formatDate(value: any, format: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// 간단한 날짜 포맷 변환
switch (format) {
case "YYYY-MM-DD":
return date.toISOString().split("T")[0];
case "YYYY-MM-DD HH:mm:ss":
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
default:
return date.toISOString();
}
}
/**
* 커스텀 함수 적용
*/
private async applyCustomFunction(
value: any,
functionName: string
): Promise<any> {
// 추후 확장 가능한 커스텀 함수들
switch (functionName) {
case "upperCase":
return String(value).toUpperCase();
case "lowerCase":
return String(value).toLowerCase();
case "trim":
return String(value).trim();
default:
console.warn(
`⚠️ [DataMappingService] 알 수 없는 함수: ${functionName}`
);
return value;
}
}
/**
* Inbound 데이터 저장
*/
private async saveInboundRecord(
mappedData: Record<string, any>,
mapping: InboundMapping
): Promise<void> {
const tableName = mapping.targetTable;
try {
switch (mapping.insertMode) {
case "insert":
await this.executeInsert(tableName, mappedData);
break;
case "upsert":
await this.executeUpsert(
tableName,
mappedData,
mapping.keyFields || []
);
break;
case "update":
await this.executeUpdate(
tableName,
mappedData,
mapping.keyFields || []
);
break;
}
} catch (error) {
console.error(
`❌ [DataMappingService] 데이터 저장 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
* 소스 데이터 조회
*/
private async getSourceData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
const tableName = mapping.sourceTable;
try {
// 동적 테이블 쿼리 (Prisma의 경우 런타임에서 제한적)
// 실제 구현에서는 각 테이블별 모델을 사용하거나 Raw SQL을 사용해야 함
let whereClause = {};
if (mapping.sourceFilter) {
// 간단한 필터 파싱 (실제로는 더 정교한 파싱 필요)
console.log(
`🔍 [DataMappingService] 필터 조건: ${mapping.sourceFilter}`
);
// TODO: 필터 조건 파싱 및 적용
}
if (filter) {
whereClause = { ...whereClause, ...filter };
}
// Raw SQL을 사용한 동적 쿼리
const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`);
const result = await this.prisma.$queryRawUnsafe(query);
return result;
} catch (error) {
console.error(
`❌ [DataMappingService] 소스 데이터 조회 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
* INSERT 실행
*/
private async executeInsert(
tableName: string,
data: Record<string, any>
): Promise<void> {
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
console.log(`📝 [DataMappingService] INSERT 실행:`, {
table: tableName,
columns,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPSERT 실행
*/
private async executeUpsert(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPSERT 모드에서는 키 필드가 필요합니다.");
}
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const updateClauses = columns
.filter((col) => !keyFields.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
const query = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${keyFields.join(", ")})
DO UPDATE SET ${updateClauses}
`;
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPDATE 실행
*/
private async executeUpdate(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPDATE 모드에서는 키 필드가 필요합니다.");
}
const updateColumns = Object.keys(data).filter(
(col) => !keyFields.includes(col)
);
const updateClauses = updateColumns
.map((col, i) => `${col} = $${i + 1}`)
.join(", ");
const whereConditions = keyFields
.map((field, i) => `${field} = $${updateColumns.length + i + 1}`)
.join(" AND ");
const values = [
...updateColumns.map((col) => data[col]),
...keyFields.map((field) => data[field]),
];
const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* 매핑 설정 검증
*/
validateMappingConfig(config: DataMappingConfig): MappingValidationResult {
const result: MappingValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
if (config.direction === "none") {
return result;
}
// Inbound 매핑 검증
if (
(config.direction === "inbound" ||
config.direction === "bidirectional") &&
config.inboundMapping
) {
if (!config.inboundMapping.targetTable) {
result.errors.push("Inbound 매핑에 대상 테이블이 필요합니다.");
}
if (config.inboundMapping.fieldMappings.length === 0) {
result.errors.push("Inbound 매핑에 필드 매핑이 필요합니다.");
}
if (
config.inboundMapping.insertMode !== "insert" &&
(!config.inboundMapping.keyFields ||
config.inboundMapping.keyFields.length === 0)
) {
result.errors.push("UPSERT/UPDATE 모드에서는 키 필드가 필요합니다.");
}
}
// Outbound 매핑 검증
if (
(config.direction === "outbound" ||
config.direction === "bidirectional") &&
config.outboundMapping
) {
if (!config.outboundMapping.sourceTable) {
result.errors.push("Outbound 매핑에 소스 테이블이 필요합니다.");
}
if (config.outboundMapping.fieldMappings.length === 0) {
result.errors.push("Outbound 매핑에 필드 매핑이 필요합니다.");
}
}
result.isValid = result.errors.length === 0;
return result;
}
/**
* 리소스 정리
*/
async disconnect(): Promise<void> {
await this.prisma.$disconnect();
}
}

View File

@@ -384,3 +384,66 @@ export const copyDataflowDiagram = async (
throw error;
}
};
/**
* 🔥 전체 관계 목록 조회 (버튼 제어용)
* dataflow_diagrams 테이블에서 관계도 데이터를 조회 (데이터 흐름 관계 화면과 동일)
*/
export const getAllRelationshipsForButtonControl = async (
companyCode: string
): Promise<Array<{
id: string;
name: string;
sourceTable: string;
targetTable: string;
category: string;
}>> => {
try {
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
// dataflow_diagrams 테이블에서 관계도들을 조회
const diagrams = await prisma.dataflow_diagrams.findMany({
where: {
company_code: companyCode,
},
select: {
diagram_id: true,
diagram_name: true,
relationships: true,
},
orderBy: {
updated_at: "desc",
},
});
const allRelationships = diagrams.map((diagram) => {
// relationships 구조에서 테이블 정보 추출
const relationships = diagram.relationships as any || {};
// 테이블 정보 추출
let sourceTable = "";
let targetTable = "";
if (relationships.fromTable?.tableName) {
sourceTable = relationships.fromTable.tableName;
}
if (relationships.toTable?.tableName) {
targetTable = relationships.toTable.tableName;
}
return {
id: diagram.diagram_id.toString(),
name: diagram.diagram_name || `관계 ${diagram.diagram_id}`,
sourceTable: sourceTable,
targetTable: targetTable,
category: "데이터 흐름",
};
});
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
return allRelationships;
} catch (error) {
logger.error("전체 관계 목록 조회 서비스 오류:", error);
throw error;
}
};

View File

@@ -308,6 +308,265 @@ export class ExternalCallConfigService {
};
}
}
/**
* 🔥 데이터 매핑과 함께 외부호출 실행
*/
async executeConfigWithDataMapping(
configId: number,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{
success: boolean;
message: string;
data?: any;
executionTime: number;
error?: string;
}> {
const startTime = performance.now();
try {
logger.info(`=== 외부호출 실행 시작 (ID: ${configId}) ===`);
// 1. 설정 조회
const config = await this.getConfigById(configId);
if (!config) {
throw new Error(`외부호출 설정을 찾을 수 없습니다: ${configId}`);
}
// 2. 데이터 매핑 처리 (있는 경우)
let processedData = requestData;
const configData = config.config_data as any;
if (configData?.dataMappingConfig?.outboundMapping) {
logger.info("Outbound 데이터 매핑 처리 중...");
processedData = await this.processOutboundMapping(
configData.dataMappingConfig.outboundMapping,
requestData
);
}
// 3. 외부 API 호출
const callResult = await this.executeExternalCall(config, processedData, contextData);
// 4. Inbound 데이터 매핑 처리 (있는 경우)
if (
callResult.success &&
configData?.dataMappingConfig?.inboundMapping
) {
logger.info("Inbound 데이터 매핑 처리 중...");
await this.processInboundMapping(
configData.dataMappingConfig.inboundMapping,
callResult.data
);
}
const executionTime = performance.now() - startTime;
logger.info(`외부호출 실행 완료: ${executionTime.toFixed(2)}ms`);
return {
success: callResult.success,
message: callResult.success
? `외부호출 '${config.config_name}' 실행 완료`
: `외부호출 '${config.config_name}' 실행 실패`,
data: callResult.data,
executionTime,
error: callResult.error,
};
} catch (error) {
const executionTime = performance.now() - startTime;
logger.error("외부호출 실행 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
message: `외부호출 실행 실패: ${errorMessage}`,
executionTime,
error: errorMessage,
};
}
}
/**
* 🔥 버튼 제어용 외부호출 설정 목록 조회 (간소화된 정보)
*/
async getConfigsForButtonControl(companyCode: string): Promise<Array<{
id: string;
name: string;
description?: string;
apiUrl: string;
method: string;
hasDataMapping: boolean;
}>> {
try {
const configs = await prisma.external_call_configs.findMany({
where: {
company_code: companyCode,
is_active: "Y",
},
select: {
id: true,
config_name: true,
description: true,
config_data: true,
},
orderBy: {
config_name: "asc",
},
});
return configs.map((config) => {
const configData = config.config_data as any;
return {
id: config.id.toString(),
name: config.config_name,
description: config.description || undefined,
apiUrl: configData?.restApiSettings?.apiUrl || "",
method: configData?.restApiSettings?.httpMethod || "GET",
hasDataMapping: !!(configData?.dataMappingConfig),
};
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 실패:", error);
throw error;
}
}
/**
* 🔥 실제 외부 API 호출 실행
*/
private async executeExternalCall(
config: ExternalCallConfig,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const configData = config.config_data as any;
const restApiSettings = configData?.restApiSettings;
if (!restApiSettings) {
throw new Error("REST API 설정이 없습니다.");
}
const { apiUrl, httpMethod, headers = {}, timeout = 30000 } = restApiSettings;
// 요청 헤더 준비
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};
// 인증 처리
if (restApiSettings.authentication?.type === "basic") {
const { username, password } = restApiSettings.authentication;
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
requestHeaders["Authorization"] = `Basic ${credentials}`;
} else if (restApiSettings.authentication?.type === "bearer") {
const { token } = restApiSettings.authentication;
requestHeaders["Authorization"] = `Bearer ${token}`;
}
// 요청 본문 준비
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(httpMethod.toUpperCase())) {
requestBody = JSON.stringify({
...requestData,
_context: contextData, // 컨텍스트 정보 추가
});
}
logger.info(`외부 API 호출: ${httpMethod} ${apiUrl}`);
// 실제 HTTP 요청 (여기서는 간단한 예시)
// 실제 구현에서는 axios나 fetch를 사용
const response = await fetch(apiUrl, {
method: httpMethod,
headers: requestHeaders,
body: requestBody,
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = await response.json();
return {
success: true,
data: responseData,
};
} catch (error) {
logger.error("외부 API 호출 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
error: errorMessage,
};
}
}
/**
* 🔥 Outbound 데이터 매핑 처리
*/
private async processOutboundMapping(
mapping: any,
sourceData: Record<string, any>
): Promise<Record<string, any>> {
try {
// 간단한 매핑 로직 (실제로는 더 복잡한 변환 로직 필요)
const mappedData: Record<string, any> = {};
if (mapping.fieldMappings) {
for (const fieldMapping of mapping.fieldMappings) {
const { sourceField, targetField, transformation } = fieldMapping;
let value = sourceData[sourceField];
// 변환 로직 적용
if (transformation) {
switch (transformation.type) {
case "format":
// 포맷 변환 로직
break;
case "calculate":
// 계산 로직
break;
default:
// 기본값 그대로 사용
break;
}
}
mappedData[targetField] = value;
}
}
return mappedData;
} catch (error) {
logger.error("Outbound 데이터 매핑 처리 실패:", error);
return sourceData; // 실패 시 원본 데이터 반환
}
}
/**
* 🔥 Inbound 데이터 매핑 처리
*/
private async processInboundMapping(
mapping: any,
responseData: any
): Promise<void> {
try {
// Inbound 매핑 로직 (응답 데이터를 내부 시스템에 저장)
logger.info("Inbound 데이터 매핑 처리:", mapping);
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
} catch (error) {
logger.error("Inbound 데이터 매핑 처리 실패:", error);
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음
}
}
}
export default new ExternalCallConfigService();

View File

@@ -10,6 +10,11 @@ import {
SupportedExternalCallSettings,
TemplateOptions,
} from "../types/externalCallTypes";
import { DataMappingService } from "./dataMappingService";
import {
DataMappingConfig,
DataMappingResult,
} from "../types/dataMappingTypes";
/**
* 외부 호출 서비스
@@ -18,10 +23,149 @@ import {
export class ExternalCallService {
private readonly DEFAULT_TIMEOUT = 30000; // 30초
private readonly DEFAULT_RETRY_COUNT = 3;
private dataMappingService: DataMappingService;
private readonly DEFAULT_RETRY_DELAY = 1000; // 1초
constructor() {
this.dataMappingService = new DataMappingService();
}
/**
* 외부 호출 실행
* 데이터 매핑과 함께 외부 호출 실행
*/
async executeWithDataMapping(
config: ExternalCallConfig,
dataMappingConfig?: DataMappingConfig,
triggerData?: any
): Promise<{
callResult: ExternalCallResult;
mappingResult?: DataMappingResult;
}> {
const startTime = Date.now();
console.log(`🚀 [ExternalCallService] 데이터 매핑 포함 외부 호출 시작:`, {
callType: config.callType,
hasMappingConfig: !!dataMappingConfig,
mappingDirection: dataMappingConfig?.direction,
});
try {
let requestData = config;
// Outbound 매핑 처리 (내부 → 외부)
if (
dataMappingConfig?.direction === "outbound" &&
dataMappingConfig.outboundMapping
) {
console.log(`📤 [ExternalCallService] Outbound 매핑 처리 시작`);
const outboundData = await this.dataMappingService.processOutboundData(
dataMappingConfig.outboundMapping,
triggerData
);
// API 요청 바디에 매핑된 데이터 포함
if (config.callType === "rest-api") {
// GenericApiSettings로 타입 캐스팅
const apiConfig = config as GenericApiSettings;
const bodyTemplate = apiConfig.body || "{}";
// 템플릿에 데이터 삽입
const processedBody = this.processTemplate(bodyTemplate, {
mappedData: outboundData,
triggerData,
...outboundData,
});
requestData = {
...config,
body: processedBody,
} as GenericApiSettings;
}
}
// 외부 호출 실행
const callRequest: ExternalCallRequest = {
diagramId: 0, // 임시값
relationshipId: "data-mapping", // 임시값
settings: requestData,
templateData: triggerData,
};
const callResult = await this.executeExternalCall(callRequest);
let mappingResult: DataMappingResult | undefined;
// Inbound 매핑 처리 (외부 → 내부)
if (
callResult.success &&
dataMappingConfig?.direction === "inbound" &&
dataMappingConfig.inboundMapping
) {
console.log(`📥 [ExternalCallService] Inbound 매핑 처리 시작`);
try {
// 응답 데이터 파싱
let responseData = callResult.response;
if (typeof responseData === "string") {
try {
responseData = JSON.parse(responseData);
} catch {
console.warn(
`⚠️ [ExternalCallService] 응답 데이터 JSON 파싱 실패, 문자열로 처리`
);
}
}
mappingResult = await this.dataMappingService.processInboundData(
responseData,
dataMappingConfig.inboundMapping
);
console.log(`✅ [ExternalCallService] Inbound 매핑 완료:`, {
recordsProcessed: mappingResult.recordsProcessed,
recordsInserted: mappingResult.recordsInserted,
});
} catch (error) {
console.error(`❌ [ExternalCallService] Inbound 매핑 실패:`, error);
mappingResult = {
success: false,
direction: "inbound",
errors: [error instanceof Error ? error.message : String(error)],
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
};
}
}
// 양방향 매핑 처리
if (dataMappingConfig?.direction === "bidirectional") {
// 필요한 경우 양방향 매핑 로직 구현
console.log(`🔄 [ExternalCallService] 양방향 매핑은 향후 구현 예정`);
}
const result = {
callResult,
mappingResult,
};
console.log(`✅ [ExternalCallService] 데이터 매핑 포함 외부 호출 완료:`, {
callSuccess: callResult.success,
mappingSuccess: mappingResult?.success,
totalExecutionTime: Date.now() - startTime,
});
return result;
} catch (error) {
console.error(
`❌ [ExternalCallService] 데이터 매핑 포함 외부 호출 실패:`,
error
);
throw error;
}
}
/**
* 기존 외부 호출 실행 (매핑 없음)
*/
async executeExternalCall(
request: ExternalCallRequest

View File

@@ -0,0 +1,82 @@
/**
* 백엔드 데이터 매핑 관련 타입 정의
*/
export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional";
export type InsertMode = "insert" | "upsert" | "update";
export type TransformType = "none" | "constant" | "format" | "function";
export type DataType = "string" | "number" | "boolean" | "date" | "json";
export interface FieldTransform {
type: TransformType;
value?: any;
format?: string;
functionName?: string;
}
export interface FieldMapping {
id: string;
sourceField: string;
targetField: string;
dataType: DataType;
transform?: FieldTransform;
required?: boolean;
defaultValue?: any;
}
export interface InboundMapping {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: InsertMode;
keyFields?: string[];
batchSize?: number;
}
export interface OutboundMapping {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string;
fieldMappings: FieldMapping[];
triggerCondition?: string;
}
export interface DataMappingConfig {
direction: DataDirection;
inboundMapping?: InboundMapping;
outboundMapping?: OutboundMapping;
}
export interface TableInfo {
name: string;
schema?: string;
displayName?: string;
fields: FieldInfo[];
}
export interface FieldInfo {
name: string;
dataType: DataType;
nullable: boolean;
isPrimaryKey?: boolean;
displayName?: string;
description?: string;
}
export interface DataMappingResult {
success: boolean;
direction: DataDirection;
recordsProcessed?: number;
recordsInserted?: number;
recordsUpdated?: number;
recordsSkipped?: number;
errors?: string[];
executionTime: number;
timestamp: string;
}
export interface MappingValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}