import { XMLParser } from "fast-xml-parser"; import { readFileSync } from "fs"; import { NextResponse } from "next/server"; import { join } from "path"; import { eq } from "drizzle-orm"; import db from "@/db/db"; import { soapLogs, type LogDirection, type SoapLogInsert } from "@/db/schema/SOAP/soap"; // XML 파싱용 타입 유틸리티: 스키마에서 XML 타입 생성 export type ToXMLFields = { [K in keyof T]?: T[K] extends string | null | undefined ? string : never; }; // SOAP Body 데이터 타입 (범용) export interface SoapBodyData { [key: string]: unknown; } // WSDL 파일 제공 함수 export function serveWsdl(wsdlFileName: string) { try { // public/wsdl 에서 WSDL 제공함을 가정 // 이게 WSDL 구현 표준인데, 보안 감사에서 반대한다면 제거 const wsdlPath = join(process.cwd(), 'public', 'wsdl', wsdlFileName); const wsdlContent = readFileSync(wsdlPath, 'utf-8'); return new NextResponse(wsdlContent, { headers: { 'Content-Type': 'text/xml; charset=utf-8', }, }); } catch (error) { console.error('Failed to read WSDL file:', error); return new NextResponse('WSDL file not found', { status: 404 }); } } // XML 파서 생성 // SAP XI 가 자동생성해 보내는 XML을 처리할 수 있도록 설정함 export function createXMLParser(arrayTags: string[] = []) { return new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', parseAttributeValue: false, trimValues: true, isArray: (name: string) => arrayTags.includes(name), parseTagValue: false, allowBooleanAttributes: true, }); } // SOAP Body나 루트에서 요청 데이터 추출 (범용) export function extractRequestData( parsedData: Record, requestKeyPattern: string ): SoapBodyData | null { // SOAP 구조 체크 (방어적) const soapPaths = [ ['soap:Envelope', 'soap:Body'], ['SOAP:Envelope', 'SOAP:Body'], ['Envelope', 'Body'], ['soapenv:Envelope', 'soapenv:Body'] ]; for (const [envelope, body] of soapPaths) { const envelopeData = parsedData?.[envelope] as Record | undefined; if (envelopeData?.[body]) { const result = extractFromSoapBody(envelopeData[body] as SoapBodyData, requestKeyPattern); if (result) return result; } } // 직접 요청 데이터 체크 const requestKeys = [ requestKeyPattern, `tns:${requestKeyPattern}`, `ns1:${requestKeyPattern}`, `p0:${requestKeyPattern}` ]; for (const key of requestKeys) { if (parsedData?.[key]) { return parsedData[key] as SoapBodyData; } } // 키 이름 패턴 검색 for (const key of Object.keys(parsedData)) { if (key.includes(requestKeyPattern)) { return parsedData[key] as SoapBodyData; } } // 메인 데이터가 직접 있는 경우 (MATL 등) if (parsedData?.MATL && Array.isArray(parsedData.MATL)) { return parsedData as SoapBodyData; } return null; } function extractFromSoapBody(soapBody: SoapBodyData, requestKeyPattern: string): SoapBodyData | null { const requestKeys = [ requestKeyPattern.replace('Req', ''), requestKeyPattern, `tns:${requestKeyPattern}`, `ns1:${requestKeyPattern}`, `p0:${requestKeyPattern}` ]; for (const key of requestKeys) { if (soapBody?.[key]) { return soapBody[key] as SoapBodyData; } } // 패턴 검색 for (const key of Object.keys(soapBody)) { if (key.includes(requestKeyPattern)) { return soapBody[key] as SoapBodyData; } } // 메인 데이터가 직접 있는 경우 if (soapBody.MATL && Array.isArray(soapBody.MATL)) { return soapBody; } return null; } // 범용 XML → DB 변환 함수 /** * XML 데이터를 DB 삽입 가능한 형태로 변환 * * 아키텍처 설계: * - 하위 테이블들은 별도의 필수 필드가 없다고 가정 (스키마에서 notNull() 제거 예정) * - FK는 항상 최상위 테이블의 unique 필드를 참조 * - 송신된 XML은 항상 전체 데이터셋을 포함 * - 최상위 테이블의 unique 필드가 충돌하면 전체 삭제 후 재삽입 처리 * * FK 처리 방식: * - XML에 FK 필드가 이미 포함된 경우: XML 값 우선 사용 (예: MATL 인터페이스) * - XML에 FK 필드가 없는 경우: 상위에서 전달받은 FK 값 사용 (예: VENDOR 인터페이스) * - 이를 통해 다양한 SAP 인터페이스 패턴에 대응 * * @param xmlData XML에서 파싱된 데이터 * @param fkData 상위 테이블에서 전달받은 FK 데이터 * @returns DB 삽입 가능한 형태로 변환된 데이터 */ export function convertXMLToDBData>( xmlData: Record, fkData?: Record ): T { const result = {} as T; // XML 필드를 DB 필드로 변환 (string → string|null) for (const key in xmlData) { if (xmlData.hasOwnProperty(key)) { const value = xmlData[key]; (result as Record)[key] = value || null; } } // FK 필드 처리 (XML 우선, 없으면 상위에서 전달받은 값 사용) if (fkData) { for (const [key, value] of Object.entries(fkData)) { // XML에 해당 FK 필드가 없거나 비어있는 경우에만 상위 값 사용 const existingValue = (result as Record)[key]; if (!existingValue || existingValue === null || existingValue === '') { (result as Record)[key] = value; } // XML에 이미 FK 필드가 있고 값이 있는 경우는 XML 값을 그대로 사용 } } return result; } // 중첩 배열 처리 함수 (개선된 버전) /** * 중첩된 배열 데이터를 처리하여 DB 삽입 가능한 형태로 변환 * * 처리 방식: * - 하위 테이블 데이터는 FK만 설정하면 됨 * - 별도의 필수 필드 생성 로직 불필요 * - 전체 데이터셋 기반으로 삭제 후 재삽입 처리 * * @param items 처리할 배열 데이터 * @param converter 변환 함수 * @param fkData FK 데이터 * @returns 변환된 배열 데이터 */ export function processNestedArray( items: T[] | undefined, converter: (item: T, fkData?: Record) => U, fkData?: Record ): U[] { if (!items || !Array.isArray(items)) { return []; } return items.map(item => converter(item, fkData)); } // 에러 응답 생성 export function createErrorResponse(error: unknown): NextResponse { console.error('API Error:', error); const errorResponse = ` soap:Server ${error instanceof Error ? ('[from eVCP]: ' + error.message) : 'Unknown error'} `; return new NextResponse(errorResponse, { status: 500, headers: { 'Content-Type': 'text/xml; charset=utf-8', }, }); } // 성공 응답 생성 export function createSuccessResponse(namespace: string): NextResponse { const xmlResponse = ` `; return new NextResponse(xmlResponse, { headers: { 'Content-Type': 'text/xml; charset=utf-8', }, }); } // 하위 테이블 처리: FK 기준으로 전체 삭제 후 재삽입 /** * 하위 테이블 데이터를 전체 삭제 후 재삽입하는 함수 * * 처리 전략: * - 송신 XML이 전체 데이터셋을 포함한다는 가정하에 설계 * - 부분 업데이트보다 전체 교체를 통해 데이터 일관성 확보 * - FK 기준으로 해당 부모 레코드의 모든 하위 데이터 교체 * * 처리 순서: * 1. FK 기준으로 기존 데이터 전체 삭제 * 2. 새로운 데이터 전체 삽입 * * @param tx 트랜잭션 객체 * @param table 대상 테이블 스키마 * @param data 삽입할 데이터 배열 * @param parentField FK 필드명 (일반적으로 'VNDRCD') * @param parentValue FK 값 (상위 테이블의 unique 필드 값) */ export async function replaceSubTableData>( tx: Parameters[0]>[0], table: any, // Drizzle 테이블 객체 - 복잡한 제네릭 타입으로 인해 any 사용 data: T[], parentField: string, parentValue: string ) { // 1. 기존 데이터 전체 삭제 (FK 기준) - eq() 함수 사용 await tx.delete(table).where(eq(table[parentField], parentValue)); // 2. 새 데이터 삽입 if (data.length > 0) { await tx.insert(table).values(data); } } // ======================================== // SOAP 로그 관련 공통 함수들 // ======================================== /** * SOAP 요청 로그를 시작하고 로그 ID를 반환 * @param direction 수신/송신 구분 ('INBOUND' | 'OUTBOUND') * @param system 시스템명 (예: 'S-ERP', 'MDG') * @param interfaceName 인터페이스명 (예: 'IF_MDZ_EVCP_CUSTOMER_MASTER') * @param requestData 요청 XML 데이터 * @returns 생성된 로그 ID */ export async function startSoapLog( direction: LogDirection, system: string, interfaceName: string, requestData: string ): Promise { try { const logData: SoapLogInsert = { direction, system, interface: interfaceName, startedAt: new Date(), endedAt: null, isSuccess: false, requestData, responseData: null, errorMessage: null, }; const [result] = await db.insert(soapLogs).values(logData).returning({ id: soapLogs.id }); console.log(`📝 SOAP 로그 시작 [${direction}] ${system}/${interfaceName} - ID: ${result.id}`); return result.id; } catch (error) { console.error('SOAP 로그 시작 실패:', error); throw error; } } /** * SOAP 요청 로그를 완료 처리 * @param logId 로그 ID * @param isSuccess 성공 여부 * @param responseData 응답 XML 데이터 (선택사항) * @param errorMessage 에러 메시지 (실패시) */ export async function completeSoapLog( logId: number, isSuccess: boolean, responseData?: string, errorMessage?: string ): Promise { try { await db.update(soapLogs) .set({ endedAt: new Date(), isSuccess, responseData: responseData || null, errorMessage: errorMessage || null, }) .where(eq(soapLogs.id, logId)); console.log(`✅ SOAP 로그 완료 - ID: ${logId}, 성공: ${isSuccess}`); } catch (error) { console.error('SOAP 로그 완료 처리 실패:', error); throw error; } } /** * 환경변수 기반으로 오래된 SOAP 로그 정리 * SOAP_LOG_MAX_RECORDS 환경변수를 확인하여 최대 개수 초과시 오래된 로그 삭제 */ export async function cleanupOldSoapLogs(): Promise { try { const maxRecords = parseInt(process.env.SOAP_LOG_MAX_RECORDS || '0'); if (maxRecords <= 0) { console.log('🔄 SOAP 로그 정리: 무제한 저장 설정 (SOAP_LOG_MAX_RECORDS = 0)'); return; } // 현재 총 로그 개수 확인 const totalLogs = await db.select({ count: soapLogs.id }).from(soapLogs); const currentCount = totalLogs.length; if (currentCount <= maxRecords) { console.log(`🔄 SOAP 로그 정리: 현재 ${currentCount}개, 최대 ${maxRecords}개 - 정리 불필요`); return; } // 삭제할 개수 계산 const deleteCount = currentCount - maxRecords; // 가장 오래된 로그들 조회 (ID 기준) const oldestLogs = await db.select({ id: soapLogs.id }) .from(soapLogs) .orderBy(soapLogs.id) .limit(deleteCount); if (oldestLogs.length === 0) { console.log('🔄 SOAP 로그 정리: 삭제할 로그 없음'); return; } // 오래된 로그들 삭제 const oldestIds = oldestLogs.map(log => log.id); // 배치 삭제 (IN 절 사용) for (const logId of oldestIds) { await db.delete(soapLogs).where(eq(soapLogs.id, logId)); } console.log(`🗑️ SOAP 로그 정리 완료: ${deleteCount}개 삭제 (${currentCount} → ${maxRecords})`); } catch (error) { console.error('SOAP 로그 정리 실패:', error); throw error; } } /** * SOAP 로그 관련 래퍼 함수: 로그 시작부터 완료까지 자동 처리 * @param direction 수신/송신 구분 * @param system 시스템명 * @param interfaceName 인터페이스명 * @param requestData 요청 데이터 * @param processor 실제 비즈니스 로직 함수 * @returns 처리 결과 */ export async function withSoapLogging( direction: LogDirection, system: string, interfaceName: string, requestData: string, processor: () => Promise ): Promise { let logId: number | null = null; try { // 1. 로그 시작 logId = await startSoapLog(direction, system, interfaceName, requestData); // 2. 실제 처리 실행 const result = await processor(); // 3. 성공 로그 완료 await completeSoapLog(logId, true); // 4. 로그 정리 (백그라운드) cleanupOldSoapLogs().catch(error => console.error('백그라운드 로그 정리 실패:', error) ); return result; } catch (error) { // 5. 실패 로그 완료 if (logId !== null) { await completeSoapLog( logId, false, undefined, error instanceof Error ? error.message : 'Unknown error' ); } throw error; } } /** * SOAP 로그 단순 기록 함수 (이미 완료된 작업에 대한 로깅) * @param direction 수신/송신 구분 * @param system 시스템명 * @param interfaceName 인터페이스명 * @param requestData 요청 XML 데이터 * @param responseData 응답 XML 데이터 (선택사항) * @param isSuccess 성공 여부 * @param errorMessage 에러 메시지 (실패시) */ export async function logSoapExecution( direction: LogDirection, system: string, interfaceName: string, requestData: string, responseData?: string, isSuccess: boolean = true, errorMessage?: string ): Promise { try { const logData: SoapLogInsert = { direction, system, interface: interfaceName, startedAt: new Date(), endedAt: new Date(), isSuccess, requestData, responseData: responseData || null, errorMessage: errorMessage || null, }; await db.insert(soapLogs).values(logData); console.log(`📝 SOAP 로그 기록 완료 [${direction}] ${system}/${interfaceName} - 성공: ${isSuccess}`); // 로그 정리 (백그라운드) cleanupOldSoapLogs().catch(error => console.error('백그라운드 로그 정리 실패:', error) ); } catch (error) { console.error('SOAP 로그 기록 실패:', error); // 로그 기록 실패는 메인 로직에 영향을 주지 않도록 throw 하지 않음 } }