데이터 매핑 설정 중간커밋
This commit is contained in:
parent
bf7fc6cfb8
commit
e0777d0fc3
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react";
|
||||
|
||||
import {
|
||||
DataMappingConfig,
|
||||
DataDirection,
|
||||
TableInfo,
|
||||
FieldMapping,
|
||||
InboundMapping,
|
||||
OutboundMapping,
|
||||
DATA_DIRECTION_OPTIONS,
|
||||
INSERT_MODE_OPTIONS,
|
||||
} from "@/types/external-call/DataMappingTypes";
|
||||
|
||||
import { FieldMappingEditor } from "./FieldMappingEditor";
|
||||
|
||||
interface DataMappingSettingsProps {
|
||||
config: DataMappingConfig;
|
||||
onConfigChange: (config: DataMappingConfig) => void;
|
||||
httpMethod: string;
|
||||
availableTables?: TableInfo[];
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const DataMappingSettings: React.FC<DataMappingSettingsProps> = ({
|
||||
config,
|
||||
onConfigChange,
|
||||
httpMethod,
|
||||
availableTables = [],
|
||||
readonly = false,
|
||||
}) => {
|
||||
const [localConfig, setLocalConfig] = useState<DataMappingConfig>(config);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
// HTTP 메서드에 따른 권장 방향 결정
|
||||
const getRecommendedDirection = useCallback((method: string): DataDirection => {
|
||||
const upperMethod = method.toUpperCase();
|
||||
if (upperMethod === "GET") return "inbound";
|
||||
if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound";
|
||||
return "none";
|
||||
}, []);
|
||||
|
||||
// 방향 변경 핸들러
|
||||
const handleDirectionChange = useCallback(
|
||||
(direction: DataDirection) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
direction,
|
||||
// 방향에 따라 불필요한 매핑 제거
|
||||
inboundMapping:
|
||||
direction === "inbound" || direction === "bidirectional"
|
||||
? localConfig.inboundMapping || {
|
||||
targetTable: "",
|
||||
fieldMappings: [],
|
||||
insertMode: "insert" as const,
|
||||
}
|
||||
: undefined,
|
||||
outboundMapping:
|
||||
direction === "outbound" || direction === "bidirectional"
|
||||
? localConfig.outboundMapping || {
|
||||
sourceTable: "",
|
||||
fieldMappings: [],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[localConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// Inbound 매핑 업데이트
|
||||
const handleInboundMappingChange = useCallback(
|
||||
(mapping: Partial<InboundMapping>) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
inboundMapping: {
|
||||
...localConfig.inboundMapping!,
|
||||
...mapping,
|
||||
},
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[localConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// Outbound 매핑 업데이트
|
||||
const handleOutboundMappingChange = useCallback(
|
||||
(mapping: Partial<OutboundMapping>) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
outboundMapping: {
|
||||
...localConfig.outboundMapping!,
|
||||
...mapping,
|
||||
},
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[localConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 필드 매핑 업데이트 (Inbound)
|
||||
const handleInboundFieldMappingsChange = useCallback(
|
||||
(fieldMappings: FieldMapping[]) => {
|
||||
handleInboundMappingChange({ fieldMappings });
|
||||
},
|
||||
[handleInboundMappingChange],
|
||||
);
|
||||
|
||||
// 필드 매핑 업데이트 (Outbound)
|
||||
const handleOutboundFieldMappingsChange = useCallback(
|
||||
(fieldMappings: FieldMapping[]) => {
|
||||
handleOutboundMappingChange({ fieldMappings });
|
||||
},
|
||||
[handleOutboundMappingChange],
|
||||
);
|
||||
|
||||
// 검증 함수
|
||||
const isConfigValid = useCallback(() => {
|
||||
if (localConfig.direction === "none") return true;
|
||||
|
||||
if (
|
||||
(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") &&
|
||||
localConfig.inboundMapping
|
||||
) {
|
||||
if (!localConfig.inboundMapping.targetTable) return false;
|
||||
if (localConfig.inboundMapping.fieldMappings.length === 0) return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") &&
|
||||
localConfig.outboundMapping
|
||||
) {
|
||||
if (!localConfig.outboundMapping.sourceTable) return false;
|
||||
if (localConfig.outboundMapping.fieldMappings.length === 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [localConfig]);
|
||||
|
||||
const recommendedDirection = getRecommendedDirection(httpMethod);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
데이터 매핑 설정
|
||||
{!isConfigValid() && <Badge variant="destructive">설정 필요</Badge>}
|
||||
{isConfigValid() && localConfig.direction !== "none" && <Badge variant="default">설정 완료</Badge>}
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">외부 API와 내부 테이블 간의 데이터 매핑을 설정합니다.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 매핑 방향 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매핑 방향</Label>
|
||||
<Select value={localConfig.direction} onValueChange={handleDirectionChange} disabled={readonly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="매핑 방향을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_DIRECTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.label}
|
||||
{option.value === recommendedDirection && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
권장
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && (
|
||||
<p className="text-xs text-amber-600">
|
||||
💡 {httpMethod} 요청에는 "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}"
|
||||
방향이 권장됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 설정 탭 */}
|
||||
{localConfig.direction !== "none" && (
|
||||
<Tabs
|
||||
defaultValue={localConfig.direction === "bidirectional" ? "inbound" : localConfig.direction}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
|
||||
<TabsTrigger value="inbound">
|
||||
<ArrowRight className="mr-1 h-4 w-4" />
|
||||
외부 → 내부
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
|
||||
<TabsTrigger value="outbound">
|
||||
<ArrowRight className="mr-1 h-4 w-4 rotate-180" />
|
||||
내부 → 외부
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Inbound 매핑 설정 */}
|
||||
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
|
||||
<TabsContent value="inbound" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상 테이블</Label>
|
||||
<Select
|
||||
value={localConfig.inboundMapping?.targetTable || ""}
|
||||
onValueChange={(value) => handleInboundMappingChange({ targetTable: value })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="저장할 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.displayName || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>저장 모드</Label>
|
||||
<Select
|
||||
value={localConfig.inboundMapping?.insertMode || "insert"}
|
||||
onValueChange={(value) => handleInboundMappingChange({ insertMode: value as any })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSERT_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 키 필드 설정 (upsert/update 모드일 때) */}
|
||||
{localConfig.inboundMapping?.insertMode !== "insert" && (
|
||||
<div className="space-y-2">
|
||||
<Label>키 필드</Label>
|
||||
<Input
|
||||
value={localConfig.inboundMapping?.keyFields?.join(", ") || ""}
|
||||
onChange={(e) =>
|
||||
handleInboundMappingChange({
|
||||
keyFields: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="id, code"
|
||||
disabled={readonly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
업데이트/업서트 시 사용할 키 필드를 쉼표로 구분하여 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 에디터 */}
|
||||
{localConfig.inboundMapping?.targetTable && (
|
||||
<FieldMappingEditor
|
||||
mappings={localConfig.inboundMapping.fieldMappings}
|
||||
onMappingsChange={handleInboundFieldMappingsChange}
|
||||
direction="inbound"
|
||||
targetTable={availableTables.find((t) => t.name === localConfig.inboundMapping?.targetTable)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Outbound 매핑 설정 */}
|
||||
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
|
||||
<TabsContent value="outbound" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>소스 테이블</Label>
|
||||
<Select
|
||||
value={localConfig.outboundMapping?.sourceTable || ""}
|
||||
onValueChange={(value) => handleOutboundMappingChange({ sourceTable: value })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="데이터를 가져올 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.displayName || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 소스 필터 조건 */}
|
||||
<div className="space-y-2">
|
||||
<Label>필터 조건 (선택사항)</Label>
|
||||
<Textarea
|
||||
value={localConfig.outboundMapping?.sourceFilter || ""}
|
||||
onChange={(e) => handleOutboundMappingChange({ sourceFilter: e.target.value })}
|
||||
placeholder="status = 'active' AND created_at >= '2024-01-01'"
|
||||
disabled={readonly}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
WHERE 절에 사용할 조건을 입력하세요. 비어있으면 모든 데이터를 가져옵니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 에디터 */}
|
||||
{localConfig.outboundMapping?.sourceTable && (
|
||||
<FieldMappingEditor
|
||||
mappings={localConfig.outboundMapping.fieldMappings}
|
||||
onMappingsChange={handleOutboundFieldMappingsChange}
|
||||
direction="outbound"
|
||||
sourceTable={availableTables.find((t) => t.name === localConfig.outboundMapping?.sourceTable)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* 설정 요약 */}
|
||||
{localConfig.direction !== "none" && (
|
||||
<div className="bg-muted mt-4 rounded-lg p-3">
|
||||
<h4 className="mb-2 text-sm font-medium">설정 요약</h4>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>방향: {DATA_DIRECTION_OPTIONS.find((o) => o.value === localConfig.direction)?.label}</div>
|
||||
{localConfig.inboundMapping && (
|
||||
<div>
|
||||
외부 → {localConfig.inboundMapping.targetTable}({localConfig.inboundMapping.fieldMappings.length}개
|
||||
필드)
|
||||
</div>
|
||||
)}
|
||||
{localConfig.outboundMapping && (
|
||||
<div>
|
||||
{localConfig.outboundMapping.sourceTable} → 외부 ({localConfig.outboundMapping.fieldMappings.length}개
|
||||
필드)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,10 +15,12 @@ import {
|
|||
RestApiSettings as RestApiSettingsType,
|
||||
ApiTestResult,
|
||||
} from "@/types/external-call/ExternalCallTypes";
|
||||
import { DataMappingConfig, TableInfo } from "@/types/external-call/DataMappingTypes";
|
||||
|
||||
// 하위 컴포넌트 import
|
||||
import RestApiSettings from "./RestApiSettings";
|
||||
import ExternalCallTestPanel from "./ExternalCallTestPanel";
|
||||
import { DataMappingSettings } from "./DataMappingSettings";
|
||||
|
||||
/**
|
||||
* 🌐 외부호출 메인 패널 컴포넌트
|
||||
|
|
@ -68,6 +70,50 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
|||
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
|
||||
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
|
||||
|
||||
// 데이터 매핑 상태
|
||||
const [dataMappingConfig, setDataMappingConfig] = useState<DataMappingConfig>(() => ({
|
||||
direction: "none",
|
||||
}));
|
||||
|
||||
// 사용 가능한 테이블 목록 (임시 데이터)
|
||||
const [availableTables] = useState<TableInfo[]>([
|
||||
{
|
||||
name: "customers",
|
||||
displayName: "고객",
|
||||
fields: [
|
||||
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
|
||||
{ name: "name", dataType: "string", nullable: false },
|
||||
{ name: "email", dataType: "string", nullable: true },
|
||||
{ name: "phone", dataType: "string", nullable: true },
|
||||
{ name: "created_at", dataType: "date", nullable: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orders",
|
||||
displayName: "주문",
|
||||
fields: [
|
||||
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
|
||||
{ name: "customer_id", dataType: "number", nullable: false },
|
||||
{ name: "product_name", dataType: "string", nullable: false },
|
||||
{ name: "quantity", dataType: "number", nullable: false },
|
||||
{ name: "price", dataType: "number", nullable: false },
|
||||
{ name: "status", dataType: "string", nullable: false },
|
||||
{ name: "order_date", dataType: "date", nullable: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "products",
|
||||
displayName: "제품",
|
||||
fields: [
|
||||
{ name: "id", dataType: "number", nullable: false, isPrimaryKey: true },
|
||||
{ name: "name", dataType: "string", nullable: false },
|
||||
{ name: "price", dataType: "number", nullable: false },
|
||||
{ name: "stock", dataType: "number", nullable: false },
|
||||
{ name: "category", dataType: "string", nullable: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 설정 변경 핸들러
|
||||
const handleRestApiSettingsChange = useCallback(
|
||||
(newSettings: RestApiSettingsType) => {
|
||||
|
|
@ -82,9 +128,12 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
|||
};
|
||||
|
||||
setConfig(updatedConfig);
|
||||
onSettingsChange(updatedConfig);
|
||||
onSettingsChange({
|
||||
...updatedConfig,
|
||||
dataMappingConfig,
|
||||
});
|
||||
},
|
||||
[config, onSettingsChange],
|
||||
[config, onSettingsChange, dataMappingConfig],
|
||||
);
|
||||
|
||||
// 테스트 결과 핸들러
|
||||
|
|
@ -141,10 +190,13 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
|||
|
||||
{/* 메인 탭 컨텐츠 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
API 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mapping" className="flex items-center gap-2">
|
||||
🔄 데이터 매핑
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex items-center gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
|
|
@ -160,7 +212,7 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
{/* API 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
|
||||
<RestApiSettings
|
||||
settings={config.restApiSettings}
|
||||
|
|
@ -169,6 +221,17 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 매핑 탭 */}
|
||||
<TabsContent value="mapping" className="flex-1 space-y-2 overflow-y-auto">
|
||||
<DataMappingSettings
|
||||
config={dataMappingConfig}
|
||||
onConfigChange={setDataMappingConfig}
|
||||
httpMethod={config.restApiSettings?.httpMethod || "GET"}
|
||||
availableTables={availableTables}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 테스트 탭 */}
|
||||
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
|
||||
{isConfigValid ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, ArrowRight, Settings, Eye, EyeOff, RefreshCw, Database, Globe } from "lucide-react";
|
||||
|
||||
import {
|
||||
FieldMapping,
|
||||
TableInfo,
|
||||
FieldInfo,
|
||||
DataDirection,
|
||||
DATA_TYPE_OPTIONS,
|
||||
TRANSFORM_TYPE_OPTIONS,
|
||||
} from "@/types/external-call/DataMappingTypes";
|
||||
|
||||
interface FieldMappingEditorProps {
|
||||
mappings: FieldMapping[];
|
||||
onMappingsChange: (mappings: FieldMapping[]) => void;
|
||||
direction: "inbound" | "outbound";
|
||||
sourceTable?: TableInfo; // outbound용
|
||||
targetTable?: TableInfo; // inbound용
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const FieldMappingEditor: React.FC<FieldMappingEditorProps> = ({
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
direction,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
readonly = false,
|
||||
}) => {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [sampleApiData, setSampleApiData] = useState("");
|
||||
|
||||
// 새 매핑 추가
|
||||
const addMapping = useCallback(() => {
|
||||
const newMapping: FieldMapping = {
|
||||
id: `mapping-${Date.now()}`,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
dataType: "string",
|
||||
required: false,
|
||||
};
|
||||
onMappingsChange([...mappings, newMapping]);
|
||||
}, [mappings, onMappingsChange]);
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = useCallback(
|
||||
(id: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.id !== id));
|
||||
},
|
||||
[mappings, onMappingsChange],
|
||||
);
|
||||
|
||||
// 매핑 업데이트
|
||||
const updateMapping = useCallback(
|
||||
(id: string, updates: Partial<FieldMapping>) => {
|
||||
onMappingsChange(mappings.map((m) => (m.id === id ? { ...m, ...updates } : m)));
|
||||
},
|
||||
[mappings, onMappingsChange],
|
||||
);
|
||||
|
||||
// 자동 매핑 (이름 기반)
|
||||
const autoMapFields = useCallback(() => {
|
||||
const currentTable = direction === "inbound" ? targetTable : sourceTable;
|
||||
if (!currentTable) return;
|
||||
|
||||
const newMappings: FieldMapping[] = [];
|
||||
|
||||
currentTable.fields.forEach((field) => {
|
||||
// 이미 매핑된 필드는 건너뛰기
|
||||
const existingMapping = mappings.find((m) =>
|
||||
direction === "inbound" ? m.targetField === field.name : m.sourceField === field.name,
|
||||
);
|
||||
if (existingMapping) return;
|
||||
|
||||
const mapping: FieldMapping = {
|
||||
id: `auto-${field.name}-${Date.now()}`,
|
||||
sourceField: direction === "inbound" ? field.name : field.name,
|
||||
targetField: direction === "inbound" ? field.name : field.name,
|
||||
dataType: field.dataType,
|
||||
required: !field.nullable,
|
||||
};
|
||||
newMappings.push(mapping);
|
||||
});
|
||||
|
||||
onMappingsChange([...mappings, ...newMappings]);
|
||||
}, [direction, targetTable, sourceTable, mappings, onMappingsChange]);
|
||||
|
||||
// 샘플 데이터에서 필드 추출
|
||||
const extractFieldsFromSample = useCallback(() => {
|
||||
if (!sampleApiData.trim()) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sampleApiData);
|
||||
const fields = Object.keys(parsed);
|
||||
|
||||
const newMappings: FieldMapping[] = [];
|
||||
|
||||
fields.forEach((fieldName) => {
|
||||
// 이미 매핑된 필드는 건너뛰기
|
||||
const existingMapping = mappings.find((m) =>
|
||||
direction === "inbound" ? m.sourceField === fieldName : m.targetField === fieldName,
|
||||
);
|
||||
if (existingMapping) return;
|
||||
|
||||
// 데이터 타입 추론
|
||||
const value = parsed[fieldName];
|
||||
let dataType: any = "string";
|
||||
if (typeof value === "number") dataType = "number";
|
||||
else if (typeof value === "boolean") dataType = "boolean";
|
||||
else if (value instanceof Date || /^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
|
||||
|
||||
const mapping: FieldMapping = {
|
||||
id: `sample-${fieldName}-${Date.now()}`,
|
||||
sourceField: direction === "inbound" ? fieldName : "",
|
||||
targetField: direction === "inbound" ? "" : fieldName,
|
||||
dataType,
|
||||
required: false,
|
||||
};
|
||||
newMappings.push(mapping);
|
||||
});
|
||||
|
||||
onMappingsChange([...mappings, ...newMappings]);
|
||||
setSampleApiData("");
|
||||
} catch (error) {
|
||||
console.error("샘플 데이터 파싱 실패:", error);
|
||||
}
|
||||
}, [sampleApiData, direction, mappings, onMappingsChange]);
|
||||
|
||||
const currentTable = direction === "inbound" ? targetTable : sourceTable;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
필드 매핑
|
||||
<Badge variant="outline">{mappings.length}개 매핑</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
{showAdvanced ? "간단히" : "고급"}
|
||||
</Button>
|
||||
{!readonly && (
|
||||
<Button variant="outline" size="sm" onClick={autoMapFields} disabled={!currentTable}>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
자동 매핑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 샘플 데이터 입력 (고급 모드) */}
|
||||
{showAdvanced && !readonly && (
|
||||
<div className="bg-muted space-y-2 rounded-lg p-3">
|
||||
<Label className="text-sm">샘플 API 데이터 (JSON)</Label>
|
||||
<Textarea
|
||||
value={sampleApiData}
|
||||
onChange={(e) => setSampleApiData(e.target.value)}
|
||||
placeholder='{"name": "홍길동", "age": 30, "email": "test@example.com"}'
|
||||
rows={3}
|
||||
/>
|
||||
<Button size="sm" onClick={extractFieldsFromSample} disabled={!sampleApiData.trim()}>
|
||||
필드 추출
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping) => (
|
||||
<Card key={mapping.id} className="p-3">
|
||||
<div className="grid grid-cols-12 items-center gap-2">
|
||||
{/* 소스 필드 */}
|
||||
<div className="col-span-4">
|
||||
<Label className="text-xs">{direction === "inbound" ? "외부 필드" : "내부 필드"}</Label>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{direction === "inbound" ? (
|
||||
<Globe className="h-3 w-3 text-blue-500" />
|
||||
) : (
|
||||
<Database className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
<Input
|
||||
value={mapping.sourceField}
|
||||
onChange={(e) => updateMapping(mapping.id, { sourceField: e.target.value })}
|
||||
placeholder={direction === "inbound" ? "API 필드명" : "테이블 컬럼명"}
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div className="col-span-4">
|
||||
<Label className="text-xs">{direction === "inbound" ? "내부 필드" : "외부 필드"}</Label>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{direction === "inbound" ? (
|
||||
<Database className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Globe className="h-3 w-3 text-blue-500" />
|
||||
)}
|
||||
{direction === "inbound" && currentTable ? (
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => {
|
||||
const field = currentTable.fields.find((f) => f.name === value);
|
||||
updateMapping(mapping.id, {
|
||||
targetField: value,
|
||||
dataType: field?.dataType || mapping.dataType,
|
||||
});
|
||||
}}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder="테이블 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentTable.fields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{field.name}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{field.dataType}
|
||||
</Badge>
|
||||
{field.isPrimaryKey && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
PK
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => updateMapping(mapping.id, { targetField: e.target.value })}
|
||||
placeholder={direction === "inbound" ? "테이블 컬럼명" : "API 필드명"}
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 타입 */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select
|
||||
value={mapping.dataType}
|
||||
onValueChange={(value: any) => updateMapping(mapping.id, { dataType: value })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="col-span-1">
|
||||
{!readonly && (
|
||||
<Button variant="ghost" size="sm" onClick={() => removeMapping(mapping.id)} className="h-8 w-8 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t pt-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${mapping.id}`}
|
||||
checked={mapping.required || false}
|
||||
onCheckedChange={(checked) => updateMapping(mapping.id, { required: checked as boolean })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Label htmlFor={`required-${mapping.id}`} className="text-xs">
|
||||
필수 필드
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">기본값</Label>
|
||||
<Input
|
||||
value={mapping.defaultValue || ""}
|
||||
onChange={(e) => updateMapping(mapping.id, { defaultValue: e.target.value })}
|
||||
placeholder="기본값"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 변환 설정 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="text-xs">데이터 변환</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Select
|
||||
value={mapping.transform?.type || "none"}
|
||||
onValueChange={(value: any) =>
|
||||
updateMapping(mapping.id, {
|
||||
transform: { ...mapping.transform, type: value },
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSFORM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{mapping.transform?.type === "constant" && (
|
||||
<Input
|
||||
value={mapping.transform.value || ""}
|
||||
onChange={(e) =>
|
||||
updateMapping(mapping.id, {
|
||||
transform: { ...mapping.transform, value: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="상수값"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mapping.transform?.type === "format" && (
|
||||
<Input
|
||||
value={mapping.transform.format || ""}
|
||||
onChange={(e) =>
|
||||
updateMapping(mapping.id, {
|
||||
transform: { ...mapping.transform, format: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="YYYY-MM-DD"
|
||||
size="sm"
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 매핑 추가 버튼 */}
|
||||
{!readonly && (
|
||||
<Button variant="outline" onClick={addMapping} className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
필드 매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 매핑 상태 */}
|
||||
{mappings.length === 0 && (
|
||||
<div className="text-muted-foreground py-6 text-center">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">필드 매핑이 없습니다.</p>
|
||||
<p className="text-xs">자동 매핑을 사용하거나 수동으로 추가하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 데이터 매핑 관련 타입 정의
|
||||
*/
|
||||
|
||||
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; // 날짜 포맷 등 (예: "YYYY-MM-DD")
|
||||
functionName?: string; // 커스텀 변환 함수명
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 매핑 정보
|
||||
*/
|
||||
export interface FieldMapping {
|
||||
id: string; // 매핑 고유 ID
|
||||
sourceField: string; // 소스 필드명 (외부 API 또는 내부 테이블)
|
||||
targetField: string; // 타겟 필드명 (내부 테이블 또는 외부 API)
|
||||
dataType: DataType;
|
||||
transform?: FieldTransform;
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound 매핑 설정 (외부 → 내부)
|
||||
*/
|
||||
export interface InboundMapping {
|
||||
targetTable: string;
|
||||
targetSchema?: string;
|
||||
fieldMappings: FieldMapping[];
|
||||
insertMode: InsertMode;
|
||||
keyFields?: string[]; // upsert/update 시 키 필드
|
||||
batchSize?: number; // 배치 처리 크기
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound 매핑 설정 (내부 → 외부)
|
||||
*/
|
||||
export interface OutboundMapping {
|
||||
sourceTable: string;
|
||||
sourceSchema?: string;
|
||||
sourceFilter?: string; // WHERE 조건
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리셋 매핑 템플릿
|
||||
*/
|
||||
export interface MappingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
direction: DataDirection;
|
||||
config: DataMappingConfig;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// 상수 정의
|
||||
export const DATA_DIRECTION_OPTIONS = [
|
||||
{ value: "none", label: "매핑 없음" },
|
||||
{ value: "inbound", label: "외부 → 내부 (GET)" },
|
||||
{ value: "outbound", label: "내부 → 외부 (POST)" },
|
||||
{ value: "bidirectional", label: "양방향" },
|
||||
] as const;
|
||||
|
||||
export const INSERT_MODE_OPTIONS = [
|
||||
{ value: "insert", label: "삽입만" },
|
||||
{ value: "upsert", label: "삽입/업데이트" },
|
||||
{ value: "update", label: "업데이트만" },
|
||||
] as const;
|
||||
|
||||
export const TRANSFORM_TYPE_OPTIONS = [
|
||||
{ value: "none", label: "변환 없음" },
|
||||
{ value: "constant", label: "상수값" },
|
||||
{ value: "format", label: "포맷 변환" },
|
||||
{ value: "function", label: "커스텀 함수" },
|
||||
] as const;
|
||||
|
||||
export const DATA_TYPE_OPTIONS = [
|
||||
{ value: "string", label: "문자열" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "boolean", label: "불린" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "json", label: "JSON" },
|
||||
] as const;
|
||||
BIN
vexplor.png
BIN
vexplor.png
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB |
|
|
@ -0,0 +1,293 @@
|
|||
# 외부호출 데이터 매핑 시스템 설계서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
외부 API 호출 시 데이터를 송수신하고, 이를 내부 테이블과 매핑하는 시스템을 구현합니다.
|
||||
|
||||
## 2. 현재 상황 분석
|
||||
|
||||
### 2.1 기존 기능
|
||||
|
||||
- ✅ REST API 호출 기본 기능
|
||||
- ✅ 인증 처리 (API Key, Basic, Bearer 등)
|
||||
- ✅ 요청/응답 테스트 기능
|
||||
- ✅ 외부호출 설정 저장
|
||||
|
||||
### 2.2 필요한 확장 기능
|
||||
|
||||
- 🔄 GET 요청 시 응답 데이터를 내부 테이블에 저장
|
||||
- 🔄 POST 요청 시 내부 테이블 데이터를 외부로 전송
|
||||
- 🔄 필드 매핑 설정 (외부 필드 ↔ 내부 필드)
|
||||
- 🔄 데이터 변환 및 검증
|
||||
|
||||
## 3. 시스템 아키텍처
|
||||
|
||||
### 3.1 데이터 플로우
|
||||
|
||||
```
|
||||
GET 요청 플로우:
|
||||
내부 이벤트 → 외부 API 호출 → 응답 데이터 → 필드 매핑 → 내부 테이블 저장
|
||||
|
||||
POST 요청 플로우:
|
||||
내부 이벤트 → 내부 테이블 조회 → 필드 매핑 → 외부 API 전송 → 응답 처리
|
||||
```
|
||||
|
||||
### 3.2 컴포넌트 구조
|
||||
|
||||
```
|
||||
ExternalCallPanel
|
||||
├── RestApiSettings (기존)
|
||||
├── DataMappingSettings (신규)
|
||||
│ ├── SourceTableSelector
|
||||
│ ├── TargetTableSelector
|
||||
│ ├── FieldMappingEditor
|
||||
│ └── DataTransformEditor
|
||||
└── ExternalCallTestPanel (확장)
|
||||
```
|
||||
|
||||
## 4. 데이터베이스 스키마 확장
|
||||
|
||||
### 4.1 external_call_configs 테이블 확장
|
||||
|
||||
```sql
|
||||
ALTER TABLE external_call_configs ADD COLUMN IF NOT EXISTS data_mapping_config JSONB;
|
||||
```
|
||||
|
||||
### 4.2 data_mapping_config JSON 구조
|
||||
|
||||
```typescript
|
||||
interface DataMappingConfig {
|
||||
direction: "inbound" | "outbound" | "bidirectional";
|
||||
|
||||
// GET 요청용 - 외부 → 내부
|
||||
inboundMapping?: {
|
||||
targetTable: string;
|
||||
targetSchema?: string;
|
||||
fieldMappings: FieldMapping[];
|
||||
insertMode: "insert" | "upsert" | "update";
|
||||
keyFields?: string[]; // upsert/update 시 키 필드
|
||||
};
|
||||
|
||||
// POST 요청용 - 내부 → 외부
|
||||
outboundMapping?: {
|
||||
sourceTable: string;
|
||||
sourceSchema?: string;
|
||||
sourceFilter?: string; // WHERE 조건
|
||||
fieldMappings: FieldMapping[];
|
||||
};
|
||||
}
|
||||
|
||||
interface FieldMapping {
|
||||
sourceField: string; // 외부 API 필드명 또는 내부 테이블 컬럼명
|
||||
targetField: string; // 내부 테이블 컬럼명 또는 외부 API 필드명
|
||||
dataType: "string" | "number" | "boolean" | "date" | "json";
|
||||
transform?: {
|
||||
type: "none" | "constant" | "format" | "function";
|
||||
value?: any;
|
||||
format?: string; // 날짜 포맷 등
|
||||
functionName?: string; // 커스텀 변환 함수
|
||||
};
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 프론트엔드 컴포넌트 설계
|
||||
|
||||
### 5.1 DataMappingSettings.tsx
|
||||
|
||||
```typescript
|
||||
interface DataMappingSettingsProps {
|
||||
config: DataMappingConfig;
|
||||
onConfigChange: (config: DataMappingConfig) => void;
|
||||
httpMethod: string;
|
||||
availableTables: TableInfo[];
|
||||
}
|
||||
|
||||
// 주요 기능:
|
||||
// - 방향 선택 (inbound/outbound/bidirectional)
|
||||
// - 소스/타겟 테이블 선택
|
||||
// - 필드 매핑 에디터
|
||||
// - 데이터 변환 설정
|
||||
```
|
||||
|
||||
### 5.2 FieldMappingEditor.tsx
|
||||
|
||||
```typescript
|
||||
interface FieldMappingEditorProps {
|
||||
mappings: FieldMapping[];
|
||||
sourceFields: FieldInfo[];
|
||||
targetFields: FieldInfo[];
|
||||
onMappingsChange: (mappings: FieldMapping[]) => void;
|
||||
}
|
||||
|
||||
// 주요 기능:
|
||||
// - 드래그 앤 드롭으로 필드 매핑
|
||||
// - 데이터 타입 자동 추론
|
||||
// - 변환 함수 설정
|
||||
// - 필수 필드 검증
|
||||
```
|
||||
|
||||
### 5.3 DataTransformEditor.tsx
|
||||
|
||||
```typescript
|
||||
// 데이터 변환 규칙 설정
|
||||
// - 상수값 할당
|
||||
// - 날짜 포맷 변환
|
||||
// - 문자열 변환 (대소문자, 트림 등)
|
||||
// - 커스텀 함수 적용
|
||||
```
|
||||
|
||||
## 6. 백엔드 서비스 확장
|
||||
|
||||
### 6.1 ExternalCallExecutor 확장
|
||||
|
||||
```typescript
|
||||
class ExternalCallExecutor {
|
||||
async executeWithDataMapping(
|
||||
config: ExternalCallConfig,
|
||||
triggerData?: any
|
||||
): Promise<ExternalCallResult> {
|
||||
const result = await this.executeApiCall(config);
|
||||
|
||||
if (result.success && config.dataMappingConfig) {
|
||||
if (config.restApiSettings.httpMethod === "GET") {
|
||||
await this.processInboundData(result, config.dataMappingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async processInboundData(
|
||||
result: ExternalCallResult,
|
||||
mappingConfig: DataMappingConfig
|
||||
) {
|
||||
// 1. 응답 데이터 파싱
|
||||
// 2. 필드 매핑 적용
|
||||
// 3. 데이터 변환
|
||||
// 4. 데이터베이스 저장
|
||||
}
|
||||
|
||||
private async prepareOutboundData(
|
||||
mappingConfig: DataMappingConfig,
|
||||
triggerData?: any
|
||||
): Promise<any> {
|
||||
// 1. 소스 테이블 조회
|
||||
// 2. 필드 매핑 적용
|
||||
// 3. 데이터 변환
|
||||
// 4. API 요청 바디 생성
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 DataMappingService.ts (신규)
|
||||
|
||||
```typescript
|
||||
class DataMappingService {
|
||||
async mapInboundData(
|
||||
sourceData: any,
|
||||
mapping: InboundMapping
|
||||
): Promise<any[]> {
|
||||
// 외부 데이터 → 내부 테이블 매핑
|
||||
}
|
||||
|
||||
async mapOutboundData(
|
||||
sourceTable: string,
|
||||
mapping: OutboundMapping,
|
||||
filter?: any
|
||||
): Promise<any> {
|
||||
// 내부 테이블 → 외부 API 매핑
|
||||
}
|
||||
|
||||
private transformFieldValue(value: any, transform: FieldTransform): any {
|
||||
// 필드 변환 로직
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 구현 단계
|
||||
|
||||
### Phase 1: 기본 매핑 시스템 (1-2주)
|
||||
|
||||
1. 데이터베이스 스키마 확장
|
||||
2. DataMappingSettings 컴포넌트 개발
|
||||
3. 기본 필드 매핑 기능
|
||||
4. GET 요청 응답 데이터 저장
|
||||
|
||||
### Phase 2: 고급 매핑 기능 (1-2주)
|
||||
|
||||
1. POST 요청 데이터 송신
|
||||
2. 필드 변환 기능
|
||||
3. upsert/update 모드
|
||||
4. 배치 처리
|
||||
|
||||
### Phase 3: UI/UX 개선 (1주)
|
||||
|
||||
1. 드래그 앤 드롭 매핑 에디터
|
||||
2. 실시간 미리보기
|
||||
3. 매핑 템플릿
|
||||
4. 에러 처리 및 로깅
|
||||
|
||||
## 8. 사용 시나리오
|
||||
|
||||
### 8.1 외부 API에서 데이터 가져오기 (GET)
|
||||
|
||||
```
|
||||
고객사 API → 우리 customer 테이블
|
||||
- 고객 정보 동기화
|
||||
- 주문 정보 수집
|
||||
- 재고 정보 업데이트
|
||||
```
|
||||
|
||||
### 8.2 외부 API로 데이터 보내기 (POST)
|
||||
|
||||
```
|
||||
우리 order 테이블 → 배송사 API
|
||||
- 주문 정보 전달
|
||||
- 재고 변동 알림
|
||||
- 상태 업데이트 전송
|
||||
```
|
||||
|
||||
## 9. 기술적 고려사항
|
||||
|
||||
### 9.1 데이터 일관성
|
||||
|
||||
- 트랜잭션 처리
|
||||
- 롤백 메커니즘
|
||||
- 중복 데이터 처리
|
||||
|
||||
### 9.2 성능 최적화
|
||||
|
||||
- 배치 처리
|
||||
- 비동기 처리
|
||||
- 캐싱 전략
|
||||
|
||||
### 9.3 보안
|
||||
|
||||
- 데이터 검증
|
||||
- SQL 인젝션 방지
|
||||
- 민감 데이터 마스킹
|
||||
|
||||
### 9.4 모니터링
|
||||
|
||||
- 매핑 실행 로그
|
||||
- 에러 추적
|
||||
- 성능 메트릭
|
||||
|
||||
## 10. 성공 지표
|
||||
|
||||
- ✅ 외부 API 응답 데이터를 내부 테이블에 정확히 저장
|
||||
- ✅ 내부 테이블 데이터를 외부 API로 정확히 전송
|
||||
- ✅ 필드 매핑 설정이 직관적이고 사용하기 쉬움
|
||||
- ✅ 데이터 변환이 정확하고 안정적
|
||||
- ✅ 에러 발생 시 적절한 처리 및 알림
|
||||
|
||||
## 11. 다음 단계
|
||||
|
||||
1. **우선순위 결정**: GET/POST 중 어느 것부터 구현할지
|
||||
2. **테이블 선택**: 매핑할 주요 테이블들 식별
|
||||
3. **프로토타입**: 간단한 매핑 시나리오로 POC 개발
|
||||
4. **점진적 확장**: 기본 → 고급 기능 순서로 개발
|
||||
|
||||
이 설계서를 바탕으로 단계별로 구현해 나가면 됩니다. 어떤 부분부터 시작하고 싶으신가요?
|
||||
Loading…
Reference in New Issue