diff --git a/PLAN.MD b/PLAN.MD index 787bef69..507695c6 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,28 +1,36 @@ -# 프로젝트: Digital Twin 에디터 안정화 +# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) ## 개요 - -Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. +현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. ## 핵심 기능 - -1. `DigitalTwinEditor` 버그 수정 -2. 비동기 함수 입력값 유효성 검증 강화 -3. 외부 DB 연결 상태에 따른 방어 코드 추가 +1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가 +2. **백엔드 로직 개선**: + - 커넥션 생성/수정 시 메서드와 바디 정보 저장 + - 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행 + - SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원) +3. **프론트엔드 UI 개선**: + - 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가 + - 테스트 기능에서 Body 데이터 포함하여 요청 전송 ## 테스트 계획 +### 1단계: 기본 기능 및 DB 마이그레이션 +- [x] DB 마이그레이션 스크립트 작성 및 실행 +- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가) -### 1단계: 긴급 버그 수정 +### 2단계: 백엔드 로직 구현 +- [x] 커넥션 생성/수정 API 수정 (필드 추가) +- [x] 커넥션 상세 조회 API 확인 +- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송) -- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) -- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 +### 3단계: 프론트엔드 구현 +- [x] 커넥션 관리 리스트/모달 UI 수정 +- [x] 연결 테스트 UI 수정 및 기능 확인 -### 2단계: 잠재적 문제 점검 - -- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 -- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 +## 에러 처리 계획 +- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리 +- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달 +- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용) ## 진행 상태 - -- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 - +- [완료] 모든 단계 구현 완료 diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json deleted file mode 100644 index 683ad20c..00000000 --- a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "1d997eeb-3d61-427d-8b54-119d4372b9b3", - "sentAt": "2025-10-22T07:13:30.905Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "Fwd: ㄴ", - "htmlContent": "\r\n
\r\n

전달히야야양


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
전달된 메일:

보낸사람: \"이희진\"
날짜: 2025. 10. 22. 오후 12:58:15
제목: ㄴ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json b/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json deleted file mode 100644 index eccdc063..00000000 --- a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6", - "sentAt": "2025-10-13T01:08:34.764Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

ㄴㅇㄹㄴㅇㄹ

\n \"\"\n

ㄴㅇㄹ

ㄴㅇㄹ

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹ

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "스크린샷 2025-10-13 오전 10.00.06.png", - "originalName": "스크린샷 2025-10-13 오전 10.00.06.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317712416-622369845.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json b/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json deleted file mode 100644 index a6fed281..00000000 --- a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87", - "sentAt": "2025-10-02T07:50:25.817Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "ㅣ;ㅏㅓ", - "htmlContent": "\r\n
\r\n

ㅓㅏㅣ

\r\n
\r\n ", - "attachments": [ - { - "filename": "test용 이미지33.jpg", - "originalName": "test용 이미지33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "UI_개선사항_문서.md", - "originalName": "UI_개선사항_문서.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json deleted file mode 100644 index 5090fdd2..00000000 --- a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "331d95d6-3a13-4657-bc75-ab0811712eb8", - "sentAt": "2025-10-22T07:18:18.240Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json b/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json deleted file mode 100644 index 46b0b1b8..00000000 --- a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "34f7f149-ac97-442e-b595-02c990082f86", - "sentAt": "2025-10-13T01:04:08.560Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n\n
\n \r\n
\r\n

선택메시지 영역

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317447824-27488793.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1d7caa77-12f1-a791-a230-162826cf03ea@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json b/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json deleted file mode 100644 index d70b6897..00000000 --- a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "37fce6a0-2301-431b-b573-82bdab9b8008", - "sentAt": "2025-10-02T07:44:38.128Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "asd", - "htmlContent": "\r\n
\r\n

asd

\r\n
\r\n ", - "attachments": [ - { - "filename": "웨이스-임직원-프로파일-이희진.key", - "originalName": "웨이스-임직원-프로파일-이희진.key", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key", - "mimetype": "application/x-iwork-keynote-sffkey" - }, - { - "filename": "웨이스-임직원-프로파일-이희진.pptx", - "originalName": "웨이스-임직원-프로파일-이희진.pptx", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx", - "mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation" - }, - { - "filename": "test용 이미지33.jpg", - "originalName": "test용 이미지33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg", - "mimetype": "image/jpeg" - } - ], - "status": "success", - "messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json b/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json deleted file mode 100644 index 05eb18c2..00000000 --- a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "3f72cbab-b60e-45e7-ac8d-7e441bc2b900", - "sentAt": "2025-10-13T01:34:19.363Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용22", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

안녕안녕하세요 이건 테스트용 템플릿입니다용22

\n \"\"\n

안녕하세용 [222]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[222] 안에 넣어도 돼요

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "blender study.docx", - "originalName": "blender study.docx", - "size": 0, - "path": "/app/uploads/mail-attachments/1760319257947-827879690.docx", - "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - } - ], - "status": "success", - "messageId": "<5b3d9f82-8531-f427-c7f7-9446b4f19da4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json new file mode 100644 index 00000000..ea3b568f --- /dev/null +++ b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json @@ -0,0 +1,20 @@ +{ + "id": "43466fc8-56e8-44a0-875c-dec2c3c8eb78", + "sentAt": "2025-11-28T02:34:02.239Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "임의로 설정한 제목", + "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n\n
\n \n \n \n \n \n
\n \n (주)웨이스\n \n 2025. 11. 28.\n
\n
\n
\n naver\n
\n \"\"\n
\n
\n
(주)웨이스
\n \n
\n 대표: 이희진\n \n \n
\n \n
주소주소
\n \n
\n Tel: 전화번호 01010101011010\n | \n Email: 이메일이메일\n
\n \n
© 2025 All rights reserved.
\n
\n \n
\n \n \n \n \n \n \n \n \n
항목내용
\n
\n \n
\n
안내
\n
안내를 합시다 합시다 합시다
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. 두번째항목
  3. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n\n\n", + "templateId": "template-1764296982213", + "templateName": "제목 있음", + "status": "success", + "messageId": "<78b63521-2648-f6eb-eeba-efdeebce8459@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json b/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json deleted file mode 100644 index 29ec634e..00000000 --- a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "449d9951-51e8-4e81-ada4-e73aed8ff60e", - "sentAt": "2025-10-13T01:29:25.975Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n
안녕안녕하세요 이건 테스트용 템플릿입니다용
\n \"\"\n

안녕하세용 [뭘 넣은 결과 입니당]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[안에 뭘 넣은 결과입니다.] 안에 넣어도 돼요

\n
\n\n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "status": "success", - "messageId": "<5d52accb-777b-b6c2-aab7-1a2f7b7754ab@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json b/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json deleted file mode 100644 index ee094c49..00000000 --- a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "6dd3673a-f510-4ba9-9634-0b391f925230", - "sentAt": "2025-10-13T01:01:55.097Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트용입니당.", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n
\n\n
\n \r\n
\r\n

이건 저장이 안되는군

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글-분석.txt", - "originalName": "한글-분석.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317313641-761345104.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json deleted file mode 100644 index 37317a6a..00000000 --- a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a", - "sentAt": "2025-10-22T04:27:51.044Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

야야야야야야야야ㅑㅇ야ㅑㅇ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json deleted file mode 100644 index 4ac647c7..00000000 --- a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "89a32ace-f39b-44fa-b614-c65d96548f92", - "sentAt": "2025-10-22T03:49:48.461Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "Fwd: 기상청 API허브 회원가입 인증번호", - "htmlContent": "\r\n
\r\n






---------- 전달된 메시지 ----------


보낸 사람: \"기상청 API허브\"


날짜: 2025. 10. 13. 오후 4:26:45


제목: 기상청 API허브 회원가입 인증번호




undefined

\r\n
\r\n ", - "status": "success", - "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json b/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json deleted file mode 100644 index ed2e4b14..00000000 --- a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "9eab902e-f77b-424f-ada4-0ea8709b36bf", - "sentAt": "2025-10-13T00:53:55.193Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "

텍스트를 입력하세요...

\n 버튼\n
\n \"\"\n

텍스트를 입력하세요...

텍스트를 입력하세요...

\n
\n \r\n
\r\n

어덯게 나오는지 봅시다 추가메시지 영역이빈다.

\r\n
\r\n \n
\n
", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760316833254-789302611.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json b/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json deleted file mode 100644 index 31492a08..00000000 --- a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "a1ca39ad-4467-44e0-963a-fba5037c8896", - "sentAt": "2025-10-02T08:22:14.721Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\r\n ", - "attachments": [ - { - "filename": "test용 이미지33.jpg", - "originalName": "test용 이미지33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "UI_개선사항_문서.md", - "originalName": "UI_개선사항_문서.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json b/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json deleted file mode 100644 index 1435f837..00000000 --- a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "a3a9aab1-4334-46bd-bf50-b867305f66c0", - "sentAt": "2025-10-02T08:41:42.086Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "한글테스트", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹ

\r\n
\r\n ", - "attachments": [ - { - "filename": "UI_개선사항_문서.md", - "originalName": "UI_개선사항_문서.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "test용 이미지33.jpg", - "originalName": "test용 이미지33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json deleted file mode 100644 index 5cf165c3..00000000 --- a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44", - "sentAt": "2025-10-22T07:21:13.723Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ", - "htmlContent": "\r\n
\r\n

ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ

\r\n
\r\n ", - "status": "success", - "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json b/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json deleted file mode 100644 index 8f8d5059..00000000 --- a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03", - "sentAt": "2025-10-02T08:57:48.412Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "ㅁㄴㅇㄹ", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹ

\r\n
\r\n ", - "attachments": [ - { - "filename": "웨이스-임직원-프로파일-이희진.key", - "originalName": "웨이스-임직원-프로파일-이희진.key", - "size": 0, - "path": "/app/uploads/mail-attachments/1759395465488-120933172.key", - "mimetype": "application/x-iwork-keynote-sffkey" - }, - { - "filename": "UI_개선사항_문서.md", - "originalName": "UI_개선사항_문서.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759395465566-306126854.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "test용 이미지33.jpg", - "originalName": "test용 이미지33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759395465567-143883587.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json b/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json deleted file mode 100644 index dbec91a5..00000000 --- a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8", - "sentAt": "2025-10-02T08:49:30.356Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "한글2", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹ

\r\n
\r\n ", - "attachments": [ - { - "filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "testáá­á¼ ááµááµááµ33.jpg", - "originalName": "testáá­á¼ ááµááµááµ33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "testáá­á¼ ááµááµááµ2.png", - "originalName": "testáá­á¼ ááµááµááµ2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json b/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json deleted file mode 100644 index d2d4c424..00000000 --- a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601", - "sentAt": "2025-10-02T08:47:03.481Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "한글테스트222", - "htmlContent": "\r\n
\r\n

2

\r\n
\r\n ", - "attachments": [ - { - "filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "testáá­á¼ ááµááµááµ33.jpg", - "originalName": "testáá­á¼ ááµááµááµ33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "testáá­á¼ ááµááµááµ2.png", - "originalName": "testáá­á¼ ááµááµááµ2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json b/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json deleted file mode 100644 index 1a388699..00000000 --- a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "e2801ec2-6219-4c3c-83b4-8a6834569488", - "sentAt": "2025-10-13T00:59:46.729Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n \r\n
\r\n

추가메시지 영역

\r\n
\r\n \n
\n
", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317184642-745285906.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1e0abffb-a6cc-8312-d8b4-31c33cb72aa7@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json deleted file mode 100644 index 74c8212f..00000000 --- a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd", - "sentAt": "2025-10-22T04:28:42.686Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"권은아\" " - ], - "subject": "Re: 매우 졸린 오후예요", - "htmlContent": "\r\n
\r\n

호홋 답장 기능을 구현했다죵
얼른 퇴근하고 싪네여

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"권은아\"

\r\n

날짜: 2025. 10. 22. 오후 1:10:37

\r\n

제목: 매우 졸린 오후예요

\r\n
\r\n undefined\r\n
\r\n ", - "attachments": [ - { - "filename": "test용 이미지2.png", - "originalName": "test용 이미지2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1761107318152-717716316.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>", - "accepted": [ - "chna8137s@gmail.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json b/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json deleted file mode 100644 index 45c6a1eb..00000000 --- a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "id": "ee0d162c-48ad-4c00-8c56-ade80be4503f", - "sentAt": "2025-10-02T08:48:29.740Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "한글한글", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ

\r\n
\r\n ", - "attachments": [ - { - "filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md", - "mimetype": "text/x-markdown" - }, - { - "filename": "testáá­á¼ ááµááµááµ33.jpg", - "originalName": "testáá­á¼ ááµááµááµ33.jpg", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg", - "mimetype": "image/jpeg" - }, - { - "filename": "testáá­á¼ ááµááµááµ2.png", - "originalName": "testáá­á¼ ááµááµááµ2.png", - "size": 0, - "path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png", - "mimetype": "image/png" - } - ], - "status": "success", - "messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json b/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json deleted file mode 100644 index f64daf8c..00000000 --- a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d", - "sentAt": "2025-10-13T00:21:51.799Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "test용입니다.", - "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹ

\r\n
\r\n ", - "templateId": "template-1759302346758", - "templateName": "test", - "attachments": [ - { - "filename": "웨이스-임직원-프로파일-이희진.key", - "originalName": "웨이스-임직원-프로파일-이희진.key", - "size": 0, - "path": "/app/uploads/mail-attachments/1760314910154-84512253.key", - "mimetype": "application/x-iwork-keynote-sffkey" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json deleted file mode 100644 index efd9a0c0..00000000 --- a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "fcea6149-a098-4212-aa00-baef0cc083d6", - "sentAt": "2025-10-22T04:24:54.126Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "\"DHS\" " - ], - "subject": "Re: 안녕하세여", - "htmlContent": "\r\n
\r\n

어떻게 가는지 궁금한데 이따가 화면 보여주세영

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"DHS\"

\r\n

날짜: 2025. 10. 22. 오후 1:09:49

\r\n

제목: 안녕하세여

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "ddhhss0603@gmail.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 8a29e5bf..638edcd2 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -169,22 +169,18 @@ export class BatchController { static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; - const userCompanyCode = req.user?.companyCode; - const batchConfig = await BatchService.getBatchConfigById( - Number(id), - userCompanyCode - ); + const result = await BatchService.getBatchConfigById(Number(id)); - if (!batchConfig) { + if (!result.success || !result.data) { return res.status(404).json({ success: false, - message: "배치 설정을 찾을 수 없습니다.", + message: result.message || "배치 설정을 찾을 수 없습니다.", }); } return res.json({ success: true, - data: batchConfig, + data: result.data, }); } catch (error) { console.error("배치 설정 조회 오류:", error); diff --git a/backend-node/src/controllers/batchExecutionLogController.ts b/backend-node/src/controllers/batchExecutionLogController.ts index 84608731..1b0166ae 100644 --- a/backend-node/src/controllers/batchExecutionLogController.ts +++ b/backend-node/src/controllers/batchExecutionLogController.ts @@ -62,6 +62,11 @@ export class BatchExecutionLogController { try { const data: CreateBatchExecutionLogRequest = req.body; + // 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정 + if (!data.company_code) { + data.company_code = req.user?.companyCode || "*"; + } + const result = await BatchExecutionLogService.createExecutionLog(data); if (result.success) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index d1be2311..61194485 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -265,8 +265,12 @@ export class BatchManagementController { try { // 실행 로그 생성 - executionLog = await BatchService.createExecutionLog({ + const { BatchExecutionLogService } = await import( + "../services/batchExecutionLogService" + ); + const logResult = await BatchExecutionLogService.createExecutionLog({ batch_config_id: Number(id), + company_code: batchConfig.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, @@ -274,6 +278,14 @@ export class BatchManagementController { failed_records: 0, }); + if (!logResult.success || !logResult.data) { + throw new Error( + logResult.message || "배치 실행 로그를 생성할 수 없습니다." + ); + } + + executionLog = logResult.data; + // BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거) const { BatchSchedulerService } = await import( "../services/batchSchedulerService" @@ -290,7 +302,7 @@ export class BatchManagementController { const duration = endTime.getTime() - startTime.getTime(); // 실행 로그 업데이트 (성공) - await BatchService.updateExecutionLog(executionLog.id, { + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "SUCCESS", end_time: endTime, duration_ms: duration, @@ -406,22 +418,34 @@ export class BatchManagementController { paramName, paramValue, paramSource, + requestBody, } = req.body; - if (!apiUrl || !apiKey || !endpoint) { + // apiUrl, endpoint는 항상 필수 + if (!apiUrl || !endpoint) { return res.status(400).json({ success: false, - message: "API URL, API Key, 엔드포인트는 필수입니다.", + message: "API URL과 엔드포인트는 필수입니다.", + }); + } + + // GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) + if ((!method || method === "GET") && !apiKey) { + return res.status(400).json({ + success: false, + message: "GET 메서드에서는 API Key가 필요합니다.", }); } console.log("🔍 REST API 미리보기 요청:", { apiUrl, endpoint, + method, paramType, paramName, paramValue, paramSource, + requestBody: requestBody ? "Included" : "None", }); // RestApiConnector 사용하여 데이터 조회 @@ -429,7 +453,7 @@ export class BatchManagementController { const connector = new RestApiConnector({ baseUrl: apiUrl, - apiKey: apiKey, + apiKey: apiKey || "", timeout: 30000, }); @@ -456,9 +480,28 @@ export class BatchManagementController { console.log("🔗 최종 엔드포인트:", finalEndpoint); - // 데이터 조회 (최대 5개만) - GET 메서드만 지원 - const result = await connector.executeQuery(finalEndpoint, method); - console.log(`[previewRestApiData] executeQuery 결과:`, { + // Request Body 파싱 + let parsedBody = undefined; + if (requestBody && typeof requestBody === "string") { + try { + parsedBody = JSON.parse(requestBody); + } catch (e) { + console.warn("Request Body JSON 파싱 실패:", e); + // 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능) + // 여기서는 경고 로그 남기고 진행 + } + } else if (requestBody) { + parsedBody = requestBody; + } + + // 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원) + const result = await connector.executeRequest( + finalEndpoint, + method as "GET" | "POST" | "PUT" | "DELETE", + parsedBody + ); + + console.log(`[previewRestApiData] executeRequest 결과:`, { rowCount: result.rowCount, rowsLength: result.rows ? result.rows.length : "undefined", firstRow: @@ -532,15 +575,21 @@ export class BatchManagementController { apiMappings, }); + // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId; + // BatchService를 사용하여 배치 설정 저장 const batchConfig: CreateBatchConfigRequest = { batchName: batchName, description: description || "", cronSchedule: cronSchedule, + isActive: "Y", + companyCode, mappings: apiMappings, }; - const result = await BatchService.createBatchConfig(batchConfig); + const result = await BatchService.createBatchConfig(batchConfig, userId); if (result.success && result.data) { // 스케줄러에 자동 등록 ✅ diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts index 882d8e62..4ea80ef9 100644 --- a/backend-node/src/controllers/digitalTwinTemplateController.ts +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -161,3 +161,4 @@ export const createMappingTemplate = async ( + diff --git a/backend-node/src/database/RestApiConnector.ts b/backend-node/src/database/RestApiConnector.ts index 2c2965aa..9fd68fe7 100644 --- a/backend-node/src/database/RestApiConnector.ts +++ b/backend-node/src/database/RestApiConnector.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance, AxiosResponse } from "axios"; +import https from "https"; import { DatabaseConnector, ConnectionConfig, @@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector { constructor(config: RestApiConfig) { this.config = config; - // Axios 인스턴스 생성 + // 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가 + const defaultHeaders: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (config.apiKey) { + defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`; + } + this.httpClient = axios.create({ baseURL: config.baseUrl, timeout: config.timeout || 30000, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - Accept: "application/json", - }, + headers: defaultHeaders, + // ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서 + // 인증서 검증을 끈 HTTPS 에이전트를 사용한다. + // 내부망/신뢰된 시스템 전용으로 사용해야 하며, + // 공개 인터넷용 API에는 적용하면 안 된다. + httpsAgent: new https.Agent({ rejectUnauthorized: false }), }); // 요청/응답 인터셉터 설정 @@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector { } async connect(): Promise { - try { - // 연결 테스트 - 기본 엔드포인트 호출 - await this.httpClient.get("/health", { timeout: 5000 }); - console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); - } catch (error) { - // health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 - if (axios.isAxiosError(error) && error.response?.status === 404) { - console.log( - `[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}` - ); - return; - } - console.error( - `[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, - error - ); - throw new Error( - `REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}` - ); - } + // 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만, + // 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아 + // 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다. + // + // 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고 + // 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다. + console.log( + `[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}` + ); + return; } async disconnect(): Promise { diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index 9f577e52..48813575 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -213,7 +213,10 @@ router.post( } const result = - await ExternalRestApiConnectionService.testConnection(testRequest); + await ExternalRestApiConnectionService.testConnection( + testRequest, + req.user?.companyCode + ); return res.status(200).json(result); } catch (error) { @@ -264,4 +267,46 @@ router.post( } ); +/** + * POST /api/external-rest-api-connections/:id/fetch + * REST API 데이터 조회 (화면관리용 프록시) + */ +router.post( + "/:id/fetch", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = parseInt(req.params.id); + + if (isNaN(id)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 ID입니다.", + }); + } + + const { endpoint, jsonPath } = req.body; + const userCompanyCode = req.user?.companyCode; + + logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`); + + const result = await ExternalRestApiConnectionService.fetchData( + id, + endpoint, + jsonPath, + userCompanyCode + ); + + return res.status(result.success ? 200 : 400).json(result); + } catch (error) { + logger.error("REST API 데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "서버 내부 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } +); + export default router; diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts index d4818b3a..f63e929f 100644 --- a/backend-node/src/services/DigitalTwinTemplateService.ts +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -170,3 +170,4 @@ export class DigitalTwinTemplateService { + diff --git a/backend-node/src/services/batchExecutionLogService.ts b/backend-node/src/services/batchExecutionLogService.ts index f2fc583c..3561f43f 100644 --- a/backend-node/src/services/batchExecutionLogService.ts +++ b/backend-node/src/services/batchExecutionLogService.ts @@ -130,13 +130,14 @@ export class BatchExecutionLogService { try { const log = await queryOne( `INSERT INTO batch_execution_logs ( - batch_config_id, execution_status, start_time, end_time, + batch_config_id, company_code, execution_status, start_time, end_time, duration_ms, total_records, success_records, failed_records, error_message, error_details, server_name, process_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ data.batch_config_id, + data.company_code, data.execution_status, data.start_time || new Date(), data.end_time, diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 75d7ea67..18524085 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -77,45 +77,47 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( - connectionType: "internal" | "external", - connectionId?: number + static async getTables( + connectionId: number ): Promise> { try { - let tables: TableInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name`, - [] - ); - - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 테이블 조회 - const tablesResult = await this.getExternalTables(connectionId); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); + if (!connection) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } + // 커넥터 생성 + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, + connectionId + ); + + // 연결 + await connector.connect(); + + // 테이블 목록 조회 + const tables = await connector.getTables(); + + // 연결 종료 + await connector.disconnect(); + return { success: true, data: tables, message: `${tables.length}개의 테이블을 조회했습니다.`, }; } catch (error) { - console.error("배치관리 테이블 목록 조회 실패:", error); + console.error("테이블 목록 조회 실패:", error); return { success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", @@ -125,562 +127,283 @@ export class BatchExternalDbService { } /** - * 배치관리용 테이블 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( - connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string - ): Promise> { - try { - console.log(`[BatchExternalDbService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - - if (connectionType === "internal") { - // 내부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}` - ); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { - // 외부 DB 컬럼 조회 - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await this.getExternalTableColumns( - connectionId, - tableName - ); - - console.log( - `[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, - columnsResult - ); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - } - - console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns); - return { - success: true, - data: columns, - message: `${columns.length}개의 컬럼을 조회했습니다.`, - }; - } catch (error) { - console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error); - return { - success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 목록 조회 (내부 구현) - */ - private static async getExternalTables( - connectionId: number - ): Promise> { - try { - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - - if (!connection) { - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; - } - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - if (!decryptedPassword) { - return { - success: false, - message: "비밀번호 복호화에 실패했습니다.", - }; - } - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - // DatabaseConnectorFactory를 통한 테이블 목록 조회 - const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type, - config, - connectionId - ); - const tables = await connector.getTables(); - - return { - success: true, - message: "테이블 목록을 조회했습니다.", - data: tables, - }; - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - return { - success: false, - message: "테이블 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }; - } - } - - /** - * 외부 DB 테이블 컬럼 정보 조회 (내부 구현) - */ - private static async getExternalTableColumns( + static async getColumns( connectionId: number, tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}` - ); - // 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + const connection = await this.getConnectionById(connectionId); if (!connection) { - console.log( - `[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}` - ); - return { - success: false, - message: "연결 정보를 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, { - id: connection.id, - connection_name: connection.connection_name, - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - }); - - // 비밀번호 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // 연결 설정 준비 - const config = { - host: connection.host, - port: connection.port, - database: connection.database_name, - user: connection.username, - password: decryptedPassword, - connectionTimeoutMillis: - connection.connection_timeout != null - ? connection.connection_timeout * 1000 - : undefined, - queryTimeoutMillis: - connection.query_timeout != null - ? connection.query_timeout * 1000 - : undefined, - ssl: - connection.ssl_enabled === "Y" - ? { rejectUnauthorized: false } - : false, - }; - - console.log( - `[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}` - ); - - // 데이터베이스 타입에 따른 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, - config, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - console.log( - `[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}` - ); + // 연결 + await connector.connect(); - // 컬럼 정보 조회 - console.log(`[BatchExternalDbService] connector.getColumns 호출 전`); + // 컬럼 목록 조회 const columns = await connector.getColumns(tableName); - console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns); - console.log( - `[BatchExternalDbService] 원본 컬럼 개수:`, - columns ? columns.length : "null/undefined" - ); + // 연결 종료 + await connector.disconnect(); - // 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환 - const standardizedColumns: ColumnInfo[] = columns.map((col: any) => { - console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col); - - // MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만) - if (col.name && col.dataType !== undefined) { - const result = { - column_name: col.name, - data_type: col.dataType, - is_nullable: col.isNullable ? "YES" : "NO", - column_default: col.defaultValue || null, - }; - console.log( - `[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, - result - ); - return result; - } - // PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default} - else { - const result = { - column_name: col.column_name || col.COLUMN_NAME, - data_type: col.data_type || col.DATA_TYPE, - is_nullable: - col.is_nullable || - col.IS_NULLABLE || - (col.nullable === "Y" ? "YES" : "NO"), - column_default: col.column_default || col.COLUMN_DEFAULT || null, - }; - console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result); - return result; - } - }); - - console.log( - `[BatchExternalDbService] 표준화된 컬럼 목록:`, - standardizedColumns - ); - - // 빈 배열인 경우 경고 로그 - if (!standardizedColumns || standardizedColumns.length === 0) { - console.warn( - `[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}` - ); - console.warn(`[BatchExternalDbService] 연결 정보:`, { - db_type: connection.db_type, - host: connection.host, - port: connection.port, - database_name: connection.database_name, - username: connection.username, - }); - - // 테이블 존재 여부 확인 - console.warn( - `[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도` - ); - try { - const tables = await connector.getTables(); - console.warn( - `[BatchExternalDbService] 사용 가능한 테이블 목록:`, - tables.map((t) => t.table_name) - ); - - // 테이블명이 정확한지 확인 - const tableExists = tables.some( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - console.warn( - `[BatchExternalDbService] 테이블 존재 여부: ${tableExists}` - ); - - // 정확한 테이블명 찾기 - const exactTable = tables.find( - (t) => t.table_name.toLowerCase() === tableName.toLowerCase() - ); - if (exactTable) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}` - ); - } - - // 모든 테이블명 출력 - console.warn( - `[BatchExternalDbService] 모든 테이블명:`, - tables.map((t) => `"${t.table_name}"`) - ); - - // 테이블명 비교 - console.warn( - `[BatchExternalDbService] 요청된 테이블명: "${tableName}"` - ); - console.warn( - `[BatchExternalDbService] 테이블명 비교 결과:`, - tables.map((t) => ({ - table_name: t.table_name, - matches: t.table_name.toLowerCase() === tableName.toLowerCase(), - exact_match: t.table_name === tableName, - })) - ); - - // 정확한 테이블명으로 다시 시도 - if (exactTable && exactTable.table_name !== tableName) { - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}` - ); - try { - const correctColumns = await connector.getColumns( - exactTable.table_name - ); - console.warn( - `[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, - correctColumns - ); - } catch (correctError) { - console.error( - `[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, - correctError - ); - } - } - } catch (tableError) { - console.error( - `[BatchExternalDbService] 테이블 목록 조회 실패:`, - tableError - ); - } - } + // BatchColumnInfo 형식으로 변환 + const batchColumns: ColumnInfo[] = columns.map((col) => ({ + column_name: col.column_name, + data_type: col.data_type, + is_nullable: col.is_nullable, + column_default: col.column_default, + })); return { success: true, - data: standardizedColumns, - message: "컬럼 정보를 조회했습니다.", + data: batchColumns, + message: `${batchColumns.length}개의 컬럼을 조회했습니다.`, }; } catch (error) { - console.error( - "[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", - error - ); - console.error( - "[BatchExternalDbService] 오류 스택:", - error instanceof Error ? error.stack : "No stack trace" - ); + console.error("컬럼 목록 조회 실패:", error); return { success: false, - message: "컬럼 정보 조회 중 오류가 발생했습니다.", + message: "컬럼 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 데이터 조회 + * 연결 정보 조회 (내부 메서드) + */ + private static async getConnectionById(id: number) { + const connections = await query( + `SELECT * FROM external_db_connections WHERE id = $1`, + [id] + ); + + if (connections.length === 0) { + return null; + } + + const connection = connections[0]; + + // 비밀번호 복호화 + if (connection.password) { + try { + const passwordEncryption = new PasswordEncryption(); + connection.password = passwordEncryption.decrypt(connection.password); + } catch (error) { + console.error("비밀번호 복호화 실패:", error); + // 복호화 실패 시 원본 사용 (또는 에러 처리) + } + } + + return connection; + } + + /** + * REST API 데이터 미리보기 + */ + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + // 👇 body 파라미터 추가 + body?: string + ): Promise> { + try { + // REST API 커넥터 생성 + const connector = new RestApiConnector({ + baseUrl: apiUrl, + apiKey: apiKey, + timeout: 10000, // 미리보기는 짧은 타임아웃 + }); + + // 파라미터 적용 + let finalEndpoint = endpoint; + if ( + paramInfo && + paramInfo.paramName && + paramInfo.paramValue && + paramInfo.paramSource === "static" + ) { + if (paramInfo.paramType === "url") { + finalEndpoint = endpoint.replace( + `{${paramInfo.paramName}}`, + paramInfo.paramValue + ); + } else if (paramInfo.paramType === "query") { + const separator = endpoint.includes("?") ? "&" : "?"; + finalEndpoint = `${endpoint}${separator}${paramInfo.paramName}=${paramInfo.paramValue}`; + } + } + + // JSON body 파싱 + let requestData; + if (body) { + try { + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + + // 데이터 조회 (직접 RestApiConnector 메서드 호출) + // 타입 단언을 사용하여 private/protected 메서드 우회 또는 인터페이스 확장 필요 + // 여기서는 executeRequest가 public이라고 가정 + const result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData + ); + + return { + success: true, + data: result.data || result, // 데이터가 없으면 전체 결과 반환 + message: "데이터 미리보기 성공", + }; + } catch (error) { + return { + success: false, + message: "데이터 미리보기 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + } + + /** + * 외부 DB 테이블 데이터 조회 */ static async getDataFromTable( connectionId: number, - tableName: string, - limit: number = 100 + tableName: string ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT * FROM ${tableName} LIMIT ${limit}`; - } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT * FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 데이터 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에서 특정 컬럼들만 조회 + * 외부 DB 테이블 데이터 조회 (컬럼 지정) */ static async getDataFromTableWithColumns( connectionId: number, tableName: string, - columns: string[], - limit: number = 100 + columns: string[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(", ")}]` - ); - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); - // 데이터 조회 (DB 타입에 따라 쿼리 구문 변경) - let query: string; - const dbType = connection.db_type?.toLowerCase() || "postgresql"; - const columnList = columns.join(", "); + // 연결 + await connector.connect(); - if (dbType === "oracle") { - query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`; - } else { - query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`; - } + // 컬럼 목록 쿼리 구성 + const columnString = columns.join(", "); - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - const result = await connector.executeQuery(query); - - console.log( - `[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드` + // 데이터 조회 (기본 100건) + const result = await connector.executeQuery( + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); + // 연결 종료 + await connector.disconnect(); + return { success: true, data: result.rows, + message: `${result.rows.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("테이블 데이터 조회 실패:", error); return { success: false, - message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.", + message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 외부 DB 테이블에 데이터 삽입 + * 테이블에 데이터 삽입 */ static async insertDataToTable( connectionId: number, @@ -688,147 +411,79 @@ export class BatchExternalDbService { data: any[] ): Promise> { try { - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드` - ); - - if (!data || data.length === 0) { - return { - success: true, - data: { successCount: 0, failedCount: 0 }, - }; - } - - // 외부 DB 연결 정보 조회 - const connection = await queryOne( - `SELECT * FROM external_db_connections WHERE id = $1`, - [connectionId] - ); - + // 연결 정보 조회 + const connection = await this.getConnectionById(connectionId); if (!connection) { - return { - success: false, - message: "외부 DB 연결을 찾을 수 없습니다.", - }; + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } - // 패스워드 복호화 - const decryptedPassword = PasswordEncryption.decrypt(connection.password); - - // DB 연결 설정 - const config = { - host: connection.host, - port: connection.port, - user: connection.username, - password: decryptedPassword, - database: connection.database_name, - }; - - // DB 커넥터 생성 + // 커넥터 생성 const connector = await DatabaseConnectorFactory.createConnector( - connection.db_type || "postgresql", - config, + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + }, connectionId ); + // 연결 + await connector.connect(); + let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) - for (const record of data) { - try { - const columns = Object.keys(record); - const values = Object.values(record); + // 트랜잭션 시작 (지원하는 경우) + // await connector.beginTransaction(); - // 값들을 SQL 문자열로 변환 (타입별 처리) - const formattedValues = values - .map((value) => { - if (value === null || value === undefined) { - return "NULL"; - } else if (value instanceof Date) { - // Date 객체를 MySQL/MariaDB 형식으로 변환 - return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`; - } else if (typeof value === "string") { - // 문자열이 날짜 형식인지 확인 - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - // JavaScript Date 문자열을 MySQL 형식으로 변환 - const date = new Date(value); - return `'${date.toISOString().slice(0, 19).replace("T", " ")}'`; - } else { - return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프 - } - } else if (typeof value === "number") { - return String(value); - } else if (typeof value === "boolean") { - return value ? "1" : "0"; - } else { - // 기타 객체는 문자열로 변환 - return `'${String(value).replace(/'/g, "''")}'`; - } - }) - .join(", "); + try { + // 각 레코드를 개별적으로 삽입 + for (const record of data) { + try { + // 쿼리 빌더 사용 (간단한 구현) + const columns = Object.keys(record); + const values = Object.values(record); + const placeholders = values + .map((_, i) => (connection.db_type === "postgresql" ? `$${i + 1}` : "?")) + .join(", "); - // Primary Key 컬럼 추정 - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; + const query = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - - let query: string; - const dbType = connection.db_type?.toLowerCase() || "mysql"; - - if (dbType === "mysql" || dbType === "mariadb") { - // MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용 - if (updateColumns.length > 0) { - const updateSet = updateColumns - .map((col) => `${col} = VALUES(${col})`) - .join(", "); - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues}) - ON DUPLICATE KEY UPDATE ${updateSet}`; - } else { - // Primary Key만 있는 경우 IGNORE 사용 - query = `INSERT IGNORE INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; - } - } else { - // 다른 DB는 기본 INSERT 사용 - query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${formattedValues})`; + // 파라미터 매핑 (PostgreSQL은 $1, $2..., MySQL은 ?) + await connector.executeQuery(query, values); + successCount++; + } catch (insertError) { + console.error("레코드 삽입 실패:", insertError); + failedCount++; } - - console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`); - console.log(`[BatchExternalDbService] 삽입할 데이터:`, record); - - await connector.executeQuery(query); - successCount++; - } catch (error) { - console.error(`외부 DB 레코드 UPSERT 실패:`, error); - failedCount++; } - } - console.log( - `[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); + // 트랜잭션 커밋 + // await connector.commit(); + } catch (txError) { + // 트랜잭션 롤백 + // await connector.rollback(); + throw txError; + } finally { + // 연결 종료 + await connector.disconnect(); + } return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, - error - ); + console.error("데이터 삽입 실패:", error); return { success: false, - message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.", + message: "데이터 삽입 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -848,7 +503,9 @@ export class BatchExternalDbService { paramType?: "url" | "query", paramName?: string, paramValue?: string, - paramSource?: "static" | "dynamic" + paramSource?: "static" | "dynamic", + // 👇 body 파라미터 추가 + body?: string ): Promise> { try { console.log( @@ -895,47 +552,49 @@ export class BatchExternalDbService { ); } + // 👇 Body 파싱 (POST/PUT 요청 시) + let requestData; + if (body && (method === 'POST' || method === 'PUT')) { + try { + // 템플릿 변수가 있을 수 있으므로 여기서는 원본 문자열을 사용하거나 + // 정적 값만 파싱. 여기서는 일단 정적 JSON으로 가정하고 파싱 시도. + // (BatchScheduler에서 템플릿 처리 후 전달하는 것이 이상적이나, + // 현재 구조상 여기서 파싱 시도하고 실패하면 문자열 그대로 전송) + requestData = JSON.parse(body); + } catch (e) { + console.warn("JSON 파싱 실패, 원본 문자열 전송"); + requestData = body; + } + } + // 데이터 조회 (REST API는 executeRequest 사용) let result; if ((connector as any).executeRequest) { - result = await (connector as any).executeRequest(finalEndpoint, method); + // executeRequest(endpoint, method, data) + result = await (connector as any).executeRequest( + finalEndpoint, + method, + requestData // body 전달 + ); } else { + // Fallback (GET only) result = await connector.executeQuery(finalEndpoint); } - let data = result.rows; - // 컬럼 필터링 (지정된 컬럼만 추출) - if (columns && columns.length > 0) { - data = data.map((row: any) => { - const filteredRow: any = {}; - columns.forEach((col) => { - if (row.hasOwnProperty(col)) { - filteredRow[col] = row[col]; - } - }); - return filteredRow; - }); + let data = result.rows || result.data || result; + + // 👇 단일 객체 응답(토큰 등)인 경우 배열로 래핑하여 리스트처럼 처리 + if (!Array.isArray(data)) { + data = [data]; } - // 제한 개수 적용 - if (limit > 0) { - data = data.slice(0, limit); - } - - logger.info( - `[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드` - ); - logger.info(`[BatchExternalDbService] 조회된 데이터`, { data }); - return { success: true, data: data, + message: `${data.length}개의 데이터를 조회했습니다.`, }; } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, - error - ); + console.error("REST API 데이터 조회 실패:", error); return { success: false, message: "REST API 데이터 조회 중 오류가 발생했습니다.", @@ -1035,16 +694,15 @@ export class BatchExternalDbService { urlPathColumn && record[urlPathColumn] ) { - // /api/users → /api/users/user123 - finalEndpoint = `${endpoint}/${record[urlPathColumn]}`; + // endpoint 마지막에 ID 추가 (예: /api/users -> /api/users/123) + // 이미 /로 끝나는지 확인 + const separator = finalEndpoint.endsWith("/") ? "" : "/"; + finalEndpoint = `${finalEndpoint}${separator}${record[urlPathColumn]}`; + + console.log(`[BatchExternalDbService] 동적 엔드포인트: ${finalEndpoint}`); } - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData); - - // REST API는 executeRequest 사용 + // 데이터 전송 (REST API는 executeRequest 사용) if ((connector as any).executeRequest) { await (connector as any).executeRequest( finalEndpoint, @@ -1052,101 +710,32 @@ export class BatchExternalDbService { requestData ); } else { - await connector.executeQuery(finalEndpoint); + // Fallback + // @ts-ignore + await connector.httpClient.request({ + method: method, + url: finalEndpoint, + data: requestData + }); } + successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); + } catch (sendError) { + console.error("데이터 전송 실패:", sendError); failedCount++; } } - console.log( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - return { success: true, data: { successCount, failedCount }, + message: `${successCount}건 성공, ${failedCount}건 실패`, }; } catch (error) { - console.error( - `[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, - error - ); + console.error("데이터 전송 실패:", error); return { success: false, - message: `REST API 데이터 전송 실패: ${error}`, - data: { successCount: 0, failedCount: 0 }, - }; - } - } - - /** - * REST API로 데이터 전송 (기존 메서드) - */ - static async sendDataToRestApi( - apiUrl: string, - apiKey: string, - endpoint: string, - method: "POST" | "PUT" = "POST", - data: any[] - ): Promise> { - try { - console.log( - `[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드` - ); - - // REST API 커넥터 생성 - const connector = new RestApiConnector({ - baseUrl: apiUrl, - apiKey: apiKey, - timeout: 30000, - }); - - // 연결 테스트 - await connector.connect(); - - let successCount = 0; - let failedCount = 0; - - // 각 레코드를 개별적으로 전송 - for (const record of data) { - try { - console.log( - `[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}` - ); - console.log(`[BatchExternalDbService] 전송할 데이터:`, record); - - // REST API는 executeRequest 사용 - if ((connector as any).executeRequest) { - await (connector as any).executeRequest(endpoint, method, record); - } else { - await connector.executeQuery(endpoint); - } - successCount++; - } catch (error) { - console.error(`REST API 레코드 전송 실패:`, error); - failedCount++; - } - } - - console.log( - `[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); - - return { - success: true, - data: { successCount, failedCount }, - }; - } catch (error) { - console.error( - `[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, - error - ); - return { - success: false, - message: "REST API 데이터 전송 중 오류가 발생했습니다.", + message: "데이터 전송 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 77863904..a8f755c3 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -1,258 +1,114 @@ -// 배치 스케줄러 서비스 -// 작성일: 2024-12-24 - -import * as cron from "node-cron"; -import { query, queryOne } from "../database/db"; +import cron from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; export class BatchSchedulerService { private static scheduledTasks: Map = new Map(); - private static isInitialized = false; - private static executingBatches: Set = new Set(); // 실행 중인 배치 추적 /** - * 스케줄러 초기화 + * 모든 활성 배치의 스케줄링 초기화 */ - static async initialize() { + static async initializeScheduler() { try { - logger.info("배치 스케줄러 초기화 시작..."); + logger.info("배치 스케줄러 초기화 시작"); - // 기존 모든 스케줄 정리 (중복 방지) - this.clearAllSchedules(); + const batchConfigsResponse = await BatchService.getBatchConfigs({ + is_active: "Y", + }); - // 활성화된 배치 설정들을 로드하여 스케줄 등록 - await this.loadActiveBatchConfigs(); - - this.isInitialized = true; - logger.info("배치 스케줄러 초기화 완료"); - } catch (error) { - logger.error("배치 스케줄러 초기화 실패:", error); - throw error; - } - } - - /** - * 모든 스케줄 정리 - */ - private static clearAllSchedules() { - logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`); - - for (const [id, task] of this.scheduledTasks) { - try { - task.stop(); - task.destroy(); - logger.info(`스케줄 정리 완료: ID ${id}`); - } catch (error) { - logger.error(`스케줄 정리 실패: ID ${id}`, error); - } - } - - this.scheduledTasks.clear(); - this.isInitialized = false; - logger.info("모든 스케줄 정리 완료"); - } - - /** - * 활성화된 배치 설정들을 로드하여 스케줄 등록 - */ - private static async loadActiveBatchConfigs() { - try { - const activeConfigs = await query( - `SELECT - bc.*, - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order, - 'from_api_url', bm.from_api_url, - 'from_api_key', bm.from_api_key, - 'from_api_method', bm.from_api_method, - 'from_api_param_type', bm.from_api_param_type, - 'from_api_param_name', bm.from_api_param_name, - 'from_api_param_value', bm.from_api_param_value, - 'from_api_param_source', bm.from_api_param_source, - 'to_api_url', bm.to_api_url, - 'to_api_key', bm.to_api_key, - 'to_api_method', bm.to_api_method, - 'to_api_body', bm.to_api_body - ) - ) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.is_active = 'Y' - GROUP BY bc.id`, - [] - ); - - logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`); - - for (const config of activeConfigs) { - await this.scheduleBatchConfig(config); - } - } catch (error) { - logger.error("활성화된 배치 설정 로드 실패:", error); - throw error; - } - } - - /** - * 배치 설정을 스케줄에 등록 - */ - static async scheduleBatchConfig(config: any) { - try { - const { id, batch_name, cron_schedule } = config; - - // 기존 스케줄이 있다면 제거 - if (this.scheduledTasks.has(id)) { - this.scheduledTasks.get(id)?.stop(); - this.scheduledTasks.delete(id); - } - - // cron 스케줄 유효성 검사 - if (!cron.validate(cron_schedule)) { - logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`); + if (!batchConfigsResponse.success || !batchConfigsResponse.data) { + logger.warn("스케줄링할 활성 배치 설정이 없습니다."); return; } - // 새로운 스케줄 등록 - const task = cron.schedule(cron_schedule, async () => { - // 중복 실행 방지 체크 - if (this.executingBatches.has(id)) { - logger.warn( - `⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})` - ); - return; - } + const batchConfigs = batchConfigsResponse.data; + logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`); - logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); + for (const config of batchConfigs) { + await this.scheduleBatch(config); + } - // 실행 중 플래그 설정 - this.executingBatches.add(id); + logger.info("배치 스케줄러 초기화 완료"); + } catch (error) { + logger.error("배치 스케줄러 초기화 중 오류 발생:", error); + } + } - try { - await this.executeBatchConfig(config); - } finally { - // 실행 완료 후 플래그 제거 - this.executingBatches.delete(id); - } + /** + * 개별 배치 작업 스케줄링 + */ + static async scheduleBatch(config: any) { + try { + // 기존 스케줄이 있으면 제거 + if (this.scheduledTasks.has(config.id)) { + this.scheduledTasks.get(config.id)?.stop(); + this.scheduledTasks.delete(config.id); + } + + if (config.is_active !== "Y") { + logger.info( + `배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})` + ); + return; + } + + if (!cron.validate(config.cron_schedule)) { + logger.error( + `유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})` + ); + return; + } + + logger.info( + `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` + ); + + const task = cron.schedule(config.cron_schedule, async () => { + logger.info( + `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` + ); + await this.executeBatchConfig(config); }); - // 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) - task.start(); - - this.scheduledTasks.set(id, task); - logger.info( - `배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨` - ); + this.scheduledTasks.set(config.id, task); } catch (error) { - logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); + logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error); } } /** - * 배치 설정 스케줄 제거 - */ - static async unscheduleBatchConfig(batchConfigId: number) { - try { - if (this.scheduledTasks.has(batchConfigId)) { - this.scheduledTasks.get(batchConfigId)?.stop(); - this.scheduledTasks.delete(batchConfigId); - logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`); - } - } catch (error) { - logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error); - } - } - - /** - * 배치 설정 업데이트 시 스케줄 재등록 + * 배치 스케줄 업데이트 (설정 변경 시 호출) */ static async updateBatchSchedule( configId: number, executeImmediately: boolean = true ) { try { - // 기존 스케줄 제거 - await this.unscheduleBatchConfig(configId); - - // 업데이트된 배치 설정 조회 - const configResult = await query( - `SELECT - bc.*, - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order, - 'from_api_url', bm.from_api_url, - 'from_api_key', bm.from_api_key, - 'from_api_method', bm.from_api_method, - 'from_api_param_type', bm.from_api_param_type, - 'from_api_param_name', bm.from_api_param_name, - 'from_api_param_value', bm.from_api_param_value, - 'from_api_param_source', bm.from_api_param_source, - 'to_api_url', bm.to_api_url, - 'to_api_key', bm.to_api_key, - 'to_api_method', bm.to_api_method, - 'to_api_body', bm.to_api_body - ) - ) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1 - GROUP BY bc.id`, - [configId] - ); - - const config = configResult[0] || null; - - if (!config) { - logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`); + const result = await BatchService.getBatchConfigById(configId); + if (!result.success || !result.data) { + // 설정이 없으면 스케줄 제거 + if (this.scheduledTasks.has(configId)) { + this.scheduledTasks.get(configId)?.stop(); + this.scheduledTasks.delete(configId); + } return; } - // 활성화된 배치만 다시 스케줄 등록 - if (config.is_active === "Y") { - await this.scheduleBatchConfig(config); - logger.info( - `배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})` - ); + const config = result.data; - // 활성화 시 즉시 실행 (옵션) - if (executeImmediately) { - logger.info( - `🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})` - ); - await this.executeBatchConfig(config); - } - } else { - logger.info( - `비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})` + // 스케줄 재등록 + await this.scheduleBatch(config); + + // 즉시 실행 옵션이 있으면 실행 + /* + if (executeImmediately && config.is_active === "Y") { + logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`); + this.executeBatchConfig(config).catch((err) => + logger.error(`즉시 실행 중 오류 발생:`, err) ); } + */ } catch (error) { logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); } @@ -272,6 +128,7 @@ export class BatchSchedulerService { const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ batch_config_id: config.id, + company_code: config.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, @@ -313,21 +170,20 @@ export class BatchSchedulerService { // 성공 결과 반환 return result; } catch (error) { - logger.error(`배치 실행 실패: ${config.batch_name}`, error); + logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error); // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "FAILED", + execution_status: "FAILURE", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), error_message: error instanceof Error ? error.message : "알 수 없는 오류", - error_details: error instanceof Error ? error.stack : String(error), }); } - // 실패 시에도 결과 반환 + // 실패 결과 반환 return { totalRecords: 0, successRecords: 0, @@ -379,6 +235,8 @@ export class BatchSchedulerService { const { BatchExternalDbService } = await import( "./batchExternalDbService" ); + + // 👇 Body 파라미터 추가 (POST 요청 시) const apiResult = await BatchExternalDbService.getDataFromRestApi( firstMapping.from_api_url!, firstMapping.from_api_key!, @@ -394,7 +252,9 @@ export class BatchSchedulerService { firstMapping.from_api_param_type, firstMapping.from_api_param_name, firstMapping.from_api_param_value, - firstMapping.from_api_param_source + firstMapping.from_api_param_source, + // 👇 Body 전달 (FROM - REST API - POST 요청) + firstMapping.from_api_body ); if (apiResult.success && apiResult.data) { @@ -416,6 +276,17 @@ export class BatchSchedulerService { totalRecords += fromData.length; // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 + // 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기 + const getValueByPath = (obj: any, path: string) => { + if (!path) return undefined; + // path가 'response.access_token' 처럼 점을 포함하는 경우 + if (path.includes(".")) { + return path.split(".").reduce((acc, part) => acc && acc[part], obj); + } + // 단순 키인 경우 + return obj[path]; + }; + const mappedData = fromData.map((row) => { const mappedRow: any = {}; for (const mapping of mappings) { @@ -428,10 +299,25 @@ export class BatchSchedulerService { mappedRow[mapping.from_column_name] = row[mapping.from_column_name]; } else { - // 기존 로직: to_column_name을 키로 사용 - mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; + // REST API -> DB (POST 요청 포함) 또는 DB -> DB + // row[mapping.from_column_name] 대신 getValueByPath 사용 + const value = getValueByPath(row, mapping.from_column_name); + + mappedRow[mapping.to_column_name] = value; } } + + // 멀티테넌시: TO가 DB일 때 company_code 자동 주입 + // - 배치 설정에 company_code가 있고 + // - 매핑에서 company_code를 명시적으로 다루지 않은 경우만 + if ( + firstMapping.to_connection_type !== "restapi" && + config.company_code && + mappedRow.company_code === undefined + ) { + mappedRow.company_code = config.company_code; + } + return mappedRow; }); @@ -482,22 +368,12 @@ export class BatchSchedulerService { ); } } else { - // 기존 REST API 전송 (REST API → DB 배치) - const apiResult = await BatchExternalDbService.sendDataToRestApi( - firstMapping.to_api_url!, - firstMapping.to_api_key!, - firstMapping.to_table_name, - (firstMapping.to_api_method as "POST" | "PUT") || "POST", - mappedData + // 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST) + // 지원하지 않음 + logger.warn( + "REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다." ); - - if (apiResult.success && apiResult.data) { - insertResult = apiResult.data; - } else { - throw new Error( - `REST API 데이터 전송 실패: ${apiResult.message}` - ); - } + insertResult = { successCount: 0, failedCount: 0 }; } } else { // DB에 데이터 삽입 @@ -511,167 +387,13 @@ export class BatchSchedulerService { successRecords += insertResult.successCount; failedRecords += insertResult.failedCount; - - logger.info( - `테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); } catch (error) { - logger.error(`테이블 처리 실패: ${tableKey}`, error); - failedRecords += 1; + logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error); + // 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가? + // 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경) } } return { totalRecords, successRecords, failedRecords }; } - - /** - * 배치 매핑 처리 (기존 메서드 - 사용 안 함) - */ - private static async processBatchMappings(config: any) { - const { batch_mappings } = config; - let totalRecords = 0; - let successRecords = 0; - let failedRecords = 0; - - if (!batch_mappings || batch_mappings.length === 0) { - logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`); - return { totalRecords, successRecords, failedRecords }; - } - - for (const mapping of batch_mappings) { - try { - logger.info( - `매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}` - ); - - // FROM 테이블에서 데이터 조회 - const fromData = await this.getDataFromSource(mapping); - totalRecords += fromData.length; - - // TO 테이블에 데이터 삽입 - const insertResult = await this.insertDataToTarget(mapping, fromData); - successRecords += insertResult.successCount; - failedRecords += insertResult.failedCount; - - logger.info( - `매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패` - ); - } catch (error) { - logger.error( - `매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, - error - ); - failedRecords += 1; - } - } - - return { totalRecords, successRecords, failedRecords }; - } - - /** - * FROM 테이블에서 데이터 조회 - */ - private static async getDataFromSource(mapping: any) { - try { - if (mapping.from_connection_type === "internal") { - // 내부 DB에서 조회 - const result = await query( - `SELECT * FROM ${mapping.from_table_name}`, - [] - ); - return result; - } else { - // 외부 DB에서 조회 (구현 필요) - logger.warn("외부 DB 조회는 아직 구현되지 않았습니다."); - return []; - } - } catch (error) { - logger.error( - `FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, - error - ); - throw error; - } - } - - /** - * TO 테이블에 데이터 삽입 - */ - private static async insertDataToTarget(mapping: any, data: any[]) { - let successCount = 0; - let failedCount = 0; - - try { - if (mapping.to_connection_type === "internal") { - // 내부 DB에 삽입 - for (const record of data) { - try { - // 매핑된 컬럼만 추출 - const mappedData = this.mapColumns(record, mapping); - - const columns = Object.keys(mappedData); - const values = Object.values(mappedData); - const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); - - await query( - `INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`, - values - ); - successCount++; - } catch (error) { - logger.error(`레코드 삽입 실패:`, error); - failedCount++; - } - } - } else { - // 외부 DB에 삽입 (구현 필요) - logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다."); - failedCount = data.length; - } - } catch (error) { - logger.error( - `TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, - error - ); - throw error; - } - - return { successCount, failedCount }; - } - - /** - * 컬럼 매핑 - */ - private static mapColumns(record: any, mapping: any) { - const mappedData: any = {}; - - // 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요) - mappedData[mapping.to_column_name] = record[mapping.from_column_name]; - - return mappedData; - } - - /** - * 모든 스케줄 중지 - */ - static async stopAllSchedules() { - try { - for (const [id, task] of this.scheduledTasks) { - task.stop(); - logger.info(`배치 스케줄 중지: ID ${id}`); - } - this.scheduledTasks.clear(); - this.isInitialized = false; - logger.info("모든 배치 스케줄이 중지되었습니다."); - } catch (error) { - logger.error("배치 스케줄 중지 실패:", error); - } - } - - /** - * 현재 등록된 스케줄 목록 조회 - */ - static getScheduledTasks() { - return Array.from(this.scheduledTasks.keys()); - } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 247b1ab8..41f20964 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -65,62 +65,43 @@ export class BatchService { const limit = filter.limit || 10; const offset = (page - 1) * limit; - // 배치 설정 조회 (매핑 포함 - 서브쿼리 사용) - const batchConfigs = await query( - `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'mapping_order', bm.mapping_order - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings + // 전체 카운트 조회 + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM batch_configs bc ${whereClause}`, + values + ); + const total = parseInt(countResult[0].count); + const totalPages = Math.ceil(total / limit); + + // 목록 조회 + const configs = await query( + `SELECT bc.* FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id ${whereClause} - GROUP BY bc.id - ORDER BY bc.is_active DESC, bc.batch_name ASC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + ORDER BY bc.created_date DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...values, limit, offset] ); - // 전체 개수 조회 - const countResult = await queryOne<{ count: string }>( - `SELECT COUNT(DISTINCT bc.id) as count - FROM batch_configs bc - ${whereClause}`, - values - ); - - const total = parseInt(countResult?.count || "0"); + // 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리) + // 하지만 목록에서도 간단한 정보는 필요할 수 있음 return { success: true, - data: batchConfigs as BatchConfig[], + data: configs as BatchConfig[], pagination: { page, limit, total, - totalPages: Math.ceil(total / limit), + totalPages, }, + message: `${configs.length}개의 배치 설정을 조회했습니다.`, }; } catch (error) { console.error("배치 설정 목록 조회 오류:", error); return { success: false, + data: [], message: "배치 설정 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -128,70 +109,56 @@ export class BatchService { } /** - * 특정 배치 설정 조회 (회사별) + * 특정 배치 설정 조회 (별칭) + */ + static async getBatchConfig(id: number): Promise { + const result = await this.getBatchConfigById(id); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * 배치 설정 상세 조회 */ static async getBatchConfigById( - id: number, - userCompanyCode?: string + id: number ): Promise> { try { - let query = `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, - bc.is_active, bc.company_code, bc.created_date, bc.created_by, - bc.updated_date, bc.updated_by, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id, - 'from_connection_type', bm.from_connection_type, - 'from_connection_id', bm.from_connection_id, - 'from_table_name', bm.from_table_name, - 'from_column_name', bm.from_column_name, - 'from_column_type', bm.from_column_type, - 'to_connection_type', bm.to_connection_type, - 'to_connection_id', bm.to_connection_id, - 'to_table_name', bm.to_table_name, - 'to_column_name', bm.to_column_name, - 'to_column_type', bm.to_column_type, - 'mapping_order', bm.mapping_order - ) - ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1`; + // 배치 설정 조회 + const config = await queryOne( + `SELECT * FROM batch_configs WHERE id = $1`, + [id] + ); - const params: any[] = [id]; - let paramIndex = 2; - - // 회사별 필터링 (최고 관리자가 아닌 경우) - if (userCompanyCode && userCompanyCode !== "*") { - query += ` AND bc.company_code = $${paramIndex}`; - params.push(userCompanyCode); - } - - query += ` GROUP BY bc.id`; - - const batchConfig = await queryOne(query, params); - - if (!batchConfig) { + if (!config) { return { success: false, - message: "배치 설정을 찾을 수 없거나 권한이 없습니다.", + message: "배치 설정을 찾을 수 없습니다.", }; } + // 매핑 정보 조회 + const mappings = await query( + `SELECT * FROM batch_mappings WHERE batch_config_id = $1 ORDER BY mapping_order ASC`, + [id] + ); + + const batchConfig: BatchConfig = { + ...config, + batch_mappings: mappings, + } as BatchConfig; + return { success: true, - data: batchConfig as BatchConfig, + data: batchConfig, }; } catch (error) { - console.error("배치 설정 조회 오류:", error); + console.error("배치 설정 상세 조회 오류:", error); return { success: false, - message: "배치 설정 조회에 실패했습니다.", + message: "배치 설정 상세 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } @@ -210,10 +177,17 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) RETURNING *`, - [data.batchName, data.description, data.cronSchedule, userId, userId] + [ + data.batchName, + data.description, + data.cronSchedule, + data.isActive || "Y", + data.companyCode, + userId, + ] ); const batchConfig = batchConfigResult.rows[0]; @@ -224,39 +198,41 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ - batchConfig.id, - mapping.from_connection_type, - mapping.from_connection_id, - mapping.from_table_name, - mapping.from_column_name, - mapping.from_column_type, - mapping.from_api_url, - mapping.from_api_key, - mapping.from_api_method, - mapping.from_api_param_type, - mapping.from_api_param_name, - mapping.from_api_param_value, - mapping.from_api_param_source, - mapping.to_connection_type, - mapping.to_connection_id, - mapping.to_table_name, - mapping.to_column_name, - mapping.to_column_type, - mapping.to_api_url, - mapping.to_api_key, - mapping.to_api_method, - mapping.to_api_body, - mapping.mapping_order || index + 1, - userId, - ] + batchConfig.id, + data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 + mapping.from_connection_type, + mapping.from_connection_id, + mapping.from_table_name, + mapping.from_column_name, + mapping.from_column_type, + mapping.from_api_url, + mapping.from_api_key, + mapping.from_api_method, + mapping.from_api_param_type, + mapping.from_api_param_name, + mapping.from_api_param_value, + mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body + mapping.to_connection_type, + mapping.to_connection_id, + mapping.to_table_name, + mapping.to_column_name, + mapping.to_column_type, + mapping.to_api_url, + mapping.to_api_key, + mapping.to_api_method, + mapping.to_api_body, + mapping.mapping_order || index + 1, + userId, + ] ); mappings.push(mappingResult.rows[0]); } @@ -292,35 +268,22 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return existing; + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." + ); } + const existingConfig = existingResult.data; - const existingConfig = await queryOne( - `SELECT bc.*, - COALESCE( - json_agg( - json_build_object( - 'id', bm.id, - 'batch_config_id', bm.batch_config_id - ) - ) FILTER (WHERE bm.id IS NOT NULL), - '[]' - ) as batch_mappings - FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id - WHERE bc.id = $1 - GROUP BY bc.id`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; + // 권한 체크 (회사 코드가 다르면 수정 불가) + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("수정 권한이 없습니다."); } // 트랜잭션으로 업데이트 @@ -373,15 +336,16 @@ export class BatchService { const mapping = data.mappings[index]; const mappingResult = await client.query( `INSERT INTO batch_mappings - (batch_config_id, from_connection_type, from_connection_id, from_table_name, from_column_name, + (batch_config_id, company_code, from_connection_type, from_connection_id, from_table_name, from_column_name, from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, - from_api_param_name, from_api_param_value, from_api_param_source, + from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) RETURNING *`, [ id, + existingConfig.company_code, // 기존 설정의 company_code 유지 mapping.from_connection_type, mapping.from_connection_id, mapping.from_table_name, @@ -394,6 +358,7 @@ export class BatchService { mapping.from_api_param_name, mapping.from_api_param_value, mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body mapping.to_connection_type, mapping.to_connection_id, mapping.to_table_name, @@ -446,38 +411,26 @@ export class BatchService { userCompanyCode?: string ): Promise> { try { - // 기존 배치 설정 확인 (회사 권한 체크 포함) - const existing = await this.getBatchConfigById(id, userCompanyCode); - if (!existing.success) { - return { - success: false, - message: existing.message, - }; - } - - const existingConfig = await queryOne( - `SELECT * FROM batch_configs WHERE id = $1`, - [id] - ); - - if (!existingConfig) { - return { - success: false, - message: "배치 설정을 찾을 수 없습니다.", - }; - } - - // 트랜잭션으로 삭제 - await transaction(async (client) => { - // 배치 매핑 먼저 삭제 (외래키 제약) - await client.query( - `DELETE FROM batch_mappings WHERE batch_config_id = $1`, - [id] + // 기존 설정 확인 + const existingResult = await this.getBatchConfigById(id); + if (!existingResult.success || !existingResult.data) { + throw new Error( + existingResult.message || "배치 설정을 찾을 수 없습니다." ); + } + const existingConfig = existingResult.data; - // 배치 설정 삭제 - await client.query(`DELETE FROM batch_configs WHERE id = $1`, [id]); - }); + // 권한 체크 + if ( + userCompanyCode && + userCompanyCode !== "*" && + existingConfig.company_code !== userCompanyCode + ) { + throw new Error("삭제 권한이 없습니다."); + } + + // 물리 삭제 (CASCADE 설정에 따라 매핑도 삭제됨) + await query(`DELETE FROM batch_configs WHERE id = $1`, [id]); return { success: true, @@ -494,93 +447,51 @@ export class BatchService { } /** - * 사용 가능한 커넥션 목록 조회 + * DB 연결 정보 조회 */ - static async getAvailableConnections(): Promise< - ApiResponse - > { + static async getConnections(): Promise> { try { - const connections: ConnectionInfo[] = []; - - // 내부 DB 추가 - connections.push({ - type: "internal", - name: "Internal Database", - db_type: "postgresql", - }); - - // 외부 DB 연결 조회 - const externalConnections = - await BatchExternalDbService.getAvailableConnections(); - - if (externalConnections.success && externalConnections.data) { - externalConnections.data.forEach((conn) => { - connections.push({ - type: "external", - id: conn.id, - name: conn.name, - db_type: conn.db_type, - }); - }); - } - - return { - success: true, - data: connections, - }; + // BatchExternalDbService 사용 + const result = await BatchExternalDbService.getAvailableConnections(); + return result; } catch (error) { - console.error("커넥션 목록 조회 오류:", error); + console.error("DB 연결 목록 조회 오류:", error); return { success: false, - message: "커넥션 목록 조회에 실패했습니다.", + data: [], + message: "DB 연결 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 특정 커넥션의 테이블 목록 조회 + * 테이블 목록 조회 */ - static async getTablesFromConnection( + static async getTables( connectionType: "internal" | "external", connectionId?: number ): Promise> { try { - let tables: TableInfo[] = []; - if (connectionType === "internal") { // 내부 DB 테이블 조회 - const result = await query<{ table_name: string }>( - `SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name` - ); - tables = result.map((row) => ({ - table_name: row.table_name, - columns: [], - })); - } else if (connectionType === "external" && connectionId) { + const tables = await DbConnectionManager.getInternalTables(); + return { + success: true, + data: tables, + message: `${tables.length}개의 테이블을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 테이블 조회 - const tablesResult = - await BatchExternalDbService.getTablesFromConnection( - connectionType, - connectionId - ); - if (tablesResult.success && tablesResult.data) { - tables = tablesResult.data; - } + return await BatchExternalDbService.getTables(connectionId); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: tables, - }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { success: false, + data: [], message: "테이블 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; @@ -588,185 +499,133 @@ export class BatchService { } /** - * 특정 테이블의 컬럼 정보 조회 + * 컬럼 목록 조회 */ - static async getTableColumns( + static async getColumns( + tableName: string, connectionType: "internal" | "external", - connectionId: number | undefined, - tableName: string + connectionId?: number ): Promise> { try { - console.log(`[BatchService] getTableColumns 호출:`, { - connectionType, - connectionId, - tableName, - }); - - let columns: ColumnInfo[] = []; - if (connectionType === "internal") { // 내부 DB 컬럼 조회 - console.log(`[BatchService] 내부 DB 컬럼 조회 시작: ${tableName}`); - - const result = await query<{ - column_name: string; - data_type: string; - is_nullable: string; - column_default: string | null; - }>( - `SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position`, - [tableName] - ); - - console.log(`[BatchService] 내부 DB 컬럼 조회 결과:`, result); - - columns = result.map((row) => ({ - column_name: row.column_name, - data_type: row.data_type, - is_nullable: row.is_nullable, - column_default: row.column_default, - })); - } else if (connectionType === "external" && connectionId) { + const columns = await DbConnectionManager.getInternalColumns(tableName); + return { + success: true, + data: columns, + message: `${columns.length}개의 컬럼을 조회했습니다.`, + }; + } else if (connectionId) { // 외부 DB 컬럼 조회 - console.log( - `[BatchService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}` - ); - - const columnsResult = await BatchExternalDbService.getTableColumns( - connectionType, - connectionId, - tableName - ); - - console.log(`[BatchService] 외부 DB 컬럼 조회 결과:`, columnsResult); - - if (columnsResult.success && columnsResult.data) { - columns = columnsResult.data; - } - - console.log(`[BatchService] 외부 DB 컬럼:`, columns); + return await BatchExternalDbService.getColumns(connectionId, tableName); + } else { + throw new Error("외부 연결 ID가 필요합니다."); } - - return { - success: true, - data: columns, - }; } catch (error) { - console.error("컬럼 정보 조회 오류:", error); + console.error("컬럼 목록 조회 오류:", error); return { success: false, - message: "컬럼 정보 조회에 실패했습니다.", + data: [], + message: "컬럼 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }; } } /** - * 배치 실행 로그 생성 + * 데이터 미리보기 */ - static async createExecutionLog(data: { - batch_config_id: number; - execution_status: string; - start_time: Date; - total_records: number; - success_records: number; - failed_records: number; - }): Promise { + static async previewData( + tableName: string, + connectionType: "internal" | "external", + connectionId?: number + ): Promise> { try { - const executionLog = await queryOne( - `INSERT INTO batch_execution_logs - (batch_config_id, execution_status, start_time, total_records, success_records, failed_records) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [ - data.batch_config_id, - data.execution_status, - data.start_time, - data.total_records, - data.success_records, - data.failed_records, - ] - ); - - return executionLog; + if (connectionType === "internal") { + // 내부 DB 데이터 조회 + const data = await DbConnectionManager.getInternalData(tableName, 10); + return { + success: true, + data, + message: "데이터 미리보기 성공", + }; + } else if (connectionId) { + // 외부 DB 데이터 조회 + return await BatchExternalDbService.getDataFromTable( + connectionId, + tableName + ); + } else { + throw new Error("외부 연결 ID가 필요합니다."); + } } catch (error) { - console.error("배치 실행 로그 생성 오류:", error); - throw error; + console.error("데이터 미리보기 오류:", error); + return { + success: false, + data: [], + message: "데이터 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } /** - * 배치 실행 로그 업데이트 + * REST API 데이터 미리보기 */ - static async updateExecutionLog( - id: number, - data: { - execution_status?: string; - end_time?: Date; - duration_ms?: number; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string; - } - ): Promise { + static async previewRestApiData( + apiUrl: string, + apiKey: string, + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + paramInfo?: { + paramType: "url" | "query"; + paramName: string; + paramValue: string; + paramSource: "static" | "dynamic"; + }, + body?: string + ): Promise> { try { - // 동적 UPDATE 쿼리 생성 - const updateFields: string[] = []; - const values: any[] = []; - let paramIndex = 1; - - if (data.execution_status !== undefined) { - updateFields.push(`execution_status = $${paramIndex++}`); - values.push(data.execution_status); - } - if (data.end_time !== undefined) { - updateFields.push(`end_time = $${paramIndex++}`); - values.push(data.end_time); - } - if (data.duration_ms !== undefined) { - updateFields.push(`duration_ms = $${paramIndex++}`); - values.push(data.duration_ms); - } - if (data.total_records !== undefined) { - updateFields.push(`total_records = $${paramIndex++}`); - values.push(data.total_records); - } - if (data.success_records !== undefined) { - updateFields.push(`success_records = $${paramIndex++}`); - values.push(data.success_records); - } - if (data.failed_records !== undefined) { - updateFields.push(`failed_records = $${paramIndex++}`); - values.push(data.failed_records); - } - if (data.error_message !== undefined) { - updateFields.push(`error_message = $${paramIndex++}`); - values.push(data.error_message); - } - - if (updateFields.length > 0) { - await query( - `UPDATE batch_execution_logs - SET ${updateFields.join(", ")} - WHERE id = $${paramIndex}`, - [...values, id] - ); - } + return await BatchExternalDbService.previewRestApiData( + apiUrl, + apiKey, + endpoint, + method, + paramInfo, + body + ); } catch (error) { - console.error("배치 실행 로그 업데이트 오류:", error); - throw error; + console.error("REST API 미리보기 오류:", error); + return { + success: false, + message: "REST API 미리보기에 실패했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; } } + /** + * 배치 유효성 검사 + */ + static async validateBatch( + config: Partial + ): Promise { + const errors: string[] = []; + + if (!config.batchName) errors.push("배치 작업명이 필요합니다."); + if (!config.cronSchedule) errors.push("Cron 스케줄이 필요합니다."); + if (!config.mappings || config.mappings.length === 0) { + errors.push("최소 하나 이상의 매핑이 필요합니다."); + } + + // 추가 유효성 검사 로직... + + return { + isValid: errors.length === 0, + errors, + }; + } + /** * 테이블에서 데이터 조회 (연결 타입에 따라 내부/외부 DB 구분) */ @@ -824,42 +683,33 @@ export class BatchService { ): Promise { try { console.log( - `[BatchService] 테이블에서 특정 컬럼 데이터 조회: ${tableName} (${columns.join(", ")}) (${connectionType}${connectionId ? `:${connectionId}` : ""})` + `[BatchService] 테이블에서 컬럼 지정 데이터 조회: ${tableName} (${connectionType})` ); if (connectionType === "internal") { - // 내부 DB에서 특정 컬럼만 조회 (주의: SQL 인젝션 위험 - 실제 프로덕션에서는 테이블명/컬럼명 검증 필요) - const columnList = columns.join(", "); + // 내부 DB + const columnString = columns.join(", "); const result = await query( - `SELECT ${columnList} FROM ${tableName} LIMIT 100` - ); - console.log( - `[BatchService] 내부 DB 특정 컬럼 조회 결과: ${result.length}개 레코드` + `SELECT ${columnString} FROM ${tableName} LIMIT 100` ); return result; } else if (connectionType === "external" && connectionId) { - // 외부 DB에서 특정 컬럼만 조회 + // 외부 DB const result = await BatchExternalDbService.getDataFromTableWithColumns( connectionId, tableName, columns ); if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 특정 컬럼 조회 결과: ${result.data.length}개 레코드` - ); return result.data; } else { - console.error(`외부 DB 특정 컬럼 조회 실패: ${result.message}`); - return []; + throw new Error(result.message || "외부 DB 조회 실패"); } } else { - throw new Error( - `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` - ); + throw new Error("잘못된 연결 설정입니다."); } } catch (error) { - console.error(`테이블 특정 컬럼 조회 오류 (${tableName}):`, error); + console.error(`데이터 조회 오류 (${tableName}):`, error); throw error; } } @@ -893,140 +743,27 @@ export class BatchService { // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) for (const record of data) { try { - // 동적 UPSERT 쿼리 생성 (PostgreSQL ON CONFLICT 사용) const columns = Object.keys(record); - const values = Object.values(record).map((value) => { - // Date 객체를 ISO 문자열로 변환 (PostgreSQL이 자동으로 파싱) - if (value instanceof Date) { - return value.toISOString(); - } - // JavaScript Date 문자열을 Date 객체로 변환 후 ISO 문자열로 - if (typeof value === "string") { - const dateRegex = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/; - if (dateRegex.test(value)) { - return new Date(value).toISOString(); - } - // ISO 날짜 문자열 형식 체크 (2025-09-24T06:29:01.351Z) - const isoDateRegex = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/; - if (isoDateRegex.test(value)) { - return new Date(value).toISOString(); - } - } - return value; - }); - - // PostgreSQL 타입 캐스팅을 위한 placeholder 생성 - const placeholders = columns - .map((col, index) => { - // 날짜/시간 관련 컬럼명 패턴 체크 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `$${index + 1}::timestamp`; - } - return `$${index + 1}`; - }) + const values = Object.values(record); + const placeholders = values + .map((_, i) => `$${i + 1}`) .join(", "); - // Primary Key 컬럼 추정 (일반적으로 id 또는 첫 번째 컬럼) - const primaryKeyColumn = columns.includes("id") - ? "id" - : columns.includes("user_id") - ? "user_id" - : columns[0]; - - // UPDATE SET 절 생성 (Primary Key 제외) - const updateColumns = columns.filter( - (col) => col !== primaryKeyColumn - ); - const updateSet = updateColumns - .map((col) => `${col} = EXCLUDED.${col}`) - .join(", "); - - // 트랜잭션 내에서 처리하여 연결 관리 최적화 - const result = await transaction(async (client) => { - // 먼저 해당 레코드가 존재하는지 확인 - const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`; - const existsResult = await client.query(checkQuery, [ - record[primaryKeyColumn], - ]); - const exists = parseInt(existsResult.rows[0]?.count || "0") > 0; - - let operationResult = "no_change"; - - if (exists && updateSet) { - // 기존 레코드가 있으면 UPDATE (값이 다른 경우에만) - const whereConditions = updateColumns - .map((col, index) => { - // 날짜/시간 컬럼에 대한 타입 캐스팅 처리 - if ( - col.toLowerCase().includes("date") || - col.toLowerCase().includes("time") || - col.toLowerCase().includes("created") || - col.toLowerCase().includes("updated") || - col.toLowerCase().includes("reg") - ) { - return `${col} IS DISTINCT FROM $${index + 2}::timestamp`; - } - return `${col} IS DISTINCT FROM $${index + 2}`; - }) - .join(" OR "); - - const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, "")} - WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`; - - // 파라미터: [primaryKeyValue, ...updateValues] - const updateValues = [ - record[primaryKeyColumn], - ...updateColumns.map((col) => record[col]), - ]; - const updateResult = await client.query(query, updateValues); - - if (updateResult.rowCount && updateResult.rowCount > 0) { - console.log( - `[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "updated"; - } else { - console.log( - `[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - } else if (!exists) { - // 새 레코드 삽입 - const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; - await client.query(query, values); - console.log( - `[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "inserted"; - } else { - console.log( - `[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}` - ); - operationResult = "no_change"; - } - - return operationResult; - }); + const queryStr = `INSERT INTO ${tableName} (${columns.join( + ", " + )}) VALUES (${placeholders})`; + await query(queryStr, values); successCount++; - } catch (error) { - console.error(`레코드 UPSERT 실패:`, error); + } catch (insertError) { + console.error( + `내부 DB 데이터 삽입 실패 (${tableName}):`, + insertError + ); failedCount++; } } - console.log( - `[BatchService] 내부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개` - ); return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { // 외부 DB에 데이터 삽입 @@ -1035,84 +772,22 @@ export class BatchService { tableName, data ); + if (result.success && result.data) { - console.log( - `[BatchService] 외부 DB 데이터 삽입 완료: 성공 ${result.data.successCount}개, 실패 ${result.data.failedCount}개` - ); return result.data; } else { console.error(`외부 DB 데이터 삽입 실패: ${result.message}`); + // 실패 시 전체 실패로 간주하지 않고 0/전체 로 반환 return { successCount: 0, failedCount: data.length }; } } else { - console.log(`[BatchService] 연결 정보 디버그:`, { - connectionType, - connectionId, - }); throw new Error( `잘못된 연결 타입 또는 연결 ID: ${connectionType}, ${connectionId}` ); } } catch (error) { - console.error(`테이블 데이터 삽입 오류 (${tableName}):`, error); - throw error; + console.error(`데이터 삽입 오류 (${tableName}):`, error); + return { successCount: 0, failedCount: data ? data.length : 0 }; } } - - /** - * 배치 매핑 유효성 검사 - */ - private static async validateBatchMappings( - mappings: BatchMapping[] - ): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (!mappings || mappings.length === 0) { - errors.push("최소 하나 이상의 매핑이 필요합니다."); - return { isValid: false, errors, warnings }; - } - - // n:1 매핑 검사 (여러 FROM이 같은 TO로 매핑되는 것 방지) - const toMappings = new Map(); - - mappings.forEach((mapping, index) => { - const toKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`; - - if (toMappings.has(toKey)) { - errors.push( - `매핑 ${index + 1}: TO 컬럼 '${mapping.to_table_name}.${mapping.to_column_name}'에 중복 매핑이 있습니다. n:1 매핑은 허용되지 않습니다.` - ); - } else { - toMappings.set(toKey, index); - } - }); - - // 1:n 매핑 경고 (같은 FROM에서 여러 TO로 매핑) - const fromMappings = new Map(); - - mappings.forEach((mapping, index) => { - const fromKey = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}:${mapping.from_column_name}`; - - if (!fromMappings.has(fromKey)) { - fromMappings.set(fromKey, []); - } - fromMappings.get(fromKey)!.push(index); - }); - - fromMappings.forEach((indices, fromKey) => { - if (indices.length > 1) { - const [, , tableName, columnName] = fromKey.split(":"); - warnings.push( - `FROM 컬럼 '${tableName}.${columnName}'에서 ${indices.length}개의 TO 컬럼으로 매핑됩니다. (1:n 매핑)` - ); - } - }); - - return { - isValid: errors.length === 0, - errors, - warnings, - }; - } } diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 28eac869..36f3a7e2 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -1,4 +1,6 @@ import { Pool, QueryResult } from "pg"; +import axios, { AxiosResponse } from "axios"; +import https from "https"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { @@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService { let query = ` SELECT id, connection_name, description, base_url, endpoint_path, default_headers, + default_method, + -- DB 스키마의 컬럼명은 default_request_body 기준이고 + -- 코드에서는 default_body 필드로 사용하기 위해 alias 처리 + default_request_body AS default_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message @@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService { let query = ` SELECT id, connection_name, description, base_url, endpoint_path, default_headers, + default_method, + default_request_body AS default_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_date, created_by, updated_date, updated_by, last_test_date, last_test_result, last_test_message @@ -158,6 +166,9 @@ export class ExternalRestApiConnectionService { ? this.decryptSensitiveData(connection.auth_config) : null; + // 디버깅: 조회된 연결 정보 로깅 + logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`); + return { success: true, data: connection, @@ -194,9 +205,10 @@ export class ExternalRestApiConnectionService { const query = ` INSERT INTO external_rest_api_connections ( connection_name, description, base_url, endpoint_path, default_headers, + default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING * `; @@ -206,6 +218,8 @@ export class ExternalRestApiConnectionService { data.base_url, data.endpoint_path || null, JSON.stringify(data.default_headers || {}), + data.default_method || "GET", + data.default_body || null, data.auth_type, encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null, data.timeout || 30000, @@ -216,6 +230,15 @@ export class ExternalRestApiConnectionService { data.created_by || "system", ]; + // 디버깅: 저장하려는 데이터 로깅 + logger.info(`REST API 연결 생성 요청 데이터:`, { + connection_name: data.connection_name, + default_method: data.default_method, + endpoint_path: data.endpoint_path, + base_url: data.base_url, + default_body: data.default_body ? "있음" : "없음", + }); + const result: QueryResult = await pool.query(query, params); logger.info(`REST API 연결 생성 성공: ${data.connection_name}`); @@ -301,6 +324,20 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.default_method !== undefined) { + updateFields.push(`default_method = $${paramIndex}`); + params.push(data.default_method); + paramIndex++; + logger.info(`수정 요청 - default_method: ${data.default_method}`); + } + + if (data.default_body !== undefined) { + updateFields.push(`default_request_body = $${paramIndex}`); + params.push(data.default_body); // null이면 DB에서 NULL로 저장됨 + paramIndex++; + logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`); + } + if (data.auth_type !== undefined) { updateFields.push(`auth_type = $${paramIndex}`); params.push(data.auth_type); @@ -441,7 +478,8 @@ export class ExternalRestApiConnectionService { * REST API 연결 테스트 (테스트 요청 데이터 기반) */ static async testConnection( - testRequest: RestApiTestRequest + testRequest: RestApiTestRequest, + userCompanyCode?: string ): Promise { const startTime = Date.now(); @@ -450,7 +488,78 @@ export class ExternalRestApiConnectionService { const headers = { ...testRequest.headers }; // 인증 헤더 추가 - if ( + if (testRequest.auth_type === "db-token") { + const cfg = testRequest.auth_config || {}; + const { + dbTableName, + dbValueColumn, + dbWhereColumn, + dbWhereValue, + dbHeaderName, + dbHeaderTemplate, + } = cfg; + + if (!dbTableName || !dbValueColumn) { + throw new Error("DB 토큰 설정이 올바르지 않습니다."); + } + + if (!userCompanyCode) { + throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); + } + + const hasWhereColumn = !!dbWhereColumn; + const hasWhereValue = + dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== ""; + + // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 + if (hasWhereColumn !== hasWhereValue) { + throw new Error( + "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." + ); + } + + // 식별자 검증 (간단한 화이트리스트) + const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + if ( + !identifierRegex.test(dbTableName) || + !identifierRegex.test(dbValueColumn) || + (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) + ) { + throw new Error( + "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." + ); + } + + let sql = ` + SELECT ${dbValueColumn} AS token_value + FROM ${dbTableName} + WHERE company_code = $1 + `; + + const params: any[] = [userCompanyCode]; + + if (hasWhereColumn && hasWhereValue) { + sql += ` AND ${dbWhereColumn} = $2`; + params.push(dbWhereValue); + } + + sql += ` + ORDER BY updated_date DESC + LIMIT 1 + `; + + const tokenResult: QueryResult = await pool.query(sql, params); + + if (tokenResult.rowCount === 0) { + throw new Error("DB에서 토큰을 찾을 수 없습니다."); + } + + const tokenValue = tokenResult.rows[0]["token_value"]; + const headerName = dbHeaderName || "Authorization"; + const template = dbHeaderTemplate || "Bearer {{value}}"; + + headers[headerName] = template.replace("{{value}}", tokenValue); + } else if ( testRequest.auth_type === "bearer" && testRequest.auth_config?.token ) { @@ -493,25 +602,84 @@ export class ExternalRestApiConnectionService { `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` ); - // HTTP 요청 실행 - const response = await fetch(url, { - method: testRequest.method || "GET", - headers, - signal: AbortSignal.timeout(testRequest.timeout || 30000), - }); + // Body 처리 + let body: any = undefined; + if (testRequest.body) { + // 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환 + if (typeof testRequest.body === "string") { + body = testRequest.body; + } else { + body = JSON.stringify(testRequest.body); + } - const responseTime = Date.now() - startTime; - let responseData = null; - - try { - responseData = await response.json(); - } catch { - // JSON 파싱 실패는 무시 (텍스트 응답일 수 있음) + // Content-Type 헤더가 없으면 기본적으로 application/json 추가 + const hasContentType = Object.keys(headers).some( + (k) => k.toLowerCase() === "content-type" + ); + if (!hasContentType) { + headers["Content-Type"] = "application/json"; + } } + // HTTP 요청 실행 + // [인수인계 중요] 2024-11-27 추가 + // 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해 + // Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다. + // + // 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나, + // 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만 + // SSL 검증을 우회하도록 예외 처리를 해두었습니다. + // + // ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다. + // 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요. + const bypassDomains = ["thiratis.com"]; + const shouldBypassTls = bypassDomains.some((domain) => + url.includes(domain) + ); + + const httpsAgent = new https.Agent({ + // bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true) + rejectUnauthorized: !shouldBypassTls, + }); + + const requestConfig = { + url, + method: (testRequest.method || "GET") as any, + headers, + data: body, + httpsAgent, + timeout: testRequest.timeout || 30000, + // 4xx/5xx 도 예외가 아니라 응답 객체로 처리 + validateStatus: () => true, + }; + + // 요청 상세 로그 (민감 정보는 최소화) + logger.info( + `REST API 연결 테스트 요청 상세: ${JSON.stringify({ + method: requestConfig.method, + url: requestConfig.url, + headers: { + ...requestConfig.headers, + // Authorization 헤더는 마스킹 + Authorization: requestConfig.headers?.Authorization + ? "***masked***" + : undefined, + }, + hasBody: !!body, + })}` + ); + + const response: AxiosResponse = await axios.request(requestConfig); + + const responseTime = Date.now() - startTime; + // axios는 response.data에 이미 파싱된 응답 본문을 담아준다. + // JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다. + const responseData = response.data ?? null; + return { - success: response.ok, - message: response.ok + success: response.status >= 200 && response.status < 300, + message: + response.status >= 200 && response.status < 300 ? "연결 성공" : `연결 실패 (${response.status} ${response.statusText})`, response_time: responseTime, @@ -552,17 +720,27 @@ export class ExternalRestApiConnectionService { const connection = connectionResult.data; + // 리스트에서 endpoint를 넘기지 않으면, + // 저장된 endpoint_path를 기본 엔드포인트로 사용 + const effectiveEndpoint = + endpoint || connection.endpoint_path || undefined; + const testRequest: RestApiTestRequest = { id: connection.id, base_url: connection.base_url, - endpoint, + endpoint: effectiveEndpoint, + method: (connection.default_method as any) || "GET", // 기본 메서드 적용 headers: connection.default_headers, + body: connection.default_body, // 기본 바디 적용 auth_type: connection.auth_type, auth_config: connection.auth_config, timeout: connection.timeout, }; - const result = await this.testConnection(testRequest); + const result = await this.testConnection( + testRequest, + connection.company_code + ); // 테스트 결과 저장 await pool.query( @@ -580,11 +758,34 @@ export class ExternalRestApiConnectionService { return result; } catch (error) { logger.error("REST API 연결 테스트 (ID) 오류:", error); + + const errorMessage = + error instanceof Error ? error.message : "알 수 없는 오류"; + + // 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록 + try { + await pool.query( + ` + UPDATE external_rest_api_connections + SET + last_test_date = NOW(), + last_test_result = $1, + last_test_message = $2 + WHERE id = $3 + `, + ["N", errorMessage, id] + ); + } catch (updateError) { + logger.error( + "REST API 연결 테스트 (ID) 오류 기록 실패:", + updateError + ); + } + return { success: false, message: "연결 테스트에 실패했습니다.", - error_details: - error instanceof Error ? error.message : "알 수 없는 오류", + error_details: errorMessage, }; } } @@ -683,6 +884,166 @@ export class ExternalRestApiConnectionService { return decrypted; } + /** + * REST API 데이터 조회 (화면관리용 프록시) + * 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환 + */ + static async fetchData( + connectionId: number, + endpoint?: string, + jsonPath?: string, + userCompanyCode?: string + ): Promise> { + try { + // 연결 정보 조회 + const connectionResult = await this.getConnectionById(connectionId, userCompanyCode); + + if (!connectionResult.success || !connectionResult.data) { + return { + success: false, + message: "REST API 연결을 찾을 수 없습니다.", + error: { + code: "CONNECTION_NOT_FOUND", + details: `연결 ID ${connectionId}를 찾을 수 없습니다.`, + }, + }; + } + + const connection = connectionResult.data; + + // 비활성화된 연결인지 확인 + if (connection.is_active !== "Y") { + return { + success: false, + message: "비활성화된 REST API 연결입니다.", + error: { + code: "CONNECTION_INACTIVE", + details: "연결이 비활성화 상태입니다.", + }, + }; + } + + // 엔드포인트 결정 (파라미터 > 저장된 값) + const effectiveEndpoint = endpoint || connection.endpoint_path || ""; + + // API 호출을 위한 테스트 요청 생성 + const testRequest: RestApiTestRequest = { + id: connection.id, + base_url: connection.base_url, + endpoint: effectiveEndpoint, + method: (connection.default_method as any) || "GET", + headers: connection.default_headers, + body: connection.default_body, + auth_type: connection.auth_type, + auth_config: connection.auth_config, + timeout: connection.timeout, + }; + + // API 호출 + const result = await this.testConnection(testRequest, connection.company_code); + + if (!result.success) { + return { + success: false, + message: result.message || "REST API 호출에 실패했습니다.", + error: { + code: "API_CALL_FAILED", + details: result.error_details, + }, + }; + } + + // 응답 데이터에서 jsonPath로 데이터 추출 + let extractedData = result.response_data; + + logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`); + logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`); + + if (jsonPath && result.response_data) { + try { + // jsonPath로 데이터 추출 (예: "data", "data.items", "result.list") + const pathParts = jsonPath.split("."); + logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`); + + for (const part of pathParts) { + if (extractedData && typeof extractedData === "object") { + extractedData = (extractedData as any)[part]; + logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`); + } else { + logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`); + break; + } + } + } catch (pathError) { + logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError); + // 추출 실패 시 원본 데이터 반환 + extractedData = result.response_data; + } + } + + // 데이터가 배열이 아닌 경우 배열로 변환 + // null이나 undefined인 경우 빈 배열로 처리 + let dataArray: any[] = []; + if (extractedData === null || extractedData === undefined) { + logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다."); + // jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도 + if (result.response_data && typeof result.response_data === "object") { + dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data]; + } + } else { + dataArray = Array.isArray(extractedData) ? extractedData : [extractedData]; + } + + logger.info(`최종 데이터 배열 길이: ${dataArray.length}`); + if (dataArray.length > 0) { + logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`); + } + + // 컬럼 정보 추출 (첫 번째 유효한 데이터 기준) + let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = []; + + // 첫 번째 유효한 객체 찾기 + const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item)); + + if (firstValidItem) { + columns = Object.keys(firstValidItem).map((key) => ({ + columnName: key, + columnLabel: key, + dataType: typeof firstValidItem[key], + })); + logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`); + } else { + logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다."); + } + + return { + success: true, + data: { + rows: dataArray, + columns, + total: dataArray.length, + connectionInfo: { + connectionId: connection.id, + connectionName: connection.connection_name, + baseUrl: connection.base_url, + endpoint: effectiveEndpoint, + }, + }, + message: `${dataArray.length}개의 데이터를 조회했습니다.`, + }; + } catch (error) { + logger.error("REST API 데이터 조회 오류:", error); + return { + success: false, + message: "REST API 데이터 조회에 실패했습니다.", + error: { + code: "FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + /** * 연결 데이터 유효성 검증 */ @@ -709,6 +1070,7 @@ export class ExternalRestApiConnectionService { "bearer", "basic", "oauth2", + "db-token", ]; if (!validAuthTypes.includes(data.auth_type)) { throw new Error("올바르지 않은 인증 타입입니다."); diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index b4dce503..4e44006a 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -334,9 +334,12 @@ class MailSendSimpleService { if (variables) { buttonText = this.replaceVariables(buttonText, variables); } + // styles 객체 또는 직접 속성에서 색상 가져오기 + const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff'; + const buttonTextColor = component.styles?.color || component.textColor || '#fff'; // 버튼은 왼쪽 정렬 (text-align 제거) html += ``; break; case 'image': @@ -348,6 +351,89 @@ class MailSendSimpleService { case 'spacer': html += `
`; break; + case 'header': + html += ` +
+ + + + + +
+ ${component.logoSrc ? `로고` : ''} + ${component.brandName || ''} + + ${component.sendDate || ''} +
+
+ `; + break; + case 'infoTable': + html += ` +
+ ${component.tableTitle ? `
${component.tableTitle}
` : ''} + + ${(component.rows || []).map((row: any, i: number) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case 'alertBox': + const alertColors: Record = { + info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' }, + warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' }, + danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' }, + success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' } + }; + const colors = alertColors[component.alertType || 'info']; + html += ` +
+ ${component.alertTitle ? `
${component.alertTitle}
` : ''} +
${component.content || ''}
+
+ `; + break; + case 'divider': + html += `
`; + break; + case 'footer': + html += ` +
+ ${component.companyName ? `
${component.companyName}
` : ''} + ${(component.ceoName || component.businessNumber) ? ` +
+ ${component.ceoName ? `대표: ${component.ceoName}` : ''} + ${component.ceoName && component.businessNumber ? ' | ' : ''} + ${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''} +
+ ` : ''} + ${component.address ? `
${component.address}
` : ''} + ${(component.phone || component.email) ? ` +
+ ${component.phone ? `Tel: ${component.phone}` : ''} + ${component.phone && component.email ? ' | ' : ''} + ${component.email ? `Email: ${component.email}` : ''} +
+ ` : ''} + ${component.copyright ? `
${component.copyright}
` : ''} +
+ `; + break; + case 'numberedList': + html += ` +
+ ${component.listTitle ? `
${component.listTitle}
` : ''} +
    + ${(component.listItems || []).map((item: string) => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index adb72fff..bd82a7d2 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -4,13 +4,35 @@ import path from "path"; // MailComponent 인터페이스 정의 export interface MailComponent { id: string; - type: "text" | "button" | "image" | "spacer"; + type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList"; content?: string; text?: string; url?: string; src?: string; height?: number; styles?: Record; + // 헤더 컴포넌트용 + logoSrc?: string; + brandName?: string; + sendDate?: string; + headerBgColor?: string; + // 정보 테이블용 + rows?: Array<{ label: string; value: string }>; + tableTitle?: string; + // 강조 박스용 + alertType?: "info" | "warning" | "danger" | "success"; + alertTitle?: string; + // 푸터용 + companyName?: string; + ceoName?: string; + businessNumber?: string; + address?: string; + phone?: string; + email?: string; + copyright?: string; + // 번호 리스트용 + listItems?: string[]; + listTitle?: string; } // QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지) @@ -236,6 +258,89 @@ class MailTemplateFileService { case "spacer": html += `
`; break; + case "header": + html += ` +
+ + + + + +
+ ${comp.logoSrc ? `로고` : ''} + ${comp.brandName || ''} + + ${comp.sendDate || ''} +
+
+ `; + break; + case "infoTable": + html += ` +
+ ${comp.tableTitle ? `
${comp.tableTitle}
` : ''} + + ${(comp.rows || []).map((row, i) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case "alertBox": + const alertColors: Record = { + info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' }, + warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' }, + danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' }, + success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' } + }; + const colors = alertColors[comp.alertType || 'info']; + html += ` +
+ ${comp.alertTitle ? `
${comp.alertTitle}
` : ''} +
${comp.content || ''}
+
+ `; + break; + case "divider": + html += `
`; + break; + case "footer": + html += ` +
+ ${comp.companyName ? `
${comp.companyName}
` : ''} + ${(comp.ceoName || comp.businessNumber) ? ` +
+ ${comp.ceoName ? `대표: ${comp.ceoName}` : ''} + ${comp.ceoName && comp.businessNumber ? ' | ' : ''} + ${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''} +
+ ` : ''} + ${comp.address ? `
${comp.address}
` : ''} + ${(comp.phone || comp.email) ? ` +
+ ${comp.phone ? `Tel: ${comp.phone}` : ''} + ${comp.phone && comp.email ? ' | ' : ''} + ${comp.email ? `Email: ${comp.email}` : ''} +
+ ` : ''} + ${comp.copyright ? `
${comp.copyright}
` : ''} +
+ `; + break; + case "numberedList": + html += ` +
+ ${comp.listTitle ? `
${comp.listTitle}
` : ''} +
    + ${(comp.listItems || []).map(item => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a7445637..71550fd6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -70,12 +70,13 @@ export class ScreenManagementService { throw new Error("이미 존재하는 화면 코드입니다."); } - // 화면 생성 (Raw Query) + // 화면 생성 (Raw Query) - REST API 지원 추가 const [screen] = await query( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, description, created_by, - db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + db_source_type, db_connection_id, data_source_type, rest_api_connection_id, + rest_api_endpoint, rest_api_json_path + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ screenData.screenName, @@ -86,6 +87,10 @@ export class ScreenManagementService { screenData.createdBy, screenData.dbSourceType || "internal", screenData.dbConnectionId || null, + (screenData as any).dataSourceType || "database", + (screenData as any).restApiConnectionId || null, + (screenData as any).restApiEndpoint || null, + (screenData as any).restApiJsonPath || "data", ] ); @@ -1977,6 +1982,11 @@ export class ScreenManagementService { updatedBy: data.updated_by, dbSourceType: data.db_source_type || "internal", dbConnectionId: data.db_connection_id || undefined, + // REST API 관련 필드 + dataSourceType: data.data_source_type || "database", + restApiConnectionId: data.rest_api_connection_id || undefined, + restApiEndpoint: data.rest_api_endpoint || undefined, + restApiJsonPath: data.rest_api_json_path || "data", }; } diff --git a/backend-node/src/types/batchExecutionLogTypes.ts b/backend-node/src/types/batchExecutionLogTypes.ts index d966de7c..aa49fd4e 100644 --- a/backend-node/src/types/batchExecutionLogTypes.ts +++ b/backend-node/src/types/batchExecutionLogTypes.ts @@ -4,6 +4,7 @@ export interface BatchExecutionLog { id?: number; batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time: Date; end_time?: Date | null; @@ -19,6 +20,7 @@ export interface BatchExecutionLog { export interface CreateBatchExecutionLogRequest { batch_config_id: number; + company_code?: string; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; start_time?: Date; end_time?: Date | null; diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 24158a3d..1cbec196 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,86 +1,13 @@ -// 배치관리 타입 정의 -// 작성일: 2024-12-24 +import { ApiResponse, ColumnInfo } from './batchTypes'; -// 배치 타입 정의 -export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; - -export interface BatchTypeOption { - value: BatchType; - label: string; - description: string; -} - -export interface BatchConfig { - id?: number; - batch_name: string; - description?: string; - cron_schedule: string; - is_active?: string; - company_code?: string; - created_date?: Date; - created_by?: string; - updated_date?: Date; - updated_by?: string; - batch_mappings?: BatchMapping[]; -} - -export interface BatchMapping { - id?: number; - batch_config_id?: number; - - // FROM 정보 - from_connection_type: 'internal' | 'external' | 'restapi'; - from_connection_id?: number; - from_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - from_column_type?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - from_api_url?: string; // REST API 서버 URL - from_api_key?: string; // REST API 키 - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 - from_api_param_name?: string; // API 파라미터명 - from_api_param_value?: string; // API 파라미터 값 또는 템플릿 - from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - - // TO 정보 - to_connection_type: 'internal' | 'external' | 'restapi'; - to_connection_id?: number; - to_table_name: string; // DB: 테이블명, REST API: 엔드포인트 - to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명 - to_column_type?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용 - to_api_url?: string; // REST API 서버 URL - to_api_key?: string; // REST API 키 - to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) - - mapping_order?: number; - created_date?: Date; - created_by?: string; -} - -export interface BatchConfigFilter { - page?: number; - limit?: number; - batch_name?: string; - is_active?: string; - company_code?: string; - search?: string; -} - -export interface ConnectionInfo { +export interface BatchConnectionInfo { type: 'internal' | 'external'; id?: number; name: string; db_type?: string; } -export interface TableInfo { - table_name: string; - columns: ColumnInfo[]; - description?: string | null; -} - -export interface ColumnInfo { +export interface BatchColumnInfo { column_name: string; data_type: string; is_nullable?: string; @@ -100,6 +27,8 @@ export interface BatchMappingRequest { from_api_param_name?: string; // API 파라미터명 from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 + // 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) + from_api_body?: string; to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; to_table_name: string; @@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest { batchName: string; description?: string; cronSchedule: string; + isActive: 'Y' | 'N'; + companyCode: string; mappings: BatchMappingRequest[]; } @@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest { batchName?: string; description?: string; cronSchedule?: string; + isActive?: 'Y' | 'N'; mappings?: BatchMappingRequest[]; - isActive?: string; } export interface BatchValidationResult { isValid: boolean; errors: string[]; - warnings?: string[]; -} - -export interface ApiResponse { - success: boolean; - data?: T; - message?: string; - error?: string; - pagination?: { - page: number; - limit: number; - total: number; - totalPages: number; - }; } diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 35877974..8d95a4a6 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -1,6 +1,12 @@ // 외부 REST API 연결 관리 타입 정의 -export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; +export type AuthType = + | "none" + | "api-key" + | "bearer" + | "basic" + | "oauth2" + | "db-token"; export interface ExternalRestApiConnection { id?: number; @@ -9,6 +15,11 @@ export interface ExternalRestApiConnection { base_url: string; endpoint_path?: string; default_headers: Record; + + // 기본 메서드 및 바디 추가 + default_method?: string; + default_body?: string; + auth_type: AuthType; auth_config?: { // API Key @@ -28,6 +39,14 @@ export interface ExternalRestApiConnection { clientSecret?: string; tokenUrl?: string; accessToken?: string; + + // DB 기반 토큰 모드 + dbTableName?: string; + dbValueColumn?: string; + dbWhereColumn?: string; + dbWhereValue?: string; + dbHeaderName?: string; + dbHeaderTemplate?: string; }; timeout?: number; retry_count?: number; @@ -54,8 +73,9 @@ export interface RestApiTestRequest { id?: number; base_url: string; endpoint?: string; - method?: "GET" | "POST" | "PUT" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; headers?: Record; + body?: any; // 테스트 요청 바디 추가 auth_type?: AuthType; auth_config?: any; timeout?: number; @@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [ { value: "bearer", label: "Bearer Token" }, { value: "basic", label: "Basic Auth" }, { value: "oauth2", label: "OAuth 2.0" }, + { value: "db-token", label: "DB 토큰" }, ]; diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index ca5a466f..8260f3c6 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -154,6 +154,11 @@ export interface ScreenDefinition { updatedBy?: string; dbSourceType?: "internal" | "external"; dbConnectionId?: number; + // REST API 관련 필드 + dataSourceType?: "database" | "restapi"; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; } // 화면 생성 요청 @@ -166,6 +171,11 @@ export interface CreateScreenRequest { createdBy?: string; dbSourceType?: "internal" | "external"; dbConnectionId?: number; + // REST API 관련 필드 + dataSourceType?: "database" | "restapi"; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; } // 화면 수정 요청 diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index f70d711a..2046ed3e 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo, memo } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -33,6 +33,31 @@ interface BatchColumnInfo { is_nullable: string; } +interface RestApiToDbMappingCardProps { + fromApiFields: string[]; + toColumns: BatchColumnInfo[]; + fromApiData: any[]; + apiFieldMappings: Record; + setApiFieldMappings: React.Dispatch< + React.SetStateAction> + >; + apiFieldPathOverrides: Record; + setApiFieldPathOverrides: React.Dispatch< + React.SetStateAction> + >; +} + +interface DbToRestApiMappingCardProps { + fromColumns: BatchColumnInfo[]; + selectedColumns: string[]; + toApiFields: string[]; + dbToApiFieldMapping: Record; + setDbToApiFieldMapping: React.Dispatch< + React.SetStateAction> + >; + setToApiBody: (body: string) => void; +} + export default function BatchManagementNewPage() { const router = useRouter(); @@ -52,7 +77,8 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 + const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET'); + const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) // REST API 파라미터 설정 const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); @@ -83,6 +109,8 @@ export default function BatchManagementNewPage() { // API 필드 → DB 컬럼 매핑 const [apiFieldMappings, setApiFieldMappings] = useState>({}); + // API 필드별 JSON 경로 오버라이드 (예: "response.access_token") + const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState>({}); // 배치 타입 상태 const [batchType, setBatchType] = useState('restapi-to-db'); @@ -182,24 +210,17 @@ export default function BatchManagementNewPage() { // TO 테이블 변경 핸들러 const handleToTableChange = async (tableName: string) => { - console.log("🔍 테이블 변경:", { tableName, toConnection }); setToTable(tableName); setToColumns([]); if (toConnection && tableName) { try { const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; - console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName }); - const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); - console.log("🔍 컬럼 조회 결과:", result); - if (result && result.length > 0) { setToColumns(result); - console.log("✅ 컬럼 설정 완료:", result.length, "개"); } else { setToColumns([]); - console.log("⚠️ 컬럼이 없음"); } } catch (error) { console.error("❌ 컬럼 목록 로드 오류:", error); @@ -239,7 +260,6 @@ export default function BatchManagementNewPage() { // FROM 테이블 변경 핸들러 (DB → REST API용) const handleFromTableChange = async (tableName: string) => { - console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection }); setFromTable(tableName); setFromColumns([]); setSelectedColumns([]); // 선택된 컬럼도 초기화 @@ -248,17 +268,11 @@ export default function BatchManagementNewPage() { if (fromConnection && tableName) { try { const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; - console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName }); - const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); - console.log("🔍 FROM 컬럼 조회 결과:", result); - if (result && result.length > 0) { setFromColumns(result); - console.log("✅ FROM 컬럼 설정 완료:", result.length, "개"); } else { setFromColumns([]); - console.log("⚠️ FROM 컬럼이 없음"); } } catch (error) { console.error("❌ FROM 컬럼 목록 로드 오류:", error); @@ -276,8 +290,6 @@ export default function BatchManagementNewPage() { } try { - console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod }); - const result = await BatchManagementAPI.previewRestApiData( toApiUrl, toApiKey, @@ -285,8 +297,6 @@ export default function BatchManagementNewPage() { 'GET' // 미리보기는 항상 GET으로 ); - console.log("🔍 TO API 미리보기 결과:", result); - if (result.fields && result.fields.length > 0) { setToApiFields(result.fields); toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`); @@ -303,17 +313,22 @@ export default function BatchManagementNewPage() { // REST API 데이터 미리보기 const previewRestApiData = async () => { - if (!fromApiUrl || !fromApiKey || !fromEndpoint) { - toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); + // API URL, 엔드포인트는 항상 필수 + if (!fromApiUrl || !fromEndpoint) { + toast.error("API URL과 엔드포인트를 모두 입력해주세요."); + return; + } + + // GET 메서드일 때만 API 키 필수 + if (fromApiMethod === "GET" && !fromApiKey) { + toast.error("GET 메서드에서는 API 키를 입력해주세요."); return; } try { - console.log("REST API 데이터 미리보기 시작..."); - const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey, + fromApiKey || "", fromEndpoint, fromApiMethod, // 파라미터 정보 추가 @@ -322,33 +337,23 @@ export default function BatchManagementNewPage() { paramName: apiParamName, paramValue: apiParamValue, paramSource: apiParamSource - } : undefined + } : undefined, + // Request Body 추가 (POST/PUT/DELETE) + (fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined ); - console.log("API 미리보기 결과:", result); - console.log("result.fields:", result.fields); - console.log("result.samples:", result.samples); - console.log("result.totalCount:", result.totalCount); - if (result.fields && result.fields.length > 0) { - console.log("✅ 백엔드에서 fields 제공됨:", result.fields); setFromApiFields(result.fields); setFromApiData(result.samples); - - console.log("추출된 필드:", result.fields); toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`); } else if (result.samples && result.samples.length > 0) { // 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출 - console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출"); const extractedFields = Object.keys(result.samples[0]); - console.log("프론트엔드에서 추출한 필드:", extractedFields); - setFromApiFields(extractedFields); setFromApiData(result.samples); toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); } else { - console.log("❌ 데이터가 없음"); setFromApiFields([]); setFromApiData([]); toast.warning("API에서 데이터를 가져올 수 없습니다."); @@ -370,38 +375,53 @@ export default function BatchManagementNewPage() { // 배치 타입별 검증 및 저장 if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); + const mappedFields = Object.keys(apiFieldMappings).filter( + (field) => apiFieldMappings[field] + ); if (mappedFields.length === 0) { toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); return; } // API 필드 매핑을 배치 매핑 형태로 변환 - const apiMappings = mappedFields.map(apiField => ({ - from_connection_type: 'restapi' as const, - from_table_name: fromEndpoint, // API 엔드포인트 - from_column_name: apiField, // API 필드명 - from_api_url: fromApiUrl, - from_api_key: fromApiKey, - from_api_method: fromApiMethod, - // API 파라미터 정보 추가 - from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined, - from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined, - from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined, - from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined, - to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', - to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, - to_table_name: toTable, - to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼 - mapping_type: 'direct' as const - })); + const apiMappings = mappedFields.map((apiField) => { + const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token) - console.log("REST API 배치 설정 저장:", { - batchName, - batchType, - cronSchedule, - description, - apiMappings + // 기본은 상위 필드 그대로 사용하되, + // 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용 + let fromColumnName = apiField; + const overridePath = apiFieldPathOverrides[apiField]; + if (overridePath && overridePath.trim().length > 0) { + fromColumnName = overridePath.trim(); + } + + return { + from_connection_type: "restapi" as const, + from_table_name: fromEndpoint, // API 엔드포인트 + from_column_name: fromColumnName, // API 필드명 또는 중첩 경로 + from_api_url: fromApiUrl, + from_api_key: fromApiKey, + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || + fromApiMethod === "PUT" || + fromApiMethod === "DELETE" + ? fromApiBody + : undefined, + // API 파라미터 정보 추가 + from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, + from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, + from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, + from_api_param_source: + apiParamType !== "none" ? apiParamSource : undefined, + to_connection_type: + toConnection?.type === "internal" ? "internal" : "external", + to_connection_id: + toConnection?.type === "internal" ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: toColumnName, // 매핑된 DB 컬럼 + mapping_type: "direct" as const, + }; }); // 실제 API 호출 @@ -492,14 +512,6 @@ export default function BatchManagementNewPage() { } } - console.log("DB → REST API 배치 설정 저장:", { - batchName, - batchType, - cronSchedule, - description, - dbMappings - }); - // 실제 API 호출 (기존 saveRestApiBatch 재사용) try { const result = await BatchManagementAPI.saveRestApiBatch({ @@ -645,13 +657,19 @@ export default function BatchManagementNewPage() { />
- + setFromApiKey(e.target.value)} placeholder="ak_your_api_key_here" /> +

+ GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다. +

@@ -673,12 +691,33 @@ export default function BatchManagementNewPage() { GET (데이터 조회) + POST (데이터 조회/전송) + PUT + DELETE + {/* Request Body (POST/PUT/DELETE용) */} + {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( +
+ +