5 Commits

Author SHA1 Message Date
Rinjae fc70560f05 공지사항 관리 - 장애/점검 등록 UI 추가
- PortalNoticeUI: 장애/점검 필드 신설 (시작/종료/상태/영향API)
- PortalNoticeManService: djb_apistatus_incident 연동 분기
- 등록 폼: 동적 유형별 UI + 영향 API 팝업 (showModal)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:06 +09:00
Rinjae 43fe63964f Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/main/java/com/eactive/eai/rms/onl/apim/approval/PortalApprovalManController.java
2026-06-12 16:08:41 +09:00
Rinjae e51331986d Merge remote-tracking branch 'origin/master' 2026-06-09 19:08:38 +09:00
Rinjae 23ba7993c3 Merge remote-tracking branch 'origin/master' 2026-05-21 15:16:41 +09:00
Rinjae 90c43ea7b0 - K8sUtil 파일 삭제
- 승인관리 완전삭제 기능 추가
- DJBank로 명칭 업데이트
2026-05-21 09:51:39 +09:00
17 changed files with 640 additions and 443 deletions
+3
View File
@@ -246,3 +246,6 @@ eapim-admin-kjb/
*.report.md
*.claude.md
tmpclaude-*-cwd
logs
rinjae_
tomcat-base
+36 -28
View File
@@ -10,7 +10,7 @@ AI 어시스턴트가 작업을 시작하기 전에 반드시 이해해야 할
- **신규 기능**: 항상 **JPA**와 Spring Data 리포지토리를 사용해야 합니다.
- **레거시 코드**: 기존 iBATIS (`*.xml` 매퍼) 코드를 수정할 때는 해당 패턴을 따라야 합니다.
- **멀티 모듈 Gradle 프로젝트**: `settings.gradle`에 정의된 것처럼 여러 하위 모듈(`elink-online-*`, `kjb-*` 등)로 구성되어 있습니다. 특정 기능은 다른 모듈에 위치할 수 있습니다.
- **QueryDSL 코드 생성**: 빌드 시 `QueryDSL`을 사용하여 Q-class가 자동으로 생성됩니다. IDE에서 엔티티를 찾을 수 없다고 표시되면, `kjb-gradle.sh compileJava`를 먼저 실행하여 코드를 생성해야 합니다.
- **QueryDSL 코드 생성**: 빌드 시 `QueryDSL`을 사용하여 Q-class가 자동으로 생성됩니다. IDE에서 엔티티를 찾을 수 없다고 표시되면, `./gradlew compileJava` 를 먼저 실행하여 코드를 생성해야 합니다.
- **고객사별 커스터마이징**: `ext.kjb` 또는 `custom` 패키지는 특정 고객사(광주은행)를 위한 코드를 포함하므로, 일반적인 기능 수정 시에는 주의가 필요합니다.
## 프로젝트 개요
@@ -39,50 +39,48 @@ eLink EMS (eLink Management System)는 eLink의 웹 기반 관리 서비스로,
### 빌드 명령어
**⚠️ 중요**: 환경변수 문제로 인해 `gradle` 대신 `kjb-gradle.sh` 스크립트를 사용해야 합니다.
- 스크립트 위치: `/c/eactive/workspaces/shell-scripts/kjb-gradle.sh`
- PATH에 등록되어 있으므로 바로 `kjb-gradle.sh` 명령어 사용 가능
프로젝트 루트의 Gradle wrapper(`./gradlew`)를 사용합니다. JDK 8 toolchain 위치는 `.envrc` 등으로 잡습니다(아래 "기술 스택" 섹션 참조).
```bash
# 표준 빌드
kjb-gradle.sh build
./gradlew build
# Weblogic 배포용 빌드 (테스트 제외)
kjb-gradle.sh build -x test -Pprofile=weblogic
./gradlew build -x test -Pprofile=weblogic
# WAR 파일 빌드
kjb-gradle.sh war
./gradlew war
# 클린 빌드
kjb-gradle.sh clean build
./gradlew clean build
```
### 테스트 실행
```bash
# 모든 테스트 실행
kjb-gradle.sh test
./gradlew test
# 특정 테스트 클래스 실행
kjb-gradle.sh test --tests "com.example.ClassName"
./gradlew test --tests "com.example.ClassName"
# 특정 테스트 패키지 실행 (JUnit 플랫폼 사용)
kjb-gradle.sh test --tests "com.eactive.eai.rms.*"
./gradlew test --tests "com.eactive.eai.rms.*"
```
### 개발 태스크
```bash
# 패키징 없이 클래스만 컴파일
kjb-gradle.sh classes
./gradlew classes
# QueryDSL Q-classes 및 기타 애노테이션 생성
kjb-gradle.sh compileJava
./gradlew compileJava
# 의존성 트리 보기
kjb-gradle.sh dependencies
./gradlew dependencies
# 사용 가능한 모든 태스크 목록
kjb-gradle.sh tasks --all
./gradlew tasks --all
```
## 로컬 개발 빠른 시작
@@ -92,26 +90,26 @@ kjb-gradle.sh tasks --all
1. **IDE 설정 파일 생성 (최초 1회)**
```bash
# Eclipse 사용 시 (기본)
kjb-gradle.sh eclipse
./gradlew eclipse
# IntelliJ IDEA 사용 시 (build.gradle.intellij 파일 사용 필요)
# 1. build.gradle을 build.gradle.eclipse로 백업
# 2. build.gradle.intellij를 build.gradle로 복사
# 3. kjb-gradle.sh idea 실행
# 3. ./gradlew idea 실행
```
2. **QueryDSL 등 소스 코드 생성**
```bash
kjb-gradle.sh compileJava
./gradlew compileJava
```
3. **전체 빌드 및 테스트 실행**
```bash
kjb-gradle.sh build
./gradlew build
```
4. **IDE에서 프로젝트 열기**
- Eclipse: `kjb-gradle.sh eclipse` 실행 후, IDE에서 프로젝트를 엽니다.
- Eclipse: `./gradlew eclipse` 실행 후, IDE에서 프로젝트를 엽니다.
- IntelliJ: IntelliJ 전용 `build.gradle.intellij`를 `build.gradle`로 교체 후 사용합니다.
5. **실행 구성(Run Configuration) 설정**
@@ -162,14 +160,14 @@ eapim-admin (root project)
#### Q-Class 생성 위치
Q-classes는 `build/generated/java/` 디렉토리에 생성됩니다.
- `kjb-gradle.sh compileJava` 또는 `kjb-gradle.sh build` 실행 시 Gradle이 자동으로 생성
- `./gradlew compileJava` 또는 `./gradlew build` 실행 시 Gradle이 자동으로 생성
- 이 디렉토리는 `.gitignore`에 포함되어 있으며 커밋해서는 안 됩니다
#### 생성된 코드 작업하기
- **Gradle 빌드**: `build/generated/java/`의 Q-classes가 자동으로 classpath에 포함됩니다
- **업데이트 pull 후**: `kjb-gradle.sh compileJava`를 실행하여 Q-classes를 재생성합니다
- **Q-classes IDE 오류 시**: `kjb-gradle.sh clean compileJava`로 재생성합니다
- **업데이트 pull 후**: `./gradlew compileJava` 를 실행하여 Q-classes를 재생성합니다
- **Q-classes IDE 오류 시**: `./gradlew clean compileJava` 로 재생성합니다
#### IDE별 빌드 설정 파일
@@ -255,12 +253,21 @@ IntelliJ를 사용하려면 `build.gradle.intellij`를 `build.gradle`로 교체
WebLogic용 빌드 시, DefaultServlet 문제를 해결하기 위해 `web.xml`을 `weblogic-web.xml`로 교체하는 `weblogic` 프로필을 사용합니다:
```bash
kjb-gradle.sh build -x test -Pprofile=weblogic
./gradlew build -x test -Pprofile=weblogic
```
## 기술 스택
- **Java 8**: Gradle toolchain을 통해 강제
- **Java 8**: Gradle toolchain을 통해 강제 (`JavaLanguageVersion.of(8)`). 프로젝트의 `gradle.properties` 에는 JDK 경로를 기입하지 않습니다 — 개인 환경별로 `.envrc`(direnv) 또는 셸 rc 파일에서 `JAVA_HOME` 을 JDK 8 위치로 export 하세요. Gradle 8 의 toolchain auto-detect 가 `JAVA_HOME` 도 후보 경로로 인식합니다.
```bash
# 예: 프로젝트 루트의 .envrc (Zulu 8 / macOS Apple Silicon)
JAVA_HOME=${HOME}/opts/jdks/zulu8.94.0.17-ca-jdk8.0.492-macosx_aarch64
GRADLE_USER_HOME=${HOME}/eactive/djb-eapim/gradle-home
export PATH=${JAVA_HOME}/bin:${PATH}
```
Gradle 표준 위치(`/Library/Java/JavaVirtualMachines/...`, Homebrew, SDKMAN 등)에 설치한 경우는 별도 설정 없이도 auto-detect 됩니다.
- **Spring Framework 5.3.27**: 코어 프레임워크 (MVC, Data JPA)
- **Spring Data JPA 2.5.2**: 리포지토리 추상화
- **Hibernate 5.4.33/5.6.15**: ORM 구현 (JPA/ORM)
@@ -271,9 +278,10 @@ kjb-gradle.sh build -x test -Pprofile=weblogic
- **Jackson 2.13.1**: JSON 처리
- **Logback 1.2.10**: 로깅 프레임워크 (SLF4J와 함께)
- **AWS SDK 2.20.142**: S3 연동
- **Kubernetes Client 18.0.1**: K8s 관리
- **EhCache**: 캐싱, **Lombok**: 코드 생성, **MapStruct**: 객체 매핑
> Kubernetes Client 의존성은 JDK 8 호환 문제로 제거됨(`io.kubernetes:client-java:18.0.1` 은 JDK 11+ 필요). K8s 모드 기능이 필요하면 `≤13.0.2` 로 다운그레이드 후 재도입 검토.
## 프로젝트 규칙
### 패키지 구조
@@ -376,7 +384,7 @@ com.eactive.eai
### IntelliJ IDEA (권장)
```bash
kjb-gradle.sh idea
./gradlew idea
```
또는 IntelliJ에서 직접 "Import Gradle Project"를 사용합니다.
@@ -395,7 +403,7 @@ cp build.gradle build.gradle.intellij
cp build.gradle.eclipse build.gradle
# 3. Eclipse 프로젝트 생성
kjb-gradle.sh eclipse
./gradlew eclipse
```
Eclipse 전용 설정(`build.gradle.eclipse`)은 다음을 포함합니다:
@@ -17,6 +17,7 @@
<script language="javascript" >
var url = '<c:url value="/onl/apim/approval/portalApprovalMan.json" />';
var url_view = '<c:url value="/onl/apim/approval/portalApprovalMan.view" />';
var hardDeleteEnabled = ${hardDeleteEnabled};
const APPROVAL_TYPE_DISPLAY = {
'USER': '법인사용자',
'APP': 'API 사용'
@@ -30,6 +31,46 @@
cellvalue.substr(6, 2) ;
}
function deleteSelectedApprovals() {
var selectedIds = $("#grid").jqGrid('getGridParam', 'selarrrow');
if (selectedIds.length === 0) {
showAlert("삭제할 승인 요청을 선택해주세요.", {type: 'warning'});
return;
}
if (!hardDeleteEnabled) {
showAlert("완전삭제 기능이 비활성화되어 있습니다.", {type: 'error'});
return;
}
var confirmMsg = "선택된 " + selectedIds.length + "건을 완전삭제(DB 영구 삭제)하시겠습니까?";
showConfirm(confirmMsg, {
type: 'error',
onConfirm: function() {
showConfirm("최종 확인: 완전삭제는 되돌릴 수 없습니다. 정말 실행하시겠습니까?", {
type: 'error',
title: '최종 확인',
onConfirm: function() {
$.ajax({
type: "POST", url: url, dataType: "json",
data: { cmd: 'HARD_DELETE_MULTIPLE', ids: selectedIds.join(',') },
success: function(data) {
showAlert(data.message, {
type: data.status === 'success' ? 'success' : 'error',
onClose: function() { $("#grid").trigger("reloadGrid"); }
});
},
error: function(e) {
showAlert("완전삭제 오류: " + e.responseText, {type: 'error'});
}
});
}
});
}
});
}
$(document).ready(function() {
$('#grid').jqGrid({
datatype: "json",
@@ -62,6 +103,8 @@
viewrecords: true,
autowidth: true,
height: 'auto',
multiselect: true,
multiboxonly: true,
ondblClickRow: function(rowId) {
var rowData = $(this).getRowData(rowId);
var id = rowData['id'];
@@ -102,6 +145,10 @@
$("#grid").setGridParam({ url: url, postData: postData, page: 1 }).trigger("reloadGrid");
});
$("#btn_delete_selected").click(function() {
deleteSelectedApprovals();
});
$("select[name^=search], input[name^=search]").keydown(function(key) {
if (key.keyCode == 13) {
$("#btn_search").click();
@@ -121,9 +168,17 @@
</div><!-- end content_top -->
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<c:if test="${hardDeleteEnabled}">
<button type="button" class="cssbtn" id="btn_delete_selected" level="W" style="background-color: #dc3545; border-color: #dc3545; color: white;"><i class="material-icons">delete_forever</i> 선택 삭제</button>
</c:if>
<button type="button" class="cssbtn" id="btn_search" level="R"><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button>
</div>
<div class="title">승인 요청 목록<span class="tooltip">승인 요청을 관리합니다.</span></div>
<c:if test="${hardDeleteEnabled}">
<div style="background:#fff3cd; border:1px solid #ffc107; padding:8px 15px; margin:5px 0; border-radius:4px; color:#856404;">
<strong>[주의]</strong> 선택 삭제 시 승인 요청과 승인자 이력이 DB에서 영구 삭제됩니다.
</div>
</c:if>
<form id="ajaxForm" onsubmit="return false;">
<table class="search_condition" cellspacing="0">
<tbody>
@@ -19,10 +19,23 @@
<script language="javascript" >
var url = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.json" />';
var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />';
var apiPopupUrl = '<c:url value="/onl/apim/apigroup/apiGroupMan.view" />';
var file_download = '<c:url value="/file/download.do" />';
var isDetail = false;
var fileInfo = null;
// 코드 상수 — PortalNoticeUI 와 동기화
var NOTICE_TYPE_NORMAL = '1';
var NOTICE_TYPE_MAINTENANCE = '2';
var NOTICE_TYPE_INCIDENT = '3';
// 영향 API 목록 (UI 상태)
var affectedApis = [];
function isIncidentType(t) {
return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE;
}
function init() {
var key = "${param.id}";
isDetail = key != "" && key != "null";
@@ -36,7 +49,6 @@
buttonControl(false);
}
// 초기화 로직
$.ajax({
type : "POST",
url:url,
@@ -45,6 +57,7 @@
success:function(json){
combo = json;
new makeOptions("CODE","NAME").setObj($("select[name=noticeType]")).setNoValueInclude(false).setData(json.noticeTypeList).rendering();
toggleIncidentFields();
if (key) {
detail(key);
@@ -56,6 +69,22 @@
});
}
function toggleIncidentFields() {
var t = $('#noticeType').val();
if (isIncidentType(t)) {
$('.incident-row').show();
// INCIDENT 만 상태 영역 표시
if (t === NOTICE_TYPE_INCIDENT) {
$('.incident-state-row').show();
} else {
$('.incident-state-row').hide();
}
} else {
$('.incident-row').hide();
$('.incident-state-row').hide();
}
}
function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea');
textArea.innerHTML = text;
@@ -73,57 +102,100 @@
function displayAttachFile(file) {
var $attachFiles = $('#attachFiles');
$attachFiles.empty();
if (file) {
var iconPath = '${pageContext.request.contextPath}/images/icon_file.png';
var fileItem = $('<div>').addClass('file-item');
$('<img>').attr('src', iconPath).css({'marginLeft': '15px','marginRight': '5px' ,'height': '1.2em'}).appendTo(fileItem);
var fileNameSpan = $('<span>')
.addClass('fileName')
var fileNameSpan = $('<span>').addClass('fileName')
.text(file.originalFileName + '.' + file.fileExtension + ' (' + formatFileSize(file.size) + ')')
.css('cursor', 'pointer')
.on('click', function() {
if (file.fileId) {
downloadFile(file.fileId);
}
});
.on('click', function() { if (file.fileId) downloadFile(file.fileId); });
fileNameSpan.appendTo(fileItem);
$('<button>').text('X').addClass('delete-file').css('marginLeft', '15px')
.on('click', function() {
fileInfo = null;
displayAttachFile(null);
// 폼에 삭제 플래그 추가
$('<input>').attr({
type: 'hidden',
name: 'fileDeleted',
value: 'true'
}).appendTo('#ajaxForm');
})
.appendTo(fileItem);
$('<input>').attr({type: 'hidden', name: 'fileDeleted', value: 'true'}).appendTo('#ajaxForm');
}).appendTo(fileItem);
fileItem.appendTo($attachFiles);
}
}
function downloadFile(fileId) {
if (fileId) {
var fileSn = 1; // 단일 파일이므로 시리얼 넘버를 1로 고정
window.location.href = file_download + '?fileId=' + fileId + '&fileSn=' + fileSn;
} else {
console.log("File not yet uploaded or doesn't have an ID");
window.location.href = file_download + '?fileId=' + fileId + '&fileSn=1';
}
}
function renderAffectedApis() {
var $tb = $('#affectedApiTbody');
$tb.empty();
if (!affectedApis.length) {
$tb.append('<tr><td colspan="3" style="text-align:center;color:#999;">선택된 영향 API가 없습니다.</td></tr>');
return;
}
$.each(affectedApis, function(idx, api) {
var $tr = $('<tr>');
$tr.append('<td style="text-align:center;width:40px;"><input type="checkbox" class="affected-api-check" data-idx="' + idx + '"></td>');
$tr.append('<td>' + (api.apiId || '') + '</td>');
$tr.append('<td>' + (api.apiName || '') + '</td>');
$tb.append($tr);
});
}
function openApiPopup() {
// showModal 헬퍼는 urlAddServiceType + &menuId=getMenuId() + &pop=true 를 자동 부여 →
// AuthorizeInterceptor 가 정상적으로 권한 인식. window.open 직접 호출 시 권한 부족 발생.
// 본 페이지는 summernote 본문 에디터 등 다른 iframe 이 함께 존재할 수 있어
// frames[0] 가정은 위험 → DOM 에서 .ui-dialog 내 iframe 중 src=apiGroupMan 인 것을 식별.
var selectedIds = affectedApis.map(function(a) { return a.apiId; }).join(',');
var url2 = apiPopupUrl + '?cmd=POPUP&selectedApiIds=' + encodeURIComponent(selectedIds);
var args = {};
showModal(url2, args, 1280, 860, function () {
var doc = args.self && args.self.document;
if (!doc) return;
var popupFrame = null;
var iframes = doc.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
var src = iframes[i].getAttribute('src') || '';
if (src.indexOf('apiGroupMan') >= 0) {
popupFrame = iframes[i].contentWindow;
break;
}
}
if (!popupFrame) return;
var rv = popupFrame.returnValue;
if (!rv) return;
var rows = Array.isArray(rv) ? rv : [rv];
$.each(rows, function(_, item) {
if (!item || !item.apiId) return;
var exists = affectedApis.some(function(a){ return a.apiId === item.apiId; });
if (!exists) {
affectedApis.push({apiId: item.apiId, apiName: item.apiDesc || item.apiName || ''});
}
});
renderAffectedApis();
});
}
function removeSelectedAffectedApis() {
var idxes = [];
$('.affected-api-check:checked').each(function() {
idxes.push(parseInt($(this).data('idx'), 10));
});
if (!idxes.length) {
alert('삭제할 항목을 선택해주세요.');
return;
}
// 역순 삭제
idxes.sort(function(a,b){return b-a;}).forEach(function(i){ affectedApis.splice(i, 1); });
renderAffectedApis();
}
function detail(key) {
if (!isDetail) return;
$.ajax({
type: "POST",
url: url,
dataType: "json",
type: "POST", url: url, dataType: "json",
data: {cmd: 'DETAIL', id: key},
success: function (data) {
$("#id").val(key);
@@ -135,7 +207,18 @@
var decodedContent = decodeHTMLEntities(data.noticeDetail);
$('#contents').summernote('code', decodedContent);
// 파일 정보 표시
// 장애/점검 필드
$('#summary').val(data.summary || '');
$('#startedAt').val(data.startedAt || '');
$('#endAt').val(data.endAt || '');
$('#state').val(data.state || 'INVESTIGATING');
$('#previousState').text(data.previousState || '없음');
affectedApis = (data.affectedApis || []).map(function(a){
return {apiId: a.apiId, apiName: a.apiName || ''};
});
renderAffectedApis();
toggleIncidentFields();
if (data.fileInfo) {
fileInfo = {
originalFileName: data.fileInfo.fileDetails[0].originalFileName,
@@ -146,9 +229,7 @@
displayAttachFile(fileInfo);
}
},
error: function (e) {
alert(e.responseText);
}
error: function (e) { alert(e.responseText); }
});
}
@@ -157,13 +238,12 @@
init();
initSummernote('#contents', { placeholder: '여기에 내용을 입력하세요.', height: 300 });
// Summernote 에디터 초기화 (이미지 붙여넣기 data-uri 방식, 리사이징 지원)
initSummernote('#contents', {
placeholder: '여기에 내용을 입력하세요.',
height: 300
});
$('#noticeType').on('change', toggleIncidentFields);
$('#btn_addAffectedApi').click(openApiPopup);
$('#btn_removeAffectedApi').click(removeSelectedAffectedApis);
$('#fileInput').change(function(e) {
var file = e.target.files[0];
@@ -171,52 +251,66 @@
fileInfo = {
originalFileName: file.name.split('.').slice(0, -1).join('.'),
fileExtension: file.name.split('.').pop(),
file: file,
size: file.size
file: file, size: file.size
};
displayAttachFile(fileInfo);
}
this.value = ''; // 입력 필드 초기화
});
$('#addFile').click(function() {
$('#fileInput').click();
this.value = '';
});
$('#addFile').click(function() { $('#fileInput').click(); });
$("#btn_modify").click(function () {
if (!checkRequired("ajaxForm")) return;
if (confirm("<%= localeMessage.getString("common.checkSave")%>") != true)
var noticeType = $('#noticeType').val();
if (isIncidentType(noticeType)) {
if (!$('#startedAt').val()) {
alert('시작 시각을 입력해 주십시오.');
return;
}
if (noticeType === NOTICE_TYPE_MAINTENANCE && !$('#endAt').val()) {
alert('점검 종료 시각을 입력해 주십시오.');
return;
}
if (affectedApis.length === 0) {
alert('영향 API를 1건 이상 선택해 주십시오.');
return;
}
}
if (confirm("<%= localeMessage.getString("common.checkSave")%>") != true) return;
var formData = new FormData($("#ajaxForm")[0]);
formData.set("useYn", $("#useYn").is(":checked") ? "Y" : "N");
formData.set("fixYn", $("#fixYn").is(":checked") ? "Y" : "N");
formData.set("noticeDetail", $('#contents').summernote('code'));
// 시작/종료 시각이 비어 있으면 전송하지 않음 (서버 파싱 오류 방지)
if (!$('#startedAt').val()) formData.delete('startedAt');
if (!$('#endAt').val()) formData.delete('endAt');
// 영향 API → affectedApis[i].apiId/apiName 으로 전송
$.each(affectedApis, function(i, api) {
formData.set('affectedApis[' + i + '].apiId', api.apiId);
formData.set('affectedApis[' + i + '].apiName', api.apiName || '');
});
formData.append("cmd", isDetail ? "UPDATE" : "INSERT");
// 파일 처리
if (fileInfo && fileInfo.file) {
formData.set("files", fileInfo.file);
formData.set("fileName", fileInfo.originalFileName + '.' + fileInfo.fileExtension);
}
$.ajax({
type: "POST",
url: url,
data: formData,
processData: false,
contentType: false,
success: function (json) {
type: "POST", url: url, data: formData,
processData: false, contentType: false,
success: function () {
alert("<%= localeMessage.getString("common.saveMsg") %>");
goNav(returnUrl);
},
error: function (e) {
alert(e.responseText);
}
error: function (e) { alert(e.responseText); }
});
});
@@ -224,25 +318,18 @@
if (confirm("<%= localeMessage.getString("common.confirmMsg")%>")) {
var postData = $('#ajaxForm').serializeArray();
postData.push({name: "cmd", value: "DELETE"});
$.ajax({
type: "POST",
url: url,
data: postData,
success: function (args) {
type: "POST", url: url, data: postData,
success: function () {
alert("<%= localeMessage.getString("common.deleteMsg") %>");
goNav(returnUrl);
},
error: function (e) {
alert(e.responseText);
}
error: function (e) { alert(e.responseText); }
});
}
});
$("#btn_previous").click(function () {
goNav(returnUrl);
});
$("#btn_previous").click(function () { goNav(returnUrl); });
});
</script>
</head>
@@ -252,7 +339,7 @@
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div><!-- end content_top -->
</div>
<div class="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_modify" level="W" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.modify") %></button>
@@ -270,9 +357,9 @@
<col style="width: 40%"/>
</colgroup>
<tr>
<th>게시유형</th>
<th>게시유형 <font color="red">*</font></th>
<td colspan="3">
<div class="select-style" >
<div class="select-style">
<select name="noticeType" id="noticeType"></select>
</div>
</td>
@@ -286,7 +373,7 @@
<td>
<input type="checkbox" name="useYn" id="useYn" value="Y" ${portalNoticeUI.useYn eq 'Y' ? 'checked' : ''}> 사용
</td>
<th>고정여부</th>
<th>상단고정</th>
<td>
<input type="checkbox" name="fixYn" id="fixYn" value="Y" ${portalNoticeUI.fixYn eq 'Y' ? 'checked' : ''}> 고정
</td>
@@ -297,16 +384,55 @@
<textarea id="contents" name="noticeDetail" style="width:100%;height:300px" data-required data-warning="본문을 입력하여 주십시오."></textarea>
</td>
</tr>
<!-- <tr>
<th>첨부파일</th>
<td colspan="3">
<div style="margin: 5px 0px; display: inline-block;">
<input type="file" id="fileInput" name="files" style="display:none;" />
<button type="button" id="addFile" class="cssbtn smallBtn">파일 선택</button>
</div>
<div id="attachFiles" style="margin: 5px 0px; display: inline-block;"></div>
<!-- ───── 장애/점검 전용 영역 ───── -->
<tr class="incident-row" style="display:none;">
<th>시작 <font color="red">*</font></th>
<td>
<input type="datetime-local" id="startedAt" name="startedAt" style="width:90%">
</td>
</tr> -->
<th>종료</th>
<td>
<input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움">
</td>
</tr>
<tr class="incident-row incident-state-row" style="display:none;">
<th>상태</th>
<td>
<div class="select-style">
<select id="state" name="state">
<option value="INVESTIGATING">INVESTIGATING - 조사중</option>
<option value="IDENTIFIED">IDENTIFIED - 원인 파악</option>
<option value="MONITORING">MONITORING - 모니터링</option>
<option value="RESOLVED">RESOLVED - 해소</option>
<option value="CANCELED">CANCELED - 취소</option>
</select>
</div>
</td>
<th>이전 상태</th>
<td><span id="previousState">없음</span></td>
</tr>
<tr class="incident-row" style="display:none;">
<th>영향 API 선택</th>
<td colspan="3">
<div style="margin-bottom:5px;">
<button type="button" class="cssbtn smallBtn" id="btn_removeAffectedApi"><i class="material-icons">delete</i> 삭제</button>
<button type="button" class="cssbtn smallBtn" id="btn_addAffectedApi"><i class="material-icons">add</i> 추가</button>
</div>
<table class="table_row" cellspacing="0" style="width:100%;">
<thead>
<tr>
<th style="width:40px;text-align:center;"><input type="checkbox" id="affectedApiAll" onclick="$('.affected-api-check').prop('checked', this.checked);"></th>
<th>API ID</th>
<th>API 명</th>
</tr>
</thead>
<tbody id="affectedApiTbody">
<tr><td colspan="3" style="text-align:center;color:#999;">선택된 영향 API가 없습니다.</td></tr>
</tbody>
</table>
</td>
</tr>
</table>
</form>
</div>
+9 -5
View File
@@ -202,13 +202,12 @@ dependencies {
implementation 'software.amazon.awssdk:sso:2.20.142'
implementation 'software.amazon.awssdk:sts:2.20.142'
implementation ('io.kubernetes:client-java:18.0.1') {
exclude group: 'org.slf4j', module: 'slf4j-api'
exclude group: 'org.slf4j', module: 'logback-classic'
}
implementation group: 'commons-net', name: 'commons-net', version: '3.5'
// JDK 8 의 rt.jar 에는 org.w3c.dom.ElementTraversal 이 없어 xercesImpl 가 NCDFE 를 일으킴.
// xml-apis 1.4.01 에 그 클래스가 포함됨. 직접 의존성으로 묶어 WAR 에 패키징되도록 함.
implementation 'xml-apis:xml-apis:1.4.01'
testRuntimeOnly 'com.h2database:h2:2.1.214'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
@@ -228,6 +227,11 @@ configurations.all {
cacheDynamicVersionsFor 10, 'minutes'
// Do not cache changing modules
cacheChangingModulesFor 0, 'seconds'
// JDK 8 의 rt.jar 에는 org.w3c.dom.ElementTraversal 이 없음.
// 일부 transitive 가 끌어오는 xml-apis:1.0.b2 (2002, DOM L2) 는 이 클래스를 포함하지 않아
// xercesImpl 가 NoClassDefFoundError 를 일으킴 → 1.4.01 로 강제 통일.
force 'xml-apis:xml-apis:1.4.01'
}
}
+16 -206
View File
@@ -9,7 +9,7 @@
## 기술 스택
- Spring Framework 5.3.27
- Java 8
- Java 17
- Gradle 8.7
- Oracle 19c
- Spring Data JPA 2.5.2
@@ -23,251 +23,61 @@
### 사전 요구사항
- JDK 8 이상
- JDK 17 이상
- Gradle 8.7
- Git
- Oracle 19c (또는 개발 환경에 따라 접근 가능한 DB)
### Git Bash에서 초기 세팅
```bash
# ====================================
# 1. 변수 설정 (프로젝트 경로 및 브랜치)
# ====================================
export WORKSPACE_DIR="/c/eactive/workspaces/kjb-eapim"
export PROJECT_BRANCH="jenkins_with_weblogic"
# Git Repository URL 설정
# 기본: ssh://git@192.168.240.178:18081/eapim
# 대체: https://git.eactive.synology.me:8090/kjb-eapim
export GIT_REPO_BASE="ssh://git@192.168.240.178:18081/eapim"
# export GIT_REPO_BASE="https://git.eactive.synology.me:8090/kjb-eapim"
# Gradle 명령어 설정
# 일반 환경: gradle
# msi-gf63 환경: /c/eactive/workspaces/shell-scripts/kjb-gradle.sh
export GRADLE_CMD="gradle"
# export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
# ====================================
# 2. 작업 디렉토리 생성
# ====================================
mkdir -p $WORKSPACE_DIR
cd $WORKSPACE_DIR
# ====================================
# 3. eapim-admin 프로젝트 클론
# ====================================
git clone ${GIT_REPO_BASE}/eapim-admin.git
cd eapim-admin
# 프로젝트 브랜치로 체크아웃
git checkout $PROJECT_BRANCH
# ====================================
# 4. eapim-online 프로젝트 클론 (코어 모듈 포함)
# ====================================
cd $WORKSPACE_DIR
git clone ${GIT_REPO_BASE}/eapim-online.git
cd eapim-online
git checkout $PROJECT_BRANCH
# ====================================
# 5. 의존 모듈 클론
# ====================================
cd $WORKSPACE_DIR
# elink-portal-common 클론
git clone ${GIT_REPO_BASE}/elink-portal-common.git
cd elink-portal-common
git checkout master # 또는 필요한 브랜치
# kjb-safedb 클론
cd $WORKSPACE_DIR
git clone ${GIT_REPO_BASE}/kjb-safedb.git
cd kjb-safedb
git checkout master # 또는 필요한 브랜치
# ====================================
# 6. 최종 폴더 구조 확인
# ====================================
# kjb-eapim/
# ├── eapim-admin/ # Admin 관리 콘솔 (현재 프로젝트)
# ├── eapim-online/ # Online 게이트웨이 코어 모듈
# │ ├── elink-online-core/
# │ ├── elink-online-core-jpa/
# │ ├── elink-online-transformer/
# │ ├── elink-online-common/
# │ ├── elink-online-emsclient/
# │ └── elink-online-adapter/
# ├── elink-portal-common/ # 포털 공통 컴포넌트 (JPA 엔티티)
# └── kjb-safedb/ # SafeDB 암호화 라이브러리
# ====================================
# 7. eapim-admin 프로젝트로 이동 및 디펜던시 다운로드
# ====================================
cd $WORKSPACE_DIR/eapim-admin
# Gradle 디펜던시 다운로드
$GRADLE_CMD dependencies
```
### Windows CMD에서 초기 세팅
```cmd
REM ====================================
REM 1. 변수 설정 (프로젝트 경로 및 브랜치)
REM ====================================
set WORKSPACE_DIR=C:\eactive\workspaces\kjb-eapim
set PROJECT_BRANCH=jenkins_with_weblogic
REM Git Repository URL 설정
REM 기본: ssh://git@192.168.240.178:18081/eapim
REM 대체: https://git.eactive.synology.me:8090/kjb-eapim
set GIT_REPO_BASE=ssh://git@192.168.240.178:18081/eapim
REM set GIT_REPO_BASE=https://git.eactive.synology.me:8090/kjb-eapim
REM Gradle 명령어 설정
REM 일반 환경: gradle
REM msi-gf63 환경: C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
set GRADLE_CMD=gradle
REM set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
REM ====================================
REM 2. 작업 디렉토리 생성
REM ====================================
if not exist "%WORKSPACE_DIR%" mkdir "%WORKSPACE_DIR%"
cd /d "%WORKSPACE_DIR%"
REM ====================================
REM 3. eapim-admin 프로젝트 클론
REM ====================================
git clone %GIT_REPO_BASE%/eapim-admin.git
cd eapim-admin
REM 프로젝트 브랜치로 체크아웃
git checkout %PROJECT_BRANCH%
REM ====================================
REM 4. eapim-online 프로젝트 클론 (코어 모듈 포함)
REM ====================================
cd /d "%WORKSPACE_DIR%"
git clone %GIT_REPO_BASE%/eapim-online.git
cd eapim-online
git checkout %PROJECT_BRANCH%
REM ====================================
REM 5. 의존 모듈 클론
REM ====================================
cd /d "%WORKSPACE_DIR%"
REM elink-portal-common 클론
git clone %GIT_REPO_BASE%/elink-portal-common.git
cd elink-portal-common
git checkout master
REM kjb-safedb 클론
cd /d "%WORKSPACE_DIR%"
git clone %GIT_REPO_BASE%/kjb-safedb.git
cd kjb-safedb
git checkout master
REM ====================================
REM 6. 최종 폴더 구조 확인
REM ====================================
REM kjb-eapim\
REM ├── eapim-admin\ # Admin 관리 콘솔 (현재 프로젝트)
REM ├── eapim-online\ # Online 게이트웨이 코어 모듈
REM │ ├── elink-online-core\
REM │ ├── elink-online-core-jpa\
REM │ ├── elink-online-transformer\
REM │ ├── elink-online-common\
REM │ ├── elink-online-emsclient\
REM │ └── elink-online-adapter\
REM ├── elink-portal-common\ # 포털 공통 컴포넌트 (JPA 엔티티)
REM └── kjb-safedb\ # SafeDB 암호화 라이브러리
REM ====================================
REM 7. eapim-admin 프로젝트로 이동 및 디펜던시 다운로드
REM ====================================
cd /d "%WORKSPACE_DIR%\eapim-admin"
REM Gradle 디펜던시 다운로드
%GRADLE_CMD% dependencies
```
### 환경별 설정 파일
프로젝트 클론 후 필요한 환경 설정을 수정하세요:
- `WebContent/WEB-INF/properties/env.D.properties` - 개발 환경
- `WebContent/WEB-INF/properties/env.T.properties` - 테스트/스테이징 환경
- `WebContent/WEB-INF/properties/env.P.properties` - 운영 환경
### 주의사항
- **gradlew 사용 금지**: 반드시 시스템에 설치된 gradle을 직접 사용하거나 환경에 맞는 스크립트를 사용하세요
- **Gradle 명령어 변수화**: 위의 `GRADLE_CMD` 변수를 설정하면 환경별로 다른 gradle 명령어 사용 가능
### 주의사항
- **SafeDB**: 암호화 기능이 필요한 경우 `-Dkjb_safedb.mode=fake` 설정 (개발 환경) 또는 실제 SafeDB 라이브러리 설치
- **멀티 모듈 구조**: eapim-online의 여러 모듈들을 참조하므로 반드시 eapim-online도 클론해야 합니다
## 빌드 및 실행
### Gradle 명령어 설정
### Gradle 명령어
사용 환경에 따라 적절한 gradle 명령어를 설정하세요:
**Git Bash:**
```bash
# 일반 환경
export GRADLE_CMD="gradle"
# msi-gf63 환경
export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
```
**Windows CMD:**
```cmd
REM 일반 환경
set GRADLE_CMD=gradle
REM msi-gf63 환경
set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
```
프로젝트 루트의 Gradle wrapper(`./gradlew`) 를 사용합니다.
### 빌드 및 실행 명령어
```bash
# 표준 빌드
$GRADLE_CMD build
./gradlew build
# Weblogic 배포용 빌드 (테스트 제외)
$GRADLE_CMD build -x test -Pprofile=weblogic
./gradlew build -x test -Pprofile=weblogic
# WAR 파일 빌드
$GRADLE_CMD war
# WAR 파일 빌드[ems_stdout.log](../../logs/emsSvr-RinjaeMA/ems_stdout.log)
./gradlew war
# 클린 빌드
$GRADLE_CMD clean build
./gradlew clean build
# 테스트 실행
$GRADLE_CMD test
./gradlew test
# 패키징 없이 클래스만 컴파일
$GRADLE_CMD classes
./gradlew classes
# QueryDSL Q-classes 및 기타 애노테이션 생성
$GRADLE_CMD compileJava
./gradlew compileJava
# 의존성 트리 보기
$GRADLE_CMD dependencies
./gradlew dependencies
# 사용 가능한 모든 태스크 목록
$GRADLE_CMD tasks --all
./gradlew tasks --all
```
**주의**: gradlew 사용 금지 - offline gradle 단독 실행
## 프로젝트 구조
### 전체 프로젝트 구조
@@ -14,6 +14,7 @@ public class PortalApprovalAuthorizer implements ApprovalAuthorizer {
return approval.getApprovers().stream()
.filter(this::isCurrentApprover)
.map(this::getApproverId)
.filter(java.util.Objects::nonNull)
.anyMatch(id -> id.equals(approverId));
}
@@ -22,6 +23,6 @@ public class PortalApprovalAuthorizer implements ApprovalAuthorizer {
}
private String getApproverId(Approver approver) {
return approver.getUser().getId();
return approver.getUser() != null ? approver.getUser().getId() : null;
}
}
@@ -13,10 +13,16 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@RequiredArgsConstructor
public class PortalApprovalManController extends BaseAnnotationController {
@@ -24,8 +30,8 @@ public class PortalApprovalManController extends BaseAnnotationController {
private final PortalApprovalManService portalApprovalManService;
@GetMapping(value = "/onl/apim/approval/portalApprovalMan.view")
public void view() {
// view
public void view(Model model) {
model.addAttribute("hardDeleteEnabled", portalApprovalManService.isHardDeleteEnabled());
}
@GetMapping(value = "/onl/apim/approval/portalApprovalMan.view", params = "cmd=DETAIL")
@@ -77,6 +83,48 @@ public class PortalApprovalManController extends BaseAnnotationController {
return ResponseEntity.ok().build();
}
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=HARD_DELETE_MULTIPLE")
public ResponseEntity<Map<String, Object>> hardDeleteMultiple(String ids) {
Map<String, Object> resultMap = new HashMap<>();
try {
if (StringUtils.isBlank(ids)) {
resultMap.put("status", "fail");
resultMap.put("message", "삭제할 승인 요청을 선택해주세요.");
return ResponseEntity.ok(resultMap);
}
String[] idArray = ids.split(",");
int successCount = 0;
List<String> failMessages = new ArrayList<>();
for (String id : idArray) {
if (StringUtils.isNotBlank(id.trim())) {
try {
portalApprovalManService.hardDelete(id.trim());
successCount++;
} catch (BizException e) {
failMessages.add(e.getMessage());
} catch (Exception e) {
failMessages.add(id.trim() + ": " + e.getMessage());
}
}
}
if (failMessages.isEmpty()) {
resultMap.put("status", "success");
resultMap.put("message", successCount + "건의 승인 요청이 완전삭제되었습니다.");
} else {
resultMap.put("status", "fail");
resultMap.put("message", "완전삭제 실패: " + String.join(", ", failMessages)
+ (successCount > 0 ? " (" + successCount + "건 성공)" : ""));
}
} catch (Exception e) {
resultMap.put("status", "fail");
resultMap.put("message", "완전삭제 중 오류가 발생했습니다: " + e.getMessage());
}
return ResponseEntity.ok(resultMap);
}
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=UPDATE")
public ResponseEntity<Void> update(String id, String expectEndDate) {
portalApprovalManService.update(id, expectEndDate);
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.entity.ApprovalType;
import com.eactive.apim.portal.approval.statemachine.*;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.eai.data.entity.onl.apim.apigroup.ApiGroup;
@@ -25,6 +26,7 @@ import com.eactive.eai.rms.onl.apim.portalorg.PortalOrgManService;
import com.eactive.eai.rms.onl.apim.portaluser.PortalUserManService;
import com.eactive.eai.rms.onl.apim.portaluser.PortalUserUI;
import com.eactive.eai.rms.common.acl.user.ui.UserUI;
import com.eactive.eai.rms.onl.common.exception.BizException;
import com.eactive.eai.rms.onl.transaction.apim.ApiInterfaceService;
import com.eactive.eai.rms.onl.transaction.apim.mapping.ApiSpecUIMapper;
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiInterfaceUI;
@@ -36,6 +38,8 @@ import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.*;
@Service
@@ -43,6 +47,9 @@ import java.util.*;
@RequiredArgsConstructor
public class PortalApprovalManService extends BaseService {
private static final String PORTAL_PROPERTY_GROUP = "Portal";
private static final String HARD_DELETE_FLAG_KEY = "approval.hard-delete.enabled";
private final ApprovalService approvalService;
private final ApprovalStateMachine approvalStateMachine;
private final AppRequestRepository appRequestRepository;
@@ -59,6 +66,10 @@ public class PortalApprovalManService extends BaseService {
private final ApiGroupUIMapper apiGroupUIMapper;
private final PortalApprovalAuthorizer portalApprovalAuthorizer;
private final PortalOrgManService portalOrgManService;
private final PortalPropertyService portalPropertyService;
@PersistenceContext(unitName = "entityManagerFactoryForEMS")
private EntityManager entityManager;
public Page<PortalApprovalUI> selectList(Pageable pageable, PortalApprovalUISearch portalApprovalUISearch) {
Page<Approval> approval = approvalService.findAll(pageable, portalApprovalUISearch);
@@ -347,6 +358,28 @@ public class PortalApprovalManService extends BaseService {
}
public boolean isHardDeleteEnabled() {
Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP);
return Boolean.parseBoolean(properties.getOrDefault(HARD_DELETE_FLAG_KEY, "false"));
}
public void hardDelete(String id) {
if (!isHardDeleteEnabled()) {
throw new BizException("승인 요청 완전삭제 기능이 비활성화되어 있습니다. (PTL_PROPERTY: " + HARD_DELETE_FLAG_KEY + ")");
}
// Approval.approvers 가 EAGER + Approver.user 가 NotFound 처리 없음 → 사라진 UserInfo 로 인해
// findById/deleteById 가 hydration 단계에서 실패하는 row 가 존재. 네이티브 SQL 로 우회.
entityManager.createNativeQuery("DELETE FROM PTL_APPROVER WHERE APPROVAL_ID = :id")
.setParameter("id", id)
.executeUpdate();
int affected = entityManager.createNativeQuery("DELETE FROM PTL_APPROVAL WHERE ID = :id")
.setParameter("id", id)
.executeUpdate();
if (affected == 0) {
throw new BizException("승인 정보를 찾을 수 없습니다. ID: " + id);
}
}
public void sendEvent(String id, ApprovalEvent event, Map<String, Object> options) {
approvalService.findById(id).ifPresent(approval -> {
options.put("authorizer", portalApprovalAuthorizer);
@@ -6,7 +6,6 @@ import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.service.ApprovalDeployException;
import com.eactive.apim.portal.approval.statemachine.listener.ApprovalListener;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendService;
@@ -19,10 +18,8 @@ import com.eactive.eai.rms.onl.apim.approval.RandomStringGenerator;
import com.eactive.eai.rms.onl.apim.approval.credential.CredentialManService;
import com.eactive.eai.rms.onl.apim.approval.credential.CredentialUI;
import com.eactive.eai.rms.onl.apim.approval.credential.CredentialUIMapper;
import com.eactive.eai.rms.onl.apim.portaluser.PortalUserUI;
import com.eactive.eai.rms.onl.common.service.AgentUtilService;
import com.eactive.eai.rms.onl.manage.authserver.client.ClientManService;
import com.eactive.eai.rms.onl.manage.authserver.client.ClientUI;
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiSpecInfoUI;
import java.util.Arrays;
import java.util.Collections;
@@ -35,22 +32,12 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang.StringUtils;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.HttpClientBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
@Service
@RequiredArgsConstructor
@@ -116,9 +103,15 @@ public class PortalAppApprovalListener implements ApprovalListener {
}
private void sendApprovalResult(AppRequest appRequest) {
com.eactive.apim.portal.portaluser.entity.PortalUser requester =
appRequest.getApproval() != null ? appRequest.getApproval().getRequester() : null;
if (requester == null) {
logger.warn("승인 결과 통지 생략 - 요청자 정보 없음. appRequestId: {}", appRequest.getId());
return;
}
MessageRecipient recipient = new MessageRecipient();
recipient.setPhone(appRequest.getApproval().getRequester().getMobileNumber());
recipient.setUserId(appRequest.getApproval().getRequester().getEmailAddr());
recipient.setPhone(requester.getMobileNumber());
recipient.setUserId(requester.getEmailAddr());
Map<String, String> params = new HashMap<>();
params.put("apiKey", appRequest.getClientName());
messageSendService.sendMessage(MessageCode.APP_REGISTER_APPROVED, recipient, params);
@@ -165,8 +158,8 @@ public class PortalAppApprovalListener implements ApprovalListener {
credentialUI.setClientname(appRequest.getClientName());
credentialUI.setScope("api");
credentialUI.setGranttypes("client_credentials");
credentialUI.setOrgid(appRequest.getOrg().getId());
credentialUI.setOrgname(appRequest.getOrg().getOrgName());
credentialUI.setOrgid(appRequest.getOrg() != null ? appRequest.getOrg().getId() : null);
credentialUI.setOrgname(appRequest.getOrg() != null ? appRequest.getOrg().getOrgName() : null);
credentialUI.setAllowedips(appRequest.getIpWhitelist());
credentialUI.setRedirecturi(appRequest.getCallbackUrl());
credentialUI.setAppIconFileId(appRequest.getAppIconFileId());
@@ -0,0 +1,11 @@
package com.eactive.eai.rms.onl.apim.portalnotice;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class IncidentAffectedApiUI {
private String apiId;
private String apiName;
}
@@ -1,5 +1,13 @@
package com.eactive.eai.rms.onl.apim.portalnotice;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApiId;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.file.entity.FileDetail;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
@@ -23,7 +31,12 @@ import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Transactional(transactionManager = "transactionManagerForEMS")
@@ -32,6 +45,9 @@ public class PortalNoticeManService extends BaseService {
private final PortalNoticeService portalNoticeService;
private final PortalNoticeUIMapper portalNoticeUIMapper;
private final FileService fileService;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
private String decodeString(String value) {
if (ContainerUtil.get() == ContainerUtil.TOMCAT) {
@@ -48,10 +64,27 @@ public class PortalNoticeManService extends BaseService {
@Autowired
public PortalNoticeManService(PortalNoticeService portalNoticeService,
PortalNoticeUIMapper portalNoticeUIMapper,
FileService fileService){
FileService fileService,
DjbApistatusIncidentRepository incidentRepository,
DjbApistatusIncidentApiRepository incidentApiRepository,
DjbApistatusIncidentTimelineRepository incidentTimelineRepository){
this.portalNoticeService = portalNoticeService;
this.portalNoticeUIMapper = portalNoticeUIMapper;
this.fileService = fileService;
this.incidentRepository = incidentRepository;
this.incidentApiRepository = incidentApiRepository;
this.incidentTimelineRepository = incidentTimelineRepository;
}
private static boolean isIncidentKind(String noticeType) {
return PortalNoticeUI.NOTICE_TYPE_INCIDENT.equals(noticeType)
|| PortalNoticeUI.NOTICE_TYPE_MAINTENANCE.equals(noticeType);
}
private static IncidentKind toKind(String noticeType) {
if (PortalNoticeUI.NOTICE_TYPE_INCIDENT.equals(noticeType)) return IncidentKind.INCIDENT;
if (PortalNoticeUI.NOTICE_TYPE_MAINTENANCE.equals(noticeType)) return IncidentKind.MAINTENANCE;
return null;
}
@@ -89,9 +122,39 @@ public class PortalNoticeManService extends BaseService {
PortalNoticeUI ui = portalNoticeUIMapper.toVo(portalNotice);
setFileInfo(portalNotice, ui);
populateIncident(portalNotice, ui);
return ui;
}
private void populateIncident(PortalNotice portalNotice, PortalNoticeUI ui) {
if (!isIncidentKind(portalNotice.getNoticeType())) {
ui.setAffectedApis(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(portalNotice.getId());
if (!incidentOpt.isPresent()) {
ui.setAffectedApis(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
ui.setIncidentId(incident.getIncidentId());
ui.setSummary(incident.getSummary());
ui.setStartedAt(incident.getStartedAt());
ui.setEndAt(incident.getEndAt());
ui.setState(incident.getState() == null ? null : incident.getState().name());
ui.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiUI> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> {
IncidentAffectedApiUI vo = new IncidentAffectedApiUI();
vo.setApiId(api.getApiId());
vo.setApiName(api.getApiName());
return vo;
}).collect(Collectors.toList());
ui.setAffectedApis(apis);
}
public void insert(PortalNoticeUI portalNoticeUI) throws IOException {
//portalNoticeUI.setNoticeSubject(decodeString(portalNoticeUI.getNoticeSubject()));
//portalNoticeUI.setNoticeDetail(decodeString(StringEscapeUtils.unescapeHtml(portalNoticeUI.getNoticeDetail())));
@@ -106,6 +169,61 @@ public class PortalNoticeManService extends BaseService {
}
portalNoticeService.save(portalNotice);
if (isIncidentKind(portalNoticeUI.getNoticeType())) {
persistIncident(portalNoticeUI, portalNotice, true);
}
}
private void persistIncident(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice, boolean isCreate) {
IncidentKind kind = toKind(portalNoticeUI.getNoticeType());
DjbApistatusIncident incident = incidentRepository.findByNoticeId(portalNotice.getId())
.orElseGet(DjbApistatusIncident::new);
incident.setKind(kind);
incident.setTitle(portalNotice.getNoticeSubject());
incident.setSummary(portalNoticeUI.getSummary());
incident.setStartedAt(portalNoticeUI.getStartedAt() != null
? portalNoticeUI.getStartedAt() : LocalDateTime.now());
incident.setEndAt(portalNoticeUI.getEndAt());
incident.setDetectedBy("MANUAL");
incident.setNoticeId(portalNotice.getId());
incident.setDraftYn("N");
incident.setFixYn(StringUtils.defaultIfBlank(portalNotice.getFixYn(), "N"));
if (kind == IncidentKind.INCIDENT) {
IncidentState newState = StringUtils.isNotBlank(portalNoticeUI.getState())
? IncidentState.valueOf(portalNoticeUI.getState())
: IncidentState.INVESTIGATING;
if (!isCreate && incident.getState() != newState) {
incident.setPreviousState(incident.getState());
}
incident.setState(newState);
} else {
incident.setState(null);
incident.setPreviousState(null);
}
DjbApistatusIncident saved = incidentRepository.save(incident);
// 영향 API 동기화 — 단순 전체 삭제 후 재삽입
incidentApiRepository.deleteByIncidentId(saved.getIncidentId());
List<IncidentAffectedApiUI> affected = portalNoticeUI.getAffectedApis();
if (affected != null && !affected.isEmpty()) {
List<DjbApistatusIncidentApi> rows = new ArrayList<>();
for (IncidentAffectedApiUI src : affected) {
if (StringUtils.isBlank(src.getApiId())) continue;
DjbApistatusIncidentApi api = new DjbApistatusIncidentApi();
api.setIncidentId(saved.getIncidentId());
api.setApiId(src.getApiId());
api.setApiName(src.getApiName());
rows.add(api);
}
if (!rows.isEmpty()) {
incidentApiRepository.saveAll(rows);
}
}
}
private void handleFileUpload(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice) throws IOException {
@@ -129,6 +247,7 @@ public class PortalNoticeManService extends BaseService {
portalNoticeUI.setNoticeDetail(StringEscapeUtils.unescapeHtml(portalNoticeUI.getNoticeDetail()));
PortalNotice portalNotice = portalNoticeService.getById(portalNoticeUI.getId());
String previousNoticeType = portalNotice.getNoticeType();
if (portalNoticeUI.getFiles() != null && !portalNoticeUI.getFiles().isEmpty()) {
handleFileUpload(portalNoticeUI, portalNotice);
@@ -139,11 +258,27 @@ public class PortalNoticeManService extends BaseService {
portalNoticeUIMapper.updateToEntity(portalNoticeUI, portalNotice);
portalNoticeService.save(portalNotice);
if (isIncidentKind(portalNotice.getNoticeType())) {
persistIncident(portalNoticeUI, portalNotice, false);
} else if (isIncidentKind(previousNoticeType)) {
// 일반 공지로 유형 변경 → 기존 incident 정리
deleteIncidentIfExists(portalNotice.getId());
}
}
private void deleteIncidentIfExists(String noticeId) {
incidentRepository.findByNoticeId(noticeId).ifPresent(incident -> {
incidentApiRepository.deleteByIncidentId(incident.getIncidentId());
incidentTimelineRepository.deleteByIncidentId(incident.getIncidentId());
incidentRepository.delete(incident);
});
}
public void delete(String id) {
PortalNotice portalNotice = portalNoticeService.getById(id);
Optional.ofNullable(portalNotice.getFileId()).ifPresent(fileService::deleteFile);
deleteIncidentIfExists(id);
portalNoticeService.deleteById(id);
}
@@ -8,11 +8,16 @@ import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class PortalNoticeUI {
public static final String NOTICE_TYPE_NORMAL = "1";
public static final String NOTICE_TYPE_MAINTENANCE = "2";
public static final String NOTICE_TYPE_INCIDENT = "3";
private String id;
private String noticeSubject;
@@ -53,4 +58,23 @@ public class PortalNoticeUI {
private boolean hasAttachment; // 첨부파일 여부
private boolean fileDeleted; // 파일 삭제 여부
// ───── 장애/점검(djb_apistatus_incident) 연동 필드 ─────
private Long incidentId;
private String summary;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
private LocalDateTime startedAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
private LocalDateTime endAt;
private String state; // INCIDENT 한정 (INVESTIGATING/IDENTIFIED/MONITORING/RESOLVED/CANCELED)
private String previousState; // 직전 상태
private List<IncidentAffectedApiUI> affectedApis;
}
@@ -3,7 +3,9 @@ package com.eactive.eai.rms.onl.apim.portalnotice;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import org.mapstruct.*;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE,
unmappedSourcePolicy = ReportingPolicy.IGNORE)
public interface PortalNoticeUIMapper {
PortalNoticeUI toVo(PortalNotice entity);
@@ -27,6 +27,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
@@ -48,6 +50,9 @@ public class PortalOrgManService extends BaseService {
private final PortalUserCorpManagerUIMapper portalUserCorpManagerUIMapper;
private final FileService fileService;
@PersistenceContext(unitName = "entityManagerFactoryForEMS")
private EntityManager entityManager;
public Page<PortalOrgUI> selectList(Pageable pageable, PortalOrgUISearch portalOrgUISearch) {
Page<PortalOrg> portalOrg = portalOrgService.findAll(pageable, portalOrgUISearch);
return portalOrg.map(entity -> convertToUIWithMaskOption(entity, true));
@@ -146,19 +151,37 @@ public class PortalOrgManService extends BaseService {
throw new BizException("삭제(탈퇴) 상태인 법인만 완전삭제할 수 있습니다.");
}
List<String> dependencies = new ArrayList<>();
// 사용자(PortalUser)는 별도 화면에서 정리해야 하므로 cascade 하지 않고 차단
long userCount = portalUserService.countByOrgId(id);
if (userCount > 0) dependencies.add("사용자 " + userCount + "");
long appRequestCount = portalOrgService.countAppRequestByOrgId(id);
if (appRequestCount > 0) dependencies.add("앱 신청 " + appRequestCount + "");
if (!dependencies.isEmpty()) {
throw new BizException("의존 데이터가 존재하여 완전삭제할 수 없습니다: " + String.join(", ", dependencies));
if (userCount > 0) {
throw new BizException("의존 데이터가 존재하여 완전삭제할 수 없습니다: 사용자 " + userCount + "");
}
// 앱 신청(AppRequest) + 연결된 승인(Approval/Approver) 은 cascade 삭제
cascadeDeleteAppRequestsByOrg(id);
portalOrgService.deleteById(id);
}
private void cascadeDeleteAppRequestsByOrg(String orgId) {
// 1) 해당 org 의 app_request 가 참조하는 approval 의 approver 제거
entityManager.createNativeQuery(
"DELETE FROM PTL_APPROVER WHERE APPROVAL_ID IN " +
"(SELECT APPROVAL_ID FROM PTL_APP_REQUEST WHERE ORG_ID = :orgId AND APPROVAL_ID IS NOT NULL)"
).setParameter("orgId", orgId).executeUpdate();
// 2) approval 제거
entityManager.createNativeQuery(
"DELETE FROM PTL_APPROVAL WHERE ID IN " +
"(SELECT APPROVAL_ID FROM PTL_APP_REQUEST WHERE ORG_ID = :orgId AND APPROVAL_ID IS NOT NULL)"
).setParameter("orgId", orgId).executeUpdate();
// 3) app_request 제거
entityManager.createNativeQuery(
"DELETE FROM PTL_APP_REQUEST WHERE ORG_ID = :orgId"
).setParameter("orgId", orgId).executeUpdate();
}
public Map<String, Object> selectDetailWithUsers(String id) {
return getDetailWithUsers(id, true);
}
@@ -1,79 +0,0 @@
package com.eactive.eai.rms.onl.common.util;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Logger;
import com.eactive.eai.rms.onl.common.service.OnlAgentUtilServiceImpl;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.Configuration;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1EndpointAddress;
import io.kubernetes.client.openapi.models.V1EndpointSubset;
import io.kubernetes.client.openapi.models.V1Endpoints;
import io.kubernetes.client.util.ClientBuilder;
public class K8sUtil {
private static final Logger logger = Logger.getLogger(K8sUtil.class);
public static List<Map<String, String>> getServerInfoByK8sUrl(String namespace, String serviceName, int port, String extUrl) throws Exception {
List<Map<String, String>> resultList = getServerInfoByK8sApi(namespace,serviceName);
for (Map<String,String> map : resultList) {
String EAISEVRINSTNCNAME = map.get("EAISEVRINSTNCNAME");
String EAISEVRIP = map.get("EAISEVRIP");
Map<String, String> serverInfo = new HashMap<>();
serverInfo.put(OnlAgentUtilServiceImpl.FIELD_URL, new StringBuilder().append("http://" + EAISEVRIP + ":"+String.valueOf(port) + extUrl).toString());
serverInfo.put(OnlAgentUtilServiceImpl.FIELD_INST_NAME, EAISEVRINSTNCNAME);
}
return resultList;
}
public static List<Map<String, String>> getServerInfoByK8sApi(String namespace, String serviceName) throws Exception {
logger.info("K8sAgentUtilService getServerInfoByK8sApi start.");
List<Map<String, String>> resultList = new ArrayList<>();
try {
ApiClient client = ClientBuilder.cluster().build();
Configuration.setDefaultApiClient(client);
if (StringUtils.isBlank(namespace)) {
new RuntimeException("Can't find kubernetes_namespace in Engine Property");
}
if (StringUtils.isBlank(serviceName)) {
new RuntimeException("Can't find kubernetes_servicename in Engine Property");
}
logger.info("getServerInfoByK8sApi Codes [ namespace: " + namespace + ", serviceName:" + serviceName + " ]");
V1Endpoints endpoints = new CoreV1Api().readNamespacedEndpoints(serviceName, namespace, null);
for (V1EndpointSubset subset : endpoints.getSubsets()) {
for (V1EndpointAddress address : subset.getAddresses()) {
Map<String, String> serverInfo = new HashMap<>();
serverInfo.put("EAISEVRINSTNCNAME", address.getTargetRef().getName());
serverInfo.put("EAISEVRIP", address.getIp());
resultList.add(serverInfo);
}
}
logger.info("getServerInfoByK8sApi resultList [ " + resultList.toString() + " ]");
} catch (Exception e) {
logger.error(e);
throw new RuntimeException("K8S Mode로 AgentUtilService를 기동중 실패하였습니다.", e);
}
return resultList;
}
}
@@ -137,7 +137,7 @@ JSP `detail()` 함수에서 제네릭 루프 대신 명시적 필드 매핑으
## 미완료 / 다음 작업
- [ ] `kjb-gradle.sh compileJava` 실행 → `QCryptoModuleConfig` Q-class 생성 확인
- [ ] `./gradlew compileJava` 실행 → `QCryptoModuleConfig` Q-class 생성 확인
- [ ] 관리자 메뉴 테이블에 경로 등록 (`/onl/admin/security/cryptoModuleMan.view`)
- [ ] 화면 접근 권한(ACL) 등록
- [ ] 전체 파일 커밋 (eapim-admin)