Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a92fc85730 | |||
| 317634b5b7 | |||
| 27fa79ddd9 | |||
| ccc55fd015 | |||
| 3bf8370d0c | |||
| 46bd79bc36 | |||
| cdc1a6f342 | |||
| 42d7fa977e | |||
| cc0ef13d5d | |||
| 6604f7c9e8 | |||
| cd8f826ba5 | |||
| 031796dac4 | |||
| f091b9ab19 | |||
| f47480674d | |||
| cd20aeee2a | |||
| 0365f4342e | |||
| 0576fd4c6e | |||
| 8215b596fe | |||
| fc6b96d7cd | |||
| 5fe247df65 | |||
| d11f7d9f38 | |||
| 889fd1f9f5 | |||
| c0844e8d36 | |||
| db38b8f6e9 | |||
| 8abe0a8585 | |||
| ca8d46c7b8 | |||
| 628ab01c13 | |||
| f10f5ce08e | |||
| 90ba584e4b | |||
| e59f64653f | |||
| 2422c7f01e | |||
| 29025d5f47 | |||
| e91b12f2d7 | |||
| da90a3a002 | |||
| a4614ca7fd | |||
| 2d414dc31b | |||
| 775d69eb53 | |||
| 8f4162569d | |||
| adc5371df8 | |||
| e7297458f6 |
Vendored
+15
@@ -90,6 +90,21 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-admin-sbom.xlsx 를 아티팩트로 보관.
|
||||||
|
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
|
||||||
|
stage('SBOM') {
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||||
|
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ PortalPropertyController_포탈 프로퍼티 관리_APIGW_UPDATE
|
|||||||
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
|
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
|
||||||
MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS
|
MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS
|
||||||
PortalInquiryManController_Q&A문의관리_APIGW_VISIBILITY
|
PortalInquiryManController_Q&A문의관리_APIGW_VISIBILITY
|
||||||
|
PortalMenuManController_포탈메뉴관리_APIGW_INSERT,UPDATE,DELETE,TRANSACTION_PLACEMENT,INITIALIZE,TRANSACTION_RELOAD
|
||||||
|
|||||||
@@ -349,8 +349,9 @@
|
|||||||
}
|
}
|
||||||
function fieldRow(label, controlHtml, opts) {
|
function fieldRow(label, controlHtml, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
const rowSpacing = opts.bordered ? 'px-3 py-3' : 'mb-3';
|
||||||
return `
|
return `
|
||||||
<div class="grid grid-cols-12 items-start gap-3 mb-3">
|
<div class="grid grid-cols-12 items-start gap-3 ${rowSpacing}">
|
||||||
<label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}">
|
<label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}">
|
||||||
${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''}
|
${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''}
|
||||||
${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''}
|
${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''}
|
||||||
@@ -380,7 +381,7 @@
|
|||||||
`<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded">
|
`<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded">
|
||||||
<span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span>
|
<span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span>
|
||||||
<code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code>
|
<code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code>
|
||||||
<span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이에서 자동 동기화</span>
|
<span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이 설정</span>
|
||||||
</div>` +
|
</div>` +
|
||||||
fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) +
|
fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) +
|
||||||
fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) +
|
fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) +
|
||||||
@@ -1023,24 +1024,26 @@
|
|||||||
root.innerHTML =
|
root.innerHTML =
|
||||||
card(
|
card(
|
||||||
sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
|
sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
|
||||||
|
'<div class="border border-slate-200 rounded-lg divide-y divide-slate-200">' +
|
||||||
fieldRow('API 그룹',
|
fieldRow('API 그룹',
|
||||||
`<div class="flex flex-col gap-1">
|
`<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span>
|
<span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span>
|
||||||
<button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
|
<button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-xs text-slate-400">API 그룹을 설정하지 않으면 포탈에 게시되지 않습니다.</div>
|
||||||
<div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div>
|
<div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div>
|
||||||
</div>`) +
|
</div>`, { bordered: true }) +
|
||||||
fieldRow('공개 여부', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 공개</label>`) +
|
fieldRow('포탈 게시 활성화', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 활성화</label>`, { bordered: true }) +
|
||||||
fieldRow('공개 권한',
|
fieldRow('역할별 공개 제한',
|
||||||
`<div class="flex flex-col gap-1">
|
`<div class="flex flex-col gap-1">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label>
|
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label>
|
||||||
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label>
|
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label>
|
||||||
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label>
|
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400">전부 선택 시 로그인 사용자 전체에게 공개됩니다.</div>
|
<div class="text-xs text-slate-400">선택시 비 로그인 사용자에게 공개 되지 않습니다.</div>
|
||||||
</div>`) +
|
</div>`, { bordered: true }) +
|
||||||
(showOrg ? fieldRow('공개 법인',
|
(showOrg ? fieldRow('공개 법인',
|
||||||
`<div class="flex flex-col gap-1">
|
`<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
@@ -1048,7 +1051,7 @@
|
|||||||
<button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
|
<button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div>
|
<div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div>
|
||||||
</div>`) : '') +
|
</div>`, { bordered: true }) : '') +
|
||||||
fieldRow('응답 유형',
|
fieldRow('응답 유형',
|
||||||
'<div>' +
|
'<div>' +
|
||||||
['sample', 'mock', 'gw'].map(v =>
|
['sample', 'mock', 'gw'].map(v =>
|
||||||
@@ -1060,20 +1063,23 @@
|
|||||||
+ `<div class="mt-1 text-xs text-slate-500">호출 방식: <b>${useProxyOn() ? '포탈 프록시 경유' : '직접 호출'}</b>`
|
+ `<div class="mt-1 text-xs text-slate-500">호출 방식: <b>${useProxyOn() ? '포탈 프록시 경유' : '직접 호출'}</b>`
|
||||||
+ (useProxyOn() ? ' (<code class="font-mono">/api/call-api</code> 경유, 인증 헤더 자동 주입)' : ' (브라우저 → 대상 직접 호출, 대상 CORS 허용 필요)') + `</div>`) +
|
+ (useProxyOn() ? ' (<code class="font-mono">/api/call-api</code> 경유, 인증 헤더 자동 주입)' : ' (브라우저 → 대상 직접 호출, 대상 CORS 허용 필요)') + `</div>`) +
|
||||||
(rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') +
|
(rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') +
|
||||||
'</div>') +
|
'</div>', { bordered: true }) +
|
||||||
(rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com/api/v1/foo' }), { hint: 'Mock 응답을 제공할 전체 호출 URL(경로 포함). 응답 유형이 Mock 일 때 이 주소로 그대로 호출하며, 어댑터/오퍼레이션 경로를 뒤에 덧붙이지 않습니다.' }) : '')
|
(rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com/api/v1/foo' }), { hint: 'Mock 응답을 제공할 전체 호출 URL(경로 포함). 응답 유형이 Mock 일 때 이 주소로 그대로 호출하며, 어댑터/오퍼레이션 경로를 뒤에 덧붙이지 않습니다.', bordered: true }) : '') +
|
||||||
|
'</div>'
|
||||||
) +
|
) +
|
||||||
card(
|
card(
|
||||||
sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') +
|
sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') +
|
||||||
'<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>'
|
'<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>'
|
||||||
) +
|
) +
|
||||||
|
// 내보내기 카드는 PTL_PROPERTY(djb.openapi.export.enabled=true) 일 때만 노출. 기본 미노출.
|
||||||
|
((window.DJB_CTX && window.DJB_CTX.exportEnabled) ?
|
||||||
card(
|
card(
|
||||||
sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
|
sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
|
||||||
`<div class="flex gap-2">
|
`<div class="flex gap-2">
|
||||||
<button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button>
|
<button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button>
|
||||||
<button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button>
|
<button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button>
|
||||||
</div>`
|
</div>`
|
||||||
);
|
) : '');
|
||||||
initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용)
|
initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,7 +1659,7 @@
|
|||||||
$('#btn-prev').addEventListener('click', () => goStep(state.step - 1));
|
$('#btn-prev').addEventListener('click', () => goStep(state.step - 1));
|
||||||
$('#btn-next').addEventListener('click', () => {
|
$('#btn-next').addEventListener('click', () => {
|
||||||
if (state.step < STEPS.length) goStep(state.step + 1);
|
if (state.step < STEPS.length) goStep(state.step + 1);
|
||||||
else toast('모든 단계를 완료했습니다. 우측 미리보기 또는 내보내기를 사용하세요.');
|
else saveSpec(false); // 마지막 단계의 "완료" → 저장 동작
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false));
|
$('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false));
|
||||||
@@ -1686,14 +1692,18 @@
|
|||||||
toast('변경을 취소했습니다');
|
toast('변경을 취소했습니다');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#btn-export').addEventListener('click', () => $('#export-menu').classList.toggle('hidden'));
|
// 내보내기 버튼은 djb.openapi.export.enabled=true 일 때만 DOM 에 존재 → 없으면 바인딩 생략
|
||||||
document.addEventListener('click', e => {
|
var bExport = $('#btn-export');
|
||||||
if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden');
|
if (bExport) {
|
||||||
});
|
bExport.addEventListener('click', () => $('#export-menu').classList.toggle('hidden'));
|
||||||
$('#export-menu').addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
const b = e.target.closest('[data-export]');
|
if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden');
|
||||||
if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); }
|
});
|
||||||
});
|
$('#export-menu').addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('[data-export]');
|
||||||
|
if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false));
|
var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false));
|
||||||
var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate);
|
var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate);
|
||||||
@@ -1996,12 +2006,16 @@
|
|||||||
if (res.responseType) pp.responseType = res.responseType;
|
if (res.responseType) pp.responseType = res.responseType;
|
||||||
if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl);
|
if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl);
|
||||||
if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } }
|
if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } }
|
||||||
|
// 설명(API 소개 페이지) — spec 에 미포함, description 컬럼에 별도 저장 → 별도 필드로 복원
|
||||||
|
if (res.description) state.data.info.description = wrap(decodeEntities(res.description));
|
||||||
// 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성
|
// 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성
|
||||||
state.data.msgSpec = state.data.msgSpec || { request: '', response: '' };
|
state.data.msgSpec = state.data.msgSpec || { request: '', response: '' };
|
||||||
if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec);
|
if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec);
|
||||||
if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec);
|
if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec);
|
||||||
// 저장본이 아니면(자동생성/최초 생성) 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성
|
// 저장본이 아니면(최초 작성) 자동생성 여부를 confirm 으로 확인 후 진행
|
||||||
if ((res.source || '') !== 'saved') frontAutoGenerate();
|
if ((res.source || '') !== 'saved') {
|
||||||
|
if (window.confirm('저장된 스펙이 없습니다. 게이트웨이 레이아웃 기반으로 예제·설명자료를 자동 생성할까요?')) frontAutoGenerate();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(e => console.error('loadInitialData', e));
|
.catch(e => console.error('loadInitialData', e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,8 +184,9 @@ function getEndTime(){
|
|||||||
</td>
|
</td>
|
||||||
<th style="width:180px;"><%= localeMessage.getString("auditLogMan.remoteAddress") %></th>
|
<th style="width:180px;"><%= localeMessage.getString("auditLogMan.remoteAddress") %></th>
|
||||||
<td><input type="text" name="searchRemoteAddress" value="${param.searchRemoteAddress}" style="width:100%"></td>
|
<td><input type="text" name="searchRemoteAddress" value="${param.searchRemoteAddress}" style="width:100%"></td>
|
||||||
<th style="width:180px;"></th>
|
<th style="width:180px;"><%= localeMessage.getString("auditLogMan.parameters") %></th>
|
||||||
<td>
|
<td>
|
||||||
|
<input type="text" name="searchParameters" value="${param.searchParameters}" style="width:100%">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
<script language="javascript" >
|
<script language="javascript" >
|
||||||
var url = '<c:url value="/common/acl/user/userMan.json" />';
|
var url = '<c:url value="/common/acl/user/userMan.json" />';
|
||||||
var url_view = '<c:url value="/common/acl/user/userMan.view" />';
|
var url_view = '<c:url value="/common/acl/user/userMan.view" />';
|
||||||
|
|
||||||
|
function userStatusFormat (cellvalue){
|
||||||
|
if ( cellvalue == '1' ){
|
||||||
|
return '<%= localeMessage.getString("code.normal") %>';
|
||||||
|
}else if ( cellvalue == '2' ) {
|
||||||
|
return '<span style="color:red"><%= localeMessage.getString("code.lock") %></span>';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
|
var gridPostData = getSearchForJqgrid("cmd","LIST"); //jqgrid에서는 object 로
|
||||||
$('#grid').jqGrid({
|
$('#grid').jqGrid({
|
||||||
@@ -28,16 +38,20 @@ $(document).ready(function() {
|
|||||||
colNames:['<%= localeMessage.getString("login.userId") %>',
|
colNames:['<%= localeMessage.getString("login.userId") %>',
|
||||||
'<%= localeMessage.getString("user.name") %>',
|
'<%= localeMessage.getString("user.name") %>',
|
||||||
'<%= localeMessage.getString("user.roleName") %>',
|
'<%= localeMessage.getString("user.roleName") %>',
|
||||||
|
'<%= localeMessage.getString("user.status") %>',
|
||||||
'<%= localeMessage.getString("user.createOn") %>',
|
'<%= localeMessage.getString("user.createOn") %>',
|
||||||
|
'<%= localeMessage.getString("user.lastLoginYms") %>',
|
||||||
'<%= localeMessage.getString("user.depart") %>',
|
'<%= localeMessage.getString("user.depart") %>',
|
||||||
'<%= localeMessage.getString("user.teamName") %>',
|
'<%= localeMessage.getString("user.teamName") %>',
|
||||||
'<%= localeMessage.getString("user.position") %>'
|
'<%= localeMessage.getString("user.position") %>'
|
||||||
],
|
],
|
||||||
colModel:[
|
colModel:[
|
||||||
{ name : 'USERID' , align:'center' ,sortable:false },
|
{ name : 'USERID' , align:'center', width: 100, sortable:false },
|
||||||
{ name : 'USERNAME' , align:'center' },
|
{ name : 'USERNAME' , align:'center', width: 100 },
|
||||||
{ name : 'ROLEIDNFINAME' , align:'center' },
|
{ name : 'ROLEIDNFINAME' , align:'center', width: 300 },
|
||||||
{ name : 'LASTAMNDYMS' , align:'center', formatter: timeStampFormat },
|
{ name : 'STATUS' , align:'center', width: 80, formatter: userStatusFormat },
|
||||||
|
{ name : 'REGDYMS' , align:'center', width: 120, formatter: timeStampFormat },
|
||||||
|
{ name : 'LASTLOGINYMS' , align:'center', width: 120, formatter: timeStampFormat },
|
||||||
{ name : 'DVSNNAME' , align:'center', hidden: true },
|
{ name : 'DVSNNAME' , align:'center', hidden: true },
|
||||||
{ name : 'TEAMNAME' , align:'center', hidden: true },
|
{ name : 'TEAMNAME' , align:'center', hidden: true },
|
||||||
{ name : 'JOBTLNAME' , align:'center', hidden: true }
|
{ name : 'JOBTLNAME' , align:'center', hidden: true }
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ function detail(key){
|
|||||||
$(tag+"[name="+name+"]").val(data[name.toUpperCase()] ? data[name.toUpperCase()].trim():"");
|
$(tag+"[name="+name+"]").val(data[name.toUpperCase()] ? data[name.toUpperCase()].trim():"");
|
||||||
});
|
});
|
||||||
|
|
||||||
$("input[name=LASTAMNDYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true});
|
$("input[name=REGDYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true});
|
||||||
|
$("input[name=LASTLOGINYMS]").inputmask("9999-99-99 99:99:99",{'autoUnmask':true});
|
||||||
/* $("input[name=SECONDMENTSTDT]").inputmask("9999-99-99",{'autoUnmask':true});
|
/* $("input[name=SECONDMENTSTDT]").inputmask("9999-99-99",{'autoUnmask':true});
|
||||||
$("input[name=SECONDMENTENDT]").inputmask("9999-99-99",{'autoUnmask':true}); */
|
$("input[name=SECONDMENTENDT]").inputmask("9999-99-99",{'autoUnmask':true}); */
|
||||||
|
|
||||||
@@ -216,6 +217,7 @@ $(document).ready(function() {
|
|||||||
dataType:"json",
|
dataType:"json",
|
||||||
data:{cmd: 'UPDATE_PASSWORD', userId : $("input[name=userId]").val()},
|
data:{cmd: 'UPDATE_PASSWORD', userId : $("input[name=userId]").val()},
|
||||||
success:function(json){
|
success:function(json){
|
||||||
|
$('select[name="status"]').val("1")
|
||||||
alert(json.result);
|
alert(json.result);
|
||||||
},
|
},
|
||||||
error:function(e){
|
error:function(e){
|
||||||
@@ -277,10 +279,10 @@ $(document).ready(function() {
|
|||||||
<table class="table_row" cellspacing="0">
|
<table class="table_row" cellspacing="0">
|
||||||
<input type="hidden" name="jobclcd">
|
<input type="hidden" name="jobclcd">
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:180px;"><%= localeMessage.getString("login.userId") %></th><td ><input type="text" name="userId"/> </td>
|
<th style="width:180px;"><%= localeMessage.getString("login.userId") %> <span class=\"required-mark\">*</span></th><td ><input type="text" name="userId"/> </td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><%= localeMessage.getString("user.name") %> </th><td><input type="text" name="userName" /></td>
|
<th><%= localeMessage.getString("user.name") %> <span class=\"required-mark\">*</span></th><td><input type="text" name="userName" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="datailT">
|
<tr class="datailT">
|
||||||
<th><%= localeMessage.getString("user.auth") %> </th><td><input type="text" name="roleidnfiname" /></td>
|
<th><%= localeMessage.getString("user.auth") %> </th><td><input type="text" name="roleidnfiname" /></td>
|
||||||
@@ -317,7 +319,6 @@ $(document).ready(function() {
|
|||||||
<td>
|
<td>
|
||||||
<div class="select-style">
|
<div class="select-style">
|
||||||
<select name="status">
|
<select name="status">
|
||||||
<option value=""><%= localeMessage.getString("code.normal") %></option>
|
|
||||||
<option value="1"><%= localeMessage.getString("code.normal") %></option>
|
<option value="1"><%= localeMessage.getString("code.normal") %></option>
|
||||||
<option value="2"><%= localeMessage.getString("code.lock") %></option>
|
<option value="2"><%= localeMessage.getString("code.lock") %></option>
|
||||||
</select>
|
</select>
|
||||||
@@ -334,7 +335,10 @@ $(document).ready(function() {
|
|||||||
<th><%= localeMessage.getString("user.fieldAgentName") %> </th><td><input type="text" name="spotprxyname" /></td>
|
<th><%= localeMessage.getString("user.fieldAgentName") %> </th><td><input type="text" name="spotprxyname" /></td>
|
||||||
</tr> --%>
|
</tr> --%>
|
||||||
<tr>
|
<tr>
|
||||||
<th><%= localeMessage.getString("user.createOn") %> </th><td><input type="text" name="LASTAMNDYMS" readonly/></td>
|
<th><%= localeMessage.getString("user.createOn") %> </th><td><input type="text" name="REGDYMS" readonly/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><%= localeMessage.getString("user.lastLoginYms") %> </th><td><input type="text" name="LASTLOGINYMS" readonly/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<%-- <tr>
|
<%-- <tr>
|
||||||
<th><%= localeMessage.getString("user.secondmentbrncd") %> </th><td><input type="text" name="SECONDMENTBRNCD" readonly/></td>
|
<th><%= localeMessage.getString("user.secondmentbrncd") %> </th><td><input type="text" name="SECONDMENTBRNCD" readonly/></td>
|
||||||
|
|||||||
@@ -566,8 +566,8 @@
|
|||||||
</h1><!-- /monitoring/images/top_logo.png -->
|
</h1><!-- /monitoring/images/top_logo.png -->
|
||||||
<div class="topmenu_box">
|
<div class="topmenu_box">
|
||||||
<ul>
|
<ul>
|
||||||
<li style="width:240px;" id="newDashboardShow">
|
<li style="width:240px;">
|
||||||
<a style="width:240px;"
|
<a style="width:240px; cursor: default"
|
||||||
href="#"><span><%=SessionManager.getUserName(request) %>(<%=SessionManager.getUserId(request) %>-<%=System.getProperty("inst.Name") %>)</span>
|
href="#"><span><%=SessionManager.getUserName(request) %>(<%=SessionManager.getUserId(request) %>-<%=System.getProperty("inst.Name") %>)</span>
|
||||||
<span onClick="javascript:openColorPopup();"><%=localeMessage.getString("screen.customer") %></span>
|
<span onClick="javascript:openColorPopup();"><%=localeMessage.getString("screen.customer") %></span>
|
||||||
<% if (!"".equals(LastLoginYms.trim())) { %>
|
<% if (!"".equals(LastLoginYms.trim())) { %>
|
||||||
|
|||||||
@@ -82,8 +82,10 @@
|
|||||||
apiDesc: api.eaiSvcDesc
|
apiDesc: api.eaiSvcDesc
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// addJSONData는 datatype:"local" 그리드의 p.data를 채우지 않아, 헤더 클릭 정렬 시 p.data(빈 배열) 기준으로 다시 그려지며 행이 사라짐.
|
||||||
|
// setGridParam({data:...}).trigger('reloadGrid')로 채워야 정렬이 정상 동작함.
|
||||||
$("#selectedApiGrid").jqGrid("clearGridData");
|
$("#selectedApiGrid").jqGrid("clearGridData");
|
||||||
$("#selectedApiGrid")[0].addJSONData(gridData);
|
$("#selectedApiGrid").jqGrid('setGridParam', {data: gridData}).trigger('reloadGrid');
|
||||||
|
|
||||||
var data = json;
|
var data = json;
|
||||||
$("#btn_gen_id").hide();
|
$("#btn_gen_id").hide();
|
||||||
@@ -133,15 +135,14 @@
|
|||||||
var selectedApiGrid = $("#selectedApiGrid");
|
var selectedApiGrid = $("#selectedApiGrid");
|
||||||
selectedApiGrid.jqGrid({
|
selectedApiGrid.jqGrid({
|
||||||
datatype: "local",
|
datatype: "local",
|
||||||
gridview: true, // 성능 향상
|
rownumbers: true, // 가상 인덱스
|
||||||
rownumbers: true, // 가상 인덱스
|
|
||||||
rownumWidth: 40,
|
rownumWidth: 40,
|
||||||
colNames: ['API ID', 'API 이름'],
|
colNames: ['API ID', 'API 이름'],
|
||||||
colModel: [
|
colModel: [
|
||||||
{name: 'apiId', index: 'apiId', width: 100, align: 'center'},
|
{name: 'apiId', index: 'apiId', width: 100, align: 'center'},
|
||||||
{name: 'apiDesc', index: 'apiDesc', width: 100, align: 'center'}
|
{name: 'apiDesc', index: 'apiDesc', width: 100, align: 'center'}
|
||||||
],
|
],
|
||||||
rowNum: -1,
|
rowNum: 9999,
|
||||||
// rowList: [10, 20, 30],
|
// rowList: [10, 20, 30],
|
||||||
// pager: '#selectedApiGridPager',
|
// pager: '#selectedApiGridPager',
|
||||||
// sortname: 'apiId',
|
// sortname: 'apiId',
|
||||||
|
|||||||
@@ -76,13 +76,13 @@
|
|||||||
columns = [ // for columnData prop
|
columns = [ // for columnData prop
|
||||||
{ title: '<%= localeMessage.getString("layoutMan.itemDesc") %>', name: 'loutItemDesc', type: 'text', width:280, resize:true, minWidth:120 },
|
{ title: '<%= localeMessage.getString("layoutMan.itemDesc") %>', name: 'loutItemDesc', type: 'text', width:280, resize:true, minWidth:120 },
|
||||||
{ title: '<%= localeMessage.getString("layoutMan.itmeEn") %>', name: 'loutItemName', type: 'text', width:280, resize:true, minWidth:120 },
|
{ title: '<%= localeMessage.getString("layoutMan.itmeEn") %>', name: 'loutItemName', type: 'text', width:280, resize:true, minWidth:120 },
|
||||||
{ title: '<%=localeMessage.getString("standardLayout.itemLevel")%>', name: 'loutItemDepth', type: 'text', width:50 },
|
{ title: '<%=localeMessage.getString("standardLayout.itemLevel")%>', name: 'loutItemDepth', type: 'numeric', width:50 },
|
||||||
{ title: '<%= localeMessage.getString("standardLayout.itemType") %>', name: 'loutItemType', type: 'autocomplete', source: itemTypes, width:70 },
|
{ title: '<%= localeMessage.getString("standardLayout.itemType") %>', name: 'loutItemType', type: 'autocomplete', source: itemTypes, width:70 },
|
||||||
{ title: '<%= localeMessage.getString("standardLayout.arraySize") %>', name: 'loutItemOccCnt', type: 'text', width:90 },
|
{ title: '<%= localeMessage.getString("standardLayout.arraySize") %>', name: 'loutItemOccCnt', type: 'text', width:90 },
|
||||||
{ title: '<%= localeMessage.getString("standardLayout.refField") %>', name: 'loutItemOccRef', type: 'text', width:90 },
|
{ title: '<%= localeMessage.getString("standardLayout.refField") %>', name: 'loutItemOccRef', type: 'text', width:90 },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.dataType")%>', name: 'loutItemDataType', type: 'autocomplete', source: basicDataTypes, width:160 },
|
{ title: '<%=localeMessage.getString("layoutMan.dataType")%>', name: 'loutItemDataType', type: 'autocomplete', source: basicDataTypes, width:160 },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.dataLen")%>', name: 'loutItemLength', type: 'numeric', width: 70, align: 'center' },
|
{ title: '<%=localeMessage.getString("layoutMan.dataLen")%>', name: 'loutItemLength', type: 'numeric', width: 70, align: 'center' },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.decpLen")%>', name: 'loutItemDecimal', type: 'text', width: 70 },
|
{ title: '<%=localeMessage.getString("layoutMan.decpLen")%>', name: 'loutItemDecimal', type: 'numeric', width: 70 },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.defaultVal")%>', name: 'loutItemDefault', type: 'text', width: 70 },
|
{ title: '<%=localeMessage.getString("layoutMan.defaultVal")%>', name: 'loutItemDefault', type: 'text', width: 70 },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.mask")%>', name: 'loutItemMaskYn', type: 'checkbox', width: 70 },
|
{ title: '<%=localeMessage.getString("layoutMan.mask")%>', name: 'loutItemMaskYn', type: 'checkbox', width: 70 },
|
||||||
{ title: '<%=localeMessage.getString("layoutMan.moffset")%>', name: 'loutItemMaskOffset', type: 'text', width: 50 },
|
{ title: '<%=localeMessage.getString("layoutMan.moffset")%>', name: 'loutItemMaskOffset', type: 'text', width: 50 },
|
||||||
@@ -182,6 +182,41 @@
|
|||||||
$('#dataTotalLen').text(totalLen);
|
$('#dataTotalLen').text(totalLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusGridCell(colIndex, rowIndex) {
|
||||||
|
var cellName = jexcel.getColumnNameFromId([colIndex, rowIndex]);
|
||||||
|
var cell = grid.getCell(cellName);
|
||||||
|
grid.updateSelection(cell);
|
||||||
|
cell.scrollIntoView({block: 'center', inline: 'center'});
|
||||||
|
grid.openEditor(cell, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var INTEGER_REGEXP = /^(0|[1-9][0-9]*)$/;
|
||||||
|
|
||||||
|
function validateNumberColumns() {
|
||||||
|
var data = grid.getData();
|
||||||
|
var gridColumns = grid.options.columns;
|
||||||
|
|
||||||
|
for (var col = 0; col < gridColumns.length; col++) {
|
||||||
|
if (gridColumns[col].type !== 'numeric') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var row = 0; row < data.length; row++) {
|
||||||
|
var value = data[row][col];
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value = $.trim(String(value));
|
||||||
|
if (value !== '' && !INTEGER_REGEXP.test(value)) {
|
||||||
|
alert("숫자만 입력할 수 있습니다.");
|
||||||
|
focusGridCell(col, row);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function detail(key) {
|
function detail(key) {
|
||||||
if (!isDetail) {
|
if (!isDetail) {
|
||||||
return
|
return
|
||||||
@@ -321,6 +356,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#btn_modify").click(function(){
|
$("#btn_modify").click(function(){
|
||||||
|
if (!validateNumberColumns()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var postData = $('#ajaxForm').serializeArray();
|
var postData = $('#ajaxForm').serializeArray();
|
||||||
if (isDetail){
|
if (isDetail){
|
||||||
postData.push({ name: "cmd" , value:"UPDATE"});
|
postData.push({ name: "cmd" , value:"UPDATE"});
|
||||||
|
|||||||
@@ -56,11 +56,16 @@
|
|||||||
$("#expectEndDate").datepicker().inputmask("yyyy-mm-dd", {'autoUnmask': true});
|
$("#expectEndDate").datepicker().inputmask("yyyy-mm-dd", {'autoUnmask': true});
|
||||||
|
|
||||||
$("#btn_app_approve").click(function () {
|
$("#btn_app_approve").click(function () {
|
||||||
if (confirm("승인하시겠습니까?")) {
|
var isDeleteReq = (currentRequestType === 'DELETE');
|
||||||
|
var gwAction = (isDeleteReq && $('#chkGwDelete').is(':checked')) ? 'DELETE' : 'BLOCK';
|
||||||
|
var confirmMsg = isDeleteReq
|
||||||
|
? "해지 승인 시 포털 인증키(Credential)는 삭제되며,\n게이트웨이 클라이언트는 [" + (gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)") + "] 처리됩니다.\n승인하시겠습니까?"
|
||||||
|
: "승인하시겠습니까?";
|
||||||
|
if (confirm(confirmMsg)) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
url: url,
|
url: url,
|
||||||
data: {cmd: "APPROVE", id: key},
|
data: {cmd: "APPROVE", id: key, gwAction: gwAction},
|
||||||
success: function (args) {
|
success: function (args) {
|
||||||
<%--alert("<%= localeMessage.getString("approval.approveSuccess") %>");--%>
|
<%--alert("<%= localeMessage.getString("approval.approveSuccess") %>");--%>
|
||||||
alert("승인되었습니다.");
|
alert("승인되었습니다.");
|
||||||
@@ -101,6 +106,32 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#btn_app_force_reject").click(function () {
|
||||||
|
if (!confirm("승인자 목록에 삭제된 사용자가 있어 승인 진행이 불가능한 건입니다.\n강제 반려하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rejectReason = prompt("강제 반려 사유를 입력해 주세요");
|
||||||
|
if (rejectReason != null) {
|
||||||
|
$.ajax({
|
||||||
|
type: "POST",
|
||||||
|
url: url,
|
||||||
|
data: {cmd: "FORCE_REJECT", id: key, rejectReason: rejectReason},
|
||||||
|
success: function (args) {
|
||||||
|
alert("강제 반려되었습니다.");
|
||||||
|
if (window.dialogArguments && window.dialogArguments.self) {
|
||||||
|
window.dialogArguments.self.location.reload();
|
||||||
|
} else if (window.opener) {
|
||||||
|
window.opener.location.reload();
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
},
|
||||||
|
error: function (e) {
|
||||||
|
alert(e.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('#btn_app_redeploy').click(function () {
|
$('#btn_app_redeploy').click(function () {
|
||||||
if (confirm("재배포 하시겠습니까?")) {
|
if (confirm("재배포 하시겠습니까?")) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -150,6 +181,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var currentRequestType = null; // 현재 신청 유형 (DELETE 승인 시 GW 처리방식 선택 노출용)
|
||||||
|
|
||||||
function detail(key) {
|
function detail(key) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
@@ -178,10 +211,11 @@
|
|||||||
$("#createdDate").text(data.createdDate).inputmask("9999-99-99 99:99:99.999", {'autoUnmask': true});
|
$("#createdDate").text(data.createdDate).inputmask("9999-99-99 99:99:99.999", {'autoUnmask': true});
|
||||||
$("#expectEndDate").val(data.expectEndDate);
|
$("#expectEndDate").val(data.expectEndDate);
|
||||||
|
|
||||||
if (data.approvers.some(approver =>
|
var isCurrentApprover = data.approvers.some(approver =>
|
||||||
approver.approverStatus === 'CURRENT' &&
|
approver.approverStatus === 'CURRENT' &&
|
||||||
approver.user.USERID === loginUser
|
approver.user && approver.user.USERID === loginUser
|
||||||
)) {
|
);
|
||||||
|
if (isCurrentApprover) {
|
||||||
document.getElementById("btn_app_approve").style.display = "";
|
document.getElementById("btn_app_approve").style.display = "";
|
||||||
document.getElementById("btn_app_reject").style.display = "";
|
document.getElementById("btn_app_reject").style.display = "";
|
||||||
} else {
|
} else {
|
||||||
@@ -189,6 +223,29 @@
|
|||||||
document.getElementById("btn_app_reject").style.display = "none";
|
document.getElementById("btn_app_reject").style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 이용해지(DELETE) 건: 승인 가능 상태의 현재 승인자에게만 GW 처리방식 선택 노출
|
||||||
|
currentRequestType = data.requestType;
|
||||||
|
var inProgress = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
|
||||||
|
if (data.requestType === 'DELETE' && isCurrentApprover && inProgress) {
|
||||||
|
$("#gwActionSection").show();
|
||||||
|
} else {
|
||||||
|
$("#gwActionSection").hide();
|
||||||
|
}
|
||||||
|
// 처리 완료된 해지 건은 어떤 방식으로 실행됐는지 표시
|
||||||
|
if (data.requestType === 'DELETE' && data.gwAction) {
|
||||||
|
$("#gwActionResult").text("GW 처리방식: " + (data.gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)")).show();
|
||||||
|
} else {
|
||||||
|
$("#gwActionResult").hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제된 승인자(USERID 없음)로 진행 불가한 건: 요청됨/진행중 상태면 승인자가 아니어도 강제 반려 허용
|
||||||
|
var hasDeletedApprover = data.approvers.some(approver =>
|
||||||
|
!approver.user || !approver.user.USERID
|
||||||
|
);
|
||||||
|
var isProgressStatus = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
|
||||||
|
document.getElementById("btn_app_force_reject").style.display =
|
||||||
|
(hasDeletedApprover && isProgressStatus && !isCurrentApprover) ? "" : "none";
|
||||||
|
|
||||||
if (data.approvalStatus.code === 'FAILED') {
|
if (data.approvalStatus.code === 'FAILED') {
|
||||||
document.getElementById("btn_app_redeploy").style.display = "";
|
document.getElementById("btn_app_redeploy").style.display = "";
|
||||||
}
|
}
|
||||||
@@ -335,6 +392,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="gwActionSection" style="display:none; margin: 12px 0 0 4px; color: #c0392b;">
|
||||||
|
<label style="cursor:pointer;">
|
||||||
|
<input type="checkbox" id="chkGwDelete"/>
|
||||||
|
GW 인증정보 완전 삭제 (미체크 시 차단 처리 — 인증정보 보존, 복구 가능)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="gwActionResult" style="display:none; margin: 12px 0 0 4px; font-weight: bold;"></div>
|
||||||
<div class="popup_button">
|
<div class="popup_button">
|
||||||
<button type="button" class="cssbtn" id="btn_modify" level="W"><i
|
<button type="button" class="cssbtn" id="btn_modify" level="W"><i
|
||||||
class="material-icons">save</i>수정
|
class="material-icons">save</i>수정
|
||||||
@@ -345,6 +409,9 @@
|
|||||||
<button type="button" class="cssbtn" style="display: none;" id="btn_app_reject" level="W"><i
|
<button type="button" class="cssbtn" style="display: none;" id="btn_app_reject" level="W"><i
|
||||||
class="material-icons">close</i>반려
|
class="material-icons">close</i>반려
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="cssbtn" style="display: none;" id="btn_app_force_reject" level="W"><i
|
||||||
|
class="material-icons">block</i>강제 반려
|
||||||
|
</button>
|
||||||
<button type="button" class="cssbtn" style="display: none;" id="btn_app_redeploy" level="W"><i
|
<button type="button" class="cssbtn" style="display: none;" id="btn_app_redeploy" level="W"><i
|
||||||
class="material-icons">close</i>재처리
|
class="material-icons">close</i>재처리
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
.cssbtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script language="javascript">
|
<script language="javascript">
|
||||||
<%
|
<%
|
||||||
@@ -150,6 +154,55 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#btn_user_force_reject").click(function() {
|
||||||
|
if (!confirm("승인자 목록에 삭제된 사용자가 있어 승인 진행이 불가능한 건입니다.\n강제 반려하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rejectReason = prompt("강제 반려 사유를 입력해 주세요");
|
||||||
|
if (rejectReason != null) {
|
||||||
|
$.ajax({
|
||||||
|
type: "POST",
|
||||||
|
url: url,
|
||||||
|
data: {cmd: "FORCE_REJECT", id: key, rejectReason: rejectReason},
|
||||||
|
success: function(args) {
|
||||||
|
alert("강제 반려되었습니다.");
|
||||||
|
if (window.dialogArguments && window.dialogArguments.self) {
|
||||||
|
window.dialogArguments.self.location.reload();
|
||||||
|
} else if (window.opener) {
|
||||||
|
window.opener.location.reload();
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
},
|
||||||
|
error: function(e) {
|
||||||
|
alert(e.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn_user_delete").click(function() {
|
||||||
|
if (!confirm("삭제된 사용자 또는 법인이 포함되어 승인·반려할 수 없는 요청입니다.\n이 승인 요청을 삭제하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
type: "POST",
|
||||||
|
url: url,
|
||||||
|
data: {cmd: "DELETE", id: key},
|
||||||
|
success: function(args) {
|
||||||
|
alert("삭제되었습니다.");
|
||||||
|
if (window.dialogArguments && window.dialogArguments.self) {
|
||||||
|
window.dialogArguments.self.location.reload();
|
||||||
|
} else if (window.opener) {
|
||||||
|
window.opener.location.reload();
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
},
|
||||||
|
error: function(e) {
|
||||||
|
alert(e.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$('#btn_user_redeploy').click(function() {
|
$('#btn_user_redeploy').click(function() {
|
||||||
if (confirm("재배포 하시겠습니까?")) {
|
if (confirm("재배포 하시겠습니까?")) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -217,20 +270,55 @@
|
|||||||
|
|
||||||
// Display Portal User details
|
// Display Portal User details
|
||||||
var portalUser = data.portalUser;
|
var portalUser = data.portalUser;
|
||||||
$("#loginId").text(portalUser.loginId);
|
var portalOrg = portalUser ? portalUser.portalOrgUIs : null;
|
||||||
$("#userName").text(portalUser.userName);
|
|
||||||
$("#mobileNumber").text(portalUser.mobileNumber);
|
// 삭제된 대상 사용자/법인 판별
|
||||||
$("#userStatusDescription").text(portalUser.userStatusDescription);
|
var userDeleted = !portalUser
|
||||||
$("#roleCodeDescription").text(portalUser.roleCodeDescription);
|
|| portalUser.userName === '반려된 사용자'
|
||||||
if (data.approvers.some(approver =>
|
|| portalUser.userName === '삭제된 사용자';
|
||||||
approver.approverStatus === 'CURRENT' &&
|
var orgDeleted = !portalOrg
|
||||||
approver.user.USERID === loginUser
|
|| portalOrg.orgStatus === 'REMOVED'
|
||||||
)) {
|
|| portalOrg.orgName === '삭제된법인';
|
||||||
document.getElementById("btn_user_approve").style.display = "";
|
var deletedTarget = userDeleted || orgDeleted;
|
||||||
document.getElementById("btn_user_reject").style.display = "";
|
|
||||||
|
if (portalUser) {
|
||||||
|
$("#loginId").text(portalUser.loginId);
|
||||||
|
$("#userName").text(portalUser.userName);
|
||||||
|
$("#mobileNumber").text(portalUser.mobileNumber);
|
||||||
|
$("#userStatusDescription").text(portalUser.userStatusDescription);
|
||||||
|
$("#roleCodeDescription").text(portalUser.roleCodeDescription);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("btn_user_approve").style.display = "none";
|
$("#loginId, #userName, #mobileNumber, #userStatusDescription, #roleCodeDescription").text("삭제된 사용자");
|
||||||
document.getElementById("btn_user_reject").style.display = "none";
|
}
|
||||||
|
|
||||||
|
var isCurrentApprover = data.approvers.some(approver =>
|
||||||
|
approver.approverStatus === 'CURRENT' &&
|
||||||
|
approver.user && approver.user.USERID === loginUser
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deletedTarget) {
|
||||||
|
// 삭제된 사용자/법인 포함 → 승인·반려 불가. 버튼은 비활성(disable) 표기, 삭제 버튼 노출
|
||||||
|
$("#btn_user_approve, #btn_user_reject").show().prop("disabled", true);
|
||||||
|
document.getElementById("btn_user_force_reject").style.display = "none";
|
||||||
|
document.getElementById("btn_user_delete").style.display = "";
|
||||||
|
} else {
|
||||||
|
$("#btn_user_approve, #btn_user_reject").prop("disabled", false);
|
||||||
|
document.getElementById("btn_user_delete").style.display = "none";
|
||||||
|
if (isCurrentApprover) {
|
||||||
|
document.getElementById("btn_user_approve").style.display = "";
|
||||||
|
document.getElementById("btn_user_reject").style.display = "";
|
||||||
|
} else {
|
||||||
|
document.getElementById("btn_user_approve").style.display = "none";
|
||||||
|
document.getElementById("btn_user_reject").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제된 승인자(USERID 없음)로 진행 불가한 건: 요청됨/진행중 상태면 승인자가 아니어도 강제 반려 허용
|
||||||
|
var hasDeletedApprover = data.approvers.some(approver =>
|
||||||
|
!approver.user || !approver.user.USERID
|
||||||
|
);
|
||||||
|
var isProgressStatus = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
|
||||||
|
document.getElementById("btn_user_force_reject").style.display =
|
||||||
|
(hasDeletedApprover && isProgressStatus && !isCurrentApprover) ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.approvalStatus.description === '배포실패'){
|
if (data.approvalStatus.description === '배포실패'){
|
||||||
@@ -238,23 +326,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Display Portal Org details
|
// Display Portal Org details
|
||||||
var portalOrg = portalUser.portalOrgUIs;
|
if (portalOrg) {
|
||||||
$("#orgName").text(portalOrg.orgName);
|
$("#orgName").text(portalOrg.orgName);
|
||||||
$("#orgStatusDescription").text(portalOrg.orgStatusDescription);
|
$("#orgStatusDescription").text(portalOrg.orgStatusDescription);
|
||||||
$("#serviceName").text(portalOrg.serviceName);
|
$("#serviceName").text(portalOrg.serviceName);
|
||||||
$("#corpRegNo").text(formatCorpRegNo(portalOrg.corpRegNo));
|
$("#corpRegNo").text(formatCorpRegNo(portalOrg.corpRegNo));
|
||||||
$("#compRegNo").text(formatCompanyRegNo(portalOrg.compRegNo));
|
$("#compRegNo").text(formatCompanyRegNo(portalOrg.compRegNo));
|
||||||
|
|
||||||
$("#scPhoneNumber").text(portalOrg.scPhoneNumber); // 고객센터 전화번호
|
$("#scPhoneNumber").text(portalOrg.scPhoneNumber); // 고객센터 전화번호
|
||||||
$("#orgPhoneNumber").text(portalOrg.orgPhoneNumber); // 회사 전화번호
|
$("#orgPhoneNumber").text(portalOrg.orgPhoneNumber); // 회사 전화번호
|
||||||
$("#ceoName").text(portalOrg.ceoName); // 대표자 성명
|
$("#ceoName").text(portalOrg.ceoName); // 대표자 성명
|
||||||
$("#orgIndustryType").text(portalOrg.orgIndustryType || ""); // 업종
|
$("#orgIndustryType").text(portalOrg.orgIndustryType || ""); // 업종
|
||||||
$("#orgAddr").text(portalOrg.orgAddr); // 소재지
|
$("#orgAddr").text(portalOrg.orgAddr); // 소재지
|
||||||
$("#orgSectors").text(portalOrg.orgSectors || ""); // 업태
|
$("#orgSectors").text(portalOrg.orgSectors || ""); // 업태
|
||||||
$("#orgCode").text(portalOrg.orgCode);
|
$("#orgCode").text(portalOrg.orgCode);
|
||||||
$("#ipWhitelist").text(portalOrg.ipWhitelist || "");
|
$("#ipWhitelist").text(portalOrg.ipWhitelist || "");
|
||||||
|
} else {
|
||||||
|
$("#orgName").text("삭제된법인");
|
||||||
|
$("#orgStatusDescription, #serviceName, #corpRegNo, #compRegNo, #scPhoneNumber, #orgPhoneNumber, #ceoName, #orgIndustryType, #orgAddr, #orgSectors, #orgCode, #ipWhitelist").text("");
|
||||||
|
}
|
||||||
|
|
||||||
if (portalOrg.compRegFile) {
|
if (portalOrg && portalOrg.compRegFile) {
|
||||||
$("#fileName").text(portalOrg.fileName);
|
$("#fileName").text(portalOrg.fileName);
|
||||||
if (portalOrg.fileName) {
|
if (portalOrg.fileName) {
|
||||||
$("#fileWrapper").show().off('click').on('click', function() {
|
$("#fileWrapper").show().off('click').on('click', function() {
|
||||||
@@ -294,15 +386,18 @@
|
|||||||
|
|
||||||
// 사용자 정보 업데이트
|
// 사용자 정보 업데이트
|
||||||
var portalUser = data.portalUser;
|
var portalUser = data.portalUser;
|
||||||
$("#loginId").text(portalUser.loginId);
|
if (portalUser) {
|
||||||
$("#userName").text(portalUser.userName);
|
$("#loginId").text(portalUser.loginId);
|
||||||
$("#mobileNumber").text(portalUser.mobileNumber);
|
$("#userName").text(portalUser.userName);
|
||||||
$("#userStatusDescription").text(portalUser.userStatusDescription);
|
$("#mobileNumber").text(portalUser.mobileNumber);
|
||||||
$("#roleCodeDescription").text(portalUser.roleCodeDescription);
|
$("#userStatusDescription").text(portalUser.userStatusDescription);
|
||||||
|
$("#roleCodeDescription").text(portalUser.roleCodeDescription);
|
||||||
|
} else {
|
||||||
|
$("#loginId, #userName, #mobileNumber, #userStatusDescription, #roleCodeDescription").text("삭제된 사용자");
|
||||||
|
}
|
||||||
|
|
||||||
// 법인 정보 업데이트
|
// 법인 정보 업데이트
|
||||||
var portalOrg = portalUser.portalOrgUIs;
|
updateOrgInfo(portalUser ? portalUser.portalOrgUIs : null);
|
||||||
updateOrgInfo(portalOrg);
|
|
||||||
|
|
||||||
// 마스킹 상태에 따라 데이터를 다르게 표시
|
// 마스킹 상태에 따라 데이터를 다르게 표시
|
||||||
var isMasked = $("#btn_unmask").attr("status") === "DETAIL";
|
var isMasked = $("#btn_unmask").attr("status") === "DETAIL";
|
||||||
@@ -344,6 +439,11 @@
|
|||||||
|
|
||||||
// 법인 정보 업데이트 함수
|
// 법인 정보 업데이트 함수
|
||||||
function updateOrgInfo(portalOrg) {
|
function updateOrgInfo(portalOrg) {
|
||||||
|
if (!portalOrg) {
|
||||||
|
$("#orgName").text("삭제된법인");
|
||||||
|
$("#orgStatusDescription, #serviceName, #corpRegNo, #compRegNo, #scPhoneNumber, #orgPhoneNumber, #ceoName, #orgIndustryType, #orgAddr, #orgSectors, #orgCode, #ipWhitelist").text("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("#orgName").text(portalOrg.orgName);
|
$("#orgName").text(portalOrg.orgName);
|
||||||
$("#orgStatusDescription").text(portalOrg.orgStatusDescription);
|
$("#orgStatusDescription").text(portalOrg.orgStatusDescription);
|
||||||
$("#serviceName").text(portalOrg.serviceName);
|
$("#serviceName").text(portalOrg.serviceName);
|
||||||
@@ -486,6 +586,8 @@
|
|||||||
<div class="popup_button">
|
<div class="popup_button">
|
||||||
<button type="button" class="cssbtn" style="display: none;" id="btn_user_approve" level="W"><i class="material-icons">check</i>승인</button>
|
<button type="button" class="cssbtn" style="display: none;" id="btn_user_approve" level="W"><i class="material-icons">check</i>승인</button>
|
||||||
<button type="button" class="cssbtn" style="display: none;" id="btn_user_reject" level="W"><i class="material-icons">close</i>반려</button>
|
<button type="button" class="cssbtn" style="display: none;" id="btn_user_reject" level="W"><i class="material-icons">close</i>반려</button>
|
||||||
|
<button type="button" class="cssbtn" style="display: none;" id="btn_user_force_reject" level="W"><i class="material-icons">block</i>강제 반려</button>
|
||||||
|
<button type="button" class="cssbtn" style="display: none;" id="btn_user_delete" level="W"><i class="material-icons">delete</i>삭제</button>
|
||||||
<button type="button" class="cssbtn" style="display: none;" id="btn_user_redeploy" level="W"><i class="material-icons">close</i>재처리</button>
|
<button type="button" class="cssbtn" style="display: none;" id="btn_user_redeploy" level="W"><i class="material-icons">close</i>재처리</button>
|
||||||
<button type="button" class="cssbtn" id="btn_close" level="R"><i class="material-icons">cancel</i> <%= localeMessage.getString("button.close") %>
|
<button type="button" class="cssbtn" id="btn_close" level="R"><i class="material-icons">cancel</i> <%= localeMessage.getString("button.close") %>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -192,7 +192,6 @@
|
|||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="EMAIL">EMAIL</option>
|
<option value="EMAIL">EMAIL</option>
|
||||||
<option value="SMS">SMS</option>
|
<option value="SMS">SMS</option>
|
||||||
<option value="KAKAO">KAKAO</option>
|
|
||||||
<option value="MESSENGER">MESSENGER</option>
|
<option value="MESSENGER">MESSENGER</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
<%@ page language="java" contentType="text/html; charset=utf-8" %>
|
||||||
|
<%@ page import="java.io.*" %>
|
||||||
|
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
|
||||||
|
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
|
||||||
|
<%
|
||||||
|
response.setHeader("Pragma", "No-cache");
|
||||||
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
|
response.setHeader("Expires", "0");
|
||||||
|
%>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<jsp:include page="/jsp/common/include/css.jsp"/>
|
||||||
|
<jsp:include page="/jsp/common/include/script.jsp"/>
|
||||||
|
<jsp:include page="/jsp/common/include/portal.jsp"/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── 스윔레인 보드 ── */
|
||||||
|
.menu-board { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
|
.menu-lane-strip { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; flex: 1 1 auto; }
|
||||||
|
.menu-lane { background: #f1f3f5; border-radius: 10px; min-width: 230px; max-width: 230px; flex: 0 0 230px; }
|
||||||
|
.menu-lane.unplaced { background: #fff4e6; border: 1px dashed #fd7e14; }
|
||||||
|
.lane-header { display: flex; align-items: center; gap: 6px; padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #dee2e6; cursor: grab; }
|
||||||
|
.menu-lane.unplaced .lane-header { cursor: default; }
|
||||||
|
.lane-title { font-weight: 700; font-size: 14px; flex: 1 1 auto; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lane-body { list-style: none; margin: 0; padding: 8px; min-height: 60px; }
|
||||||
|
.menu-card { background: #fff; border: 1px solid #dee2e6; border-radius: 8px;
|
||||||
|
padding: 8px 10px; margin-bottom: 8px; cursor: grab;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
|
||||||
|
.menu-card.hidden-item, .menu-lane.hidden-item > .lane-header { opacity: 0.45; }
|
||||||
|
.menu-card .card-name { font-size: 13px; font-weight: 600; }
|
||||||
|
.menu-card .card-id { font-size: 11px; color: #868e96; }
|
||||||
|
.menu-card .card-path { font-size: 11px; color: #495057; word-break: break-all; }
|
||||||
|
.card-actions, .lane-actions { display: flex; gap: 4px; }
|
||||||
|
.icon-btn { border: none; background: transparent; padding: 1px 3px; cursor: pointer;
|
||||||
|
font-size: 14px; color: #495057; }
|
||||||
|
.icon-btn:hover { color: #0d6efd; }
|
||||||
|
.icon-btn.danger:hover { color: #dc3545; }
|
||||||
|
.badge-src { font-size: 10px; }
|
||||||
|
.role-check-group { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||||
|
.role-check-group .form-check { min-width: 45%; }
|
||||||
|
.dflt-hint { font-size: 11px; color: #868e96; }
|
||||||
|
.board-toolbar { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; }
|
||||||
|
.board-toolbar .spacer { flex: 1 1 auto; }
|
||||||
|
.sortable-placeholder { border: 2px dashed #adb5bd; border-radius: 8px; height: 48px;
|
||||||
|
margin-bottom: 8px; background: #e9ecef; }
|
||||||
|
.lane-placeholder { border: 2px dashed #adb5bd; border-radius: 10px; min-width: 230px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script language="javascript">
|
||||||
|
// AuthorizeInterceptor 는 serviceType 을 "요청 파라미터"에서 읽으므로
|
||||||
|
// 모든 .json 호출 URL 에 serviceType 을 부착한다 (누락 시 권한 조회 실패 → Access denied)
|
||||||
|
var url = urlAddServiceType('<c:url value="/onl/apim/portalmenu/portalMenuMan.json" />');
|
||||||
|
|
||||||
|
// 화면 상태 (저장 버튼 클릭 시에만 서버 반영 — 드래그/토글은 클라이언트 상태만 변경)
|
||||||
|
var state = { itemsById: {}, lanes: [], unplaced: [], roles: [] };
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll(afterFn) {
|
||||||
|
$.post(url, { cmd: 'LIST_ALL' }, function (data) {
|
||||||
|
buildState(data);
|
||||||
|
render();
|
||||||
|
dirty = false;
|
||||||
|
if (afterFn) afterFn();
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildState(data) {
|
||||||
|
state.itemsById = {};
|
||||||
|
state.roles = data.roles || [];
|
||||||
|
(data.items || []).forEach(function (it) { state.itemsById[it.menuId] = it; });
|
||||||
|
|
||||||
|
var placedTop = [], childrenByParent = {}, unplaced = [];
|
||||||
|
(data.items || []).forEach(function (it) {
|
||||||
|
if (!it.placed) { unplaced.push(it.menuId); return; }
|
||||||
|
if (!it.parentId) {
|
||||||
|
placedTop.push(it);
|
||||||
|
} else {
|
||||||
|
(childrenByParent[it.parentId] = childrenByParent[it.parentId] || []).push(it);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
placedTop.sort(function (a, b) { return (a.sortOrder || 0) - (b.sortOrder || 0); });
|
||||||
|
|
||||||
|
state.lanes = placedTop.map(function (top) {
|
||||||
|
var kids = (childrenByParent[top.menuId] || [])
|
||||||
|
.sort(function (a, b) { return (a.sortOrder || 0) - (b.sortOrder || 0); })
|
||||||
|
.map(function (c) { return { menuId: c.menuId, visibleYn: c.visibleYn || 'Y' }; });
|
||||||
|
delete childrenByParent[top.menuId];
|
||||||
|
return { menuId: top.menuId, visibleYn: top.visibleYn || 'Y', children: kids };
|
||||||
|
});
|
||||||
|
// 부모가 미배치/부재인 고아 배치는 미배치로 강등
|
||||||
|
Object.keys(childrenByParent).forEach(function (pid) {
|
||||||
|
childrenByParent[pid].forEach(function (c) { unplaced.push(c.menuId); });
|
||||||
|
});
|
||||||
|
state.unplaced = unplaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 렌더 ──
|
||||||
|
function cardHtml(menuId, visibleYn, inLane) {
|
||||||
|
var it = state.itemsById[menuId];
|
||||||
|
if (!it) return '';
|
||||||
|
var hidden = visibleYn === 'N';
|
||||||
|
var srcBadge = it.sourceType === 'PORTAL'
|
||||||
|
? '<span class="badge text-bg-secondary badge-src">기본</span>'
|
||||||
|
: '<span class="badge text-bg-warning badge-src">커스텀</span>';
|
||||||
|
var grpBadge = it.groupYn === 'Y' ? ' <span class="badge text-bg-info badge-src">그룹</span>' : '';
|
||||||
|
var placeBtn = (!inLane && it.groupYn === 'Y')
|
||||||
|
? '<button type="button" class="icon-btn" title="최상위 레인으로 배치" onclick="placeAsLane(\'' + menuId + '\')"><i class="bi bi-box-arrow-in-up"></i></button>'
|
||||||
|
: '';
|
||||||
|
var eyeBtn = inLane
|
||||||
|
? '<button type="button" class="icon-btn" title="노출/숨김" onclick="toggleVisible(\'' + menuId + '\')"><i class="bi ' + (hidden ? 'bi-eye-slash' : 'bi-eye') + '"></i></button>'
|
||||||
|
: '';
|
||||||
|
return '<li class="menu-card' + (hidden ? ' hidden-item' : '') + '" data-menu-id="' + escapeHtml(menuId) + '">'
|
||||||
|
+ '<div class="d-flex align-items-start">'
|
||||||
|
+ '<div class="flex-grow-1">'
|
||||||
|
+ '<div class="card-name">' + escapeHtml(it.menuName) + ' ' + srcBadge + grpBadge + '</div>'
|
||||||
|
+ '<div class="card-id">' + escapeHtml(it.menuId) + '</div>'
|
||||||
|
+ (it.menuPath ? '<div class="card-path">' + escapeHtml(it.menuPath) + '</div>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="card-actions">'
|
||||||
|
+ placeBtn + eyeBtn
|
||||||
|
+ '<button type="button" class="icon-btn" title="수정" onclick="openModal(\'' + menuId + '\')"><i class="bi bi-pencil"></i></button>'
|
||||||
|
+ '<button type="button" class="icon-btn danger" level="W" title="' + (it.sourceType === 'PORTAL' ? '미배치로 이동' : '삭제') + '" onclick="minusItem(\'' + menuId + '\')"><i class="bi bi-dash-circle"></i></button>'
|
||||||
|
+ '</div></div></li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneHtml(lane) {
|
||||||
|
var it = state.itemsById[lane.menuId];
|
||||||
|
if (!it) return '';
|
||||||
|
var hidden = lane.visibleYn === 'N';
|
||||||
|
var sectionBadge = it.menuSection === 'MYPAGE'
|
||||||
|
? '<span class="badge text-bg-primary badge-src">마이페이지</span>'
|
||||||
|
: '<span class="badge text-bg-success badge-src">GNB</span>';
|
||||||
|
var srcBadge = it.sourceType === 'PORTAL' ? '' : ' <span class="badge text-bg-warning badge-src">커스텀</span>';
|
||||||
|
var cards = lane.children.map(function (c) { return cardHtml(c.menuId, c.visibleYn, true); }).join('');
|
||||||
|
return '<div class="menu-lane' + (hidden ? ' hidden-item' : '') + '" data-menu-id="' + escapeHtml(lane.menuId) + '">'
|
||||||
|
+ '<div class="lane-header lane-drag-handle">'
|
||||||
|
+ '<span class="lane-title">' + escapeHtml(it.menuName) + '</span>'
|
||||||
|
+ sectionBadge + srcBadge
|
||||||
|
+ '<div class="lane-actions">'
|
||||||
|
+ '<button type="button" class="icon-btn" title="노출/숨김" onclick="toggleVisible(\'' + lane.menuId + '\')"><i class="bi ' + (hidden ? 'bi-eye-slash' : 'bi-eye') + '"></i></button>'
|
||||||
|
+ '<button type="button" class="icon-btn" title="수정" onclick="openModal(\'' + lane.menuId + '\')"><i class="bi bi-pencil"></i></button>'
|
||||||
|
+ '<button type="button" class="icon-btn danger" level="W" title="레인 미배치" onclick="unplaceLane(\'' + lane.menuId + '\')"><i class="bi bi-dash-circle"></i></button>'
|
||||||
|
+ '</div></div>'
|
||||||
|
+ '<ul class="lane-body" data-lane-id="' + escapeHtml(lane.menuId) + '">' + cards + '</ul>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var strip = state.lanes.map(laneHtml).join('');
|
||||||
|
$('#laneStrip').html(strip);
|
||||||
|
$('#unplacedBody').html(state.unplaced.map(function (id) { return cardHtml(id, 'Y', false); }).join(''));
|
||||||
|
initSortables();
|
||||||
|
buttonControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortables() {
|
||||||
|
$('#laneStrip').sortable({
|
||||||
|
items: '.menu-lane',
|
||||||
|
handle: '.lane-drag-handle',
|
||||||
|
placeholder: 'lane-placeholder',
|
||||||
|
tolerance: 'pointer',
|
||||||
|
update: function () { syncFromDom(); }
|
||||||
|
});
|
||||||
|
$('.lane-body, #unplacedBody').sortable({
|
||||||
|
connectWith: '.lane-body, #unplacedBody',
|
||||||
|
items: '.menu-card',
|
||||||
|
placeholder: 'sortable-placeholder',
|
||||||
|
tolerance: 'pointer',
|
||||||
|
stop: function () { syncFromDom(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 종료 후 DOM 순서 → 상태 동기화 (visible 값은 기존 상태 유지)
|
||||||
|
function syncFromDom() {
|
||||||
|
var visibleMap = {};
|
||||||
|
state.lanes.forEach(function (l) {
|
||||||
|
visibleMap[l.menuId] = l.visibleYn;
|
||||||
|
l.children.forEach(function (c) { visibleMap[c.menuId] = c.visibleYn; });
|
||||||
|
});
|
||||||
|
|
||||||
|
var lanes = [];
|
||||||
|
$('#laneStrip .menu-lane').each(function () {
|
||||||
|
var laneId = $(this).data('menu-id');
|
||||||
|
var children = [];
|
||||||
|
$(this).find('.lane-body .menu-card').each(function () {
|
||||||
|
var id = $(this).data('menu-id');
|
||||||
|
children.push({ menuId: id, visibleYn: visibleMap[id] || 'Y' });
|
||||||
|
});
|
||||||
|
lanes.push({ menuId: laneId, visibleYn: visibleMap[laneId] || 'Y', children: children });
|
||||||
|
});
|
||||||
|
var unplaced = [];
|
||||||
|
$('#unplacedBody .menu-card').each(function () { unplaced.push($(this).data('menu-id')); });
|
||||||
|
|
||||||
|
state.lanes = lanes;
|
||||||
|
state.unplaced = unplaced;
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 카드/레인 조작 ──
|
||||||
|
function toggleVisible(menuId) {
|
||||||
|
state.lanes.forEach(function (l) {
|
||||||
|
if (l.menuId === menuId) l.visibleYn = (l.visibleYn === 'N' ? 'Y' : 'N');
|
||||||
|
l.children.forEach(function (c) {
|
||||||
|
if (c.menuId === menuId) c.visibleYn = (c.visibleYn === 'N' ? 'Y' : 'N');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeAsLane(menuId) {
|
||||||
|
state.unplaced = state.unplaced.filter(function (id) { return id !== menuId; });
|
||||||
|
state.lanes.push({ menuId: menuId, visibleYn: 'Y', children: [] });
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function minusItem(menuId) {
|
||||||
|
var it = state.itemsById[menuId];
|
||||||
|
if (!it) return;
|
||||||
|
if (it.sourceType === 'PORTAL') {
|
||||||
|
removeFromBoard(menuId);
|
||||||
|
if (state.unplaced.indexOf(menuId) < 0) state.unplaced.push(menuId);
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
} else {
|
||||||
|
if (!confirm('커스텀 항목은 미배치 시 삭제됩니다.\n[' + it.menuName + '] 을(를) 삭제하시겠습니까?')) return;
|
||||||
|
$.post(url, { cmd: 'DELETE', menuId: menuId }, function () {
|
||||||
|
removeFromBoard(menuId);
|
||||||
|
state.unplaced = state.unplaced.filter(function (id) { return id !== menuId; });
|
||||||
|
delete state.itemsById[menuId];
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unplaceLane(laneId) {
|
||||||
|
var lane = null;
|
||||||
|
state.lanes = state.lanes.filter(function (l) {
|
||||||
|
if (l.menuId === laneId) { lane = l; return false; }
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!lane) return;
|
||||||
|
lane.children.forEach(function (c) {
|
||||||
|
if (state.unplaced.indexOf(c.menuId) < 0) state.unplaced.push(c.menuId);
|
||||||
|
});
|
||||||
|
var it = state.itemsById[laneId];
|
||||||
|
if (it && it.sourceType !== 'PORTAL') {
|
||||||
|
if (confirm('커스텀 항목은 미배치 시 삭제됩니다.\n[' + it.menuName + '] 항목을 삭제하시겠습니까?')) {
|
||||||
|
$.post(url, { cmd: 'DELETE', menuId: laneId }, function () {
|
||||||
|
delete state.itemsById[laneId];
|
||||||
|
render();
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
} else {
|
||||||
|
state.unplaced.push(laneId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (state.unplaced.indexOf(laneId) < 0) state.unplaced.push(laneId);
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromBoard(menuId) {
|
||||||
|
state.lanes = state.lanes.filter(function (l) { return l.menuId !== menuId; });
|
||||||
|
state.lanes.forEach(function (l) {
|
||||||
|
l.children = l.children.filter(function (c) { return c.menuId !== menuId; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 저장 / 초기화 / 캐시 Reload ──
|
||||||
|
function savePlacements() {
|
||||||
|
// 미배치 상태의 커스텀 항목은 저장 시 삭제 처리 (스펙: 커스텀은 미배치=삭제)
|
||||||
|
var unplacedAdmin = state.unplaced.filter(function (id) {
|
||||||
|
var it = state.itemsById[id];
|
||||||
|
return it && it.sourceType !== 'PORTAL';
|
||||||
|
});
|
||||||
|
if (unplacedAdmin.length > 0) {
|
||||||
|
var names = unplacedAdmin.map(function (id) { return state.itemsById[id].menuName; }).join(', ');
|
||||||
|
if (!confirm('미배치된 커스텀 항목은 저장 시 삭제됩니다: ' + names + '\n계속하시겠습니까?')) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = [];
|
||||||
|
state.lanes.forEach(function (l, i) {
|
||||||
|
rows.push({ menuId: l.menuId, parentId: null, sortOrder: (i + 1) * 10, visibleYn: l.visibleYn });
|
||||||
|
l.children.forEach(function (c, j) {
|
||||||
|
rows.push({ menuId: c.menuId, parentId: l.menuId, sortOrder: (j + 1) * 10, visibleYn: c.visibleYn });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var deleteCalls = unplacedAdmin.map(function (id) {
|
||||||
|
return $.post(url, { cmd: 'DELETE', menuId: id });
|
||||||
|
});
|
||||||
|
$.when.apply($, deleteCalls).always(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: url + '&cmd=TRANSACTION_PLACEMENT',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
data: JSON.stringify(rows)
|
||||||
|
}).done(function () {
|
||||||
|
dirty = false;
|
||||||
|
loadAll(function () {
|
||||||
|
if (confirm('배치가 저장되었습니다.\n포탈 메뉴 캐시를 즉시 반영(Reload)하시겠습니까?\n(미반영 시 캐시 TTL 후 자동 반영)')) {
|
||||||
|
reloadPortalCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).fail(ajaxFail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeMenus() {
|
||||||
|
if (!confirm('메뉴 초기화 시 포탈 기본(menu.yml) 값으로 복원되고\n커스텀 항목은 모두 삭제됩니다. 계속하시겠습니까?')) return;
|
||||||
|
$.post(url, { cmd: 'INITIALIZE' }, function () {
|
||||||
|
loadAll(function () { alert('메뉴가 초기화되었습니다.'); });
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadPortalCache() {
|
||||||
|
$.post(url, { cmd: 'TRANSACTION_RELOAD' }, function (result) {
|
||||||
|
if (result && result.success) {
|
||||||
|
alert('포탈 메뉴 캐시 리로드 완료 (항목 ' + result.itemCount + '건, ' + result.reloadedAt + ')');
|
||||||
|
} else {
|
||||||
|
alert('캐시 리로드 실패: ' + (result ? result.message : '알 수 없는 오류'));
|
||||||
|
}
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajaxFail(xhr) {
|
||||||
|
var msg = '처리 중 오류가 발생했습니다.';
|
||||||
|
try {
|
||||||
|
var body = JSON.parse(xhr.responseText);
|
||||||
|
if (body.errorMsg) msg = body.errorMsg;
|
||||||
|
else if (body.message) msg = body.message;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
alert(msg + ' (HTTP ' + xhr.status + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 추가/수정 모달 ──
|
||||||
|
var menuModal;
|
||||||
|
|
||||||
|
function roleChecksHtml(namePrefix, checkedCsv) {
|
||||||
|
var checked = (checkedCsv || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
var html = '';
|
||||||
|
var all = [{ roleCode: 'AUTHENTICATED', roleName: '로그인 사용자', roleType: '-' }].concat(state.roles);
|
||||||
|
all.forEach(function (r) {
|
||||||
|
var id = namePrefix + '_' + r.roleCode;
|
||||||
|
html += '<div class="form-check form-check-sm">'
|
||||||
|
+ '<input class="form-check-input" type="checkbox" name="' + namePrefix + '" id="' + id + '" value="' + escapeHtml(r.roleCode) + '"'
|
||||||
|
+ (checked.indexOf(r.roleCode) >= 0 ? ' checked' : '') + '>'
|
||||||
|
+ '<label class="form-check-label" for="' + id + '" style="font-size:12px;">'
|
||||||
|
+ escapeHtml(r.roleName) + ' <span class="text-muted">(' + escapeHtml(r.roleCode) + ')</span></label>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(menuId) {
|
||||||
|
var isNew = !menuId;
|
||||||
|
var it = isNew ? { sourceType: 'ADMIN', groupYn: 'N', menuSection: 'GNB', newWindowYn: 'N' }
|
||||||
|
: state.itemsById[menuId];
|
||||||
|
if (!it) return;
|
||||||
|
var isPortal = it.sourceType === 'PORTAL';
|
||||||
|
|
||||||
|
$('#modalTitle').text(isNew ? '메뉴 추가' : '메뉴 수정' + (isPortal ? ' (기본 항목)' : ''));
|
||||||
|
$('#fMenuId').val(it.menuId || '').prop('readonly', !isNew);
|
||||||
|
$('#fMenuName').val(it.menuName || '');
|
||||||
|
$('#fMenuPath').val(it.menuPath || '');
|
||||||
|
$('#fIconClass').val(it.iconClass || '');
|
||||||
|
$('#fMenuSection').val(it.menuSection || 'GNB').prop('disabled', isPortal);
|
||||||
|
$('#fGroupYn').prop('checked', it.groupYn === 'Y').prop('disabled', isPortal);
|
||||||
|
$('#fNewWindowYn').prop('checked', it.newWindowYn === 'Y');
|
||||||
|
$('#exposeRoleChecks').html(roleChecksHtml('exposeRole', it.exposeRoles));
|
||||||
|
$('#accessRoleChecks').html(roleChecksHtml('accessRole', it.accessRoles));
|
||||||
|
|
||||||
|
// 기본 항목: 기본값(DFLT_*) 병기
|
||||||
|
if (isPortal) {
|
||||||
|
$('#hintMenuName').text('기본값: ' + (it.dfltMenuName || '-'));
|
||||||
|
$('#hintMenuPath').text('기본값: ' + (it.dfltMenuPath || '-'));
|
||||||
|
$('#hintExposeRoles').text('기본값: ' + (it.dfltExposeRoles || '전체 노출'));
|
||||||
|
$('#hintAccessRoles').text('기본값: ' + (it.dfltAccessRoles || '제한 없음'));
|
||||||
|
$('.dflt-hint').show();
|
||||||
|
} else {
|
||||||
|
$('.dflt-hint').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#modalMode').val(isNew ? 'INSERT' : 'UPDATE');
|
||||||
|
menuModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitModal() {
|
||||||
|
var cmd = $('#modalMode').val();
|
||||||
|
var data = {
|
||||||
|
cmd: cmd,
|
||||||
|
menuId: $('#fMenuId').val().trim(),
|
||||||
|
menuName: $('#fMenuName').val().trim(),
|
||||||
|
menuPath: $('#fMenuPath').val().trim(),
|
||||||
|
iconClass: $('#fIconClass').val().trim(),
|
||||||
|
menuSection: $('#fMenuSection').val(),
|
||||||
|
groupYn: $('#fGroupYn').is(':checked') ? 'Y' : 'N',
|
||||||
|
newWindowYn: $('#fNewWindowYn').is(':checked') ? 'Y' : 'N',
|
||||||
|
exposeRoles: $('input[name=exposeRole]:checked').map(function () { return this.value; }).get().join(','),
|
||||||
|
accessRoles: $('input[name=accessRole]:checked').map(function () { return this.value; }).get().join(',')
|
||||||
|
};
|
||||||
|
if (!data.menuId) { alert('메뉴 ID 를 입력하세요.'); return; }
|
||||||
|
if (cmd === 'INSERT' && !/^[a-z0-9-]+$/.test(data.menuId)) {
|
||||||
|
alert('메뉴 ID 는 kebab-case(소문자/숫자/하이픈)만 허용합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.menuName) { alert('노출명을 입력하세요.'); return; }
|
||||||
|
|
||||||
|
$.post(url, data, function () {
|
||||||
|
menuModal.hide();
|
||||||
|
loadAll();
|
||||||
|
}, 'json').fail(ajaxFail);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
menuModal = new bootstrap.Modal(document.getElementById('menuModal'));
|
||||||
|
loadAll();
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function (e) {
|
||||||
|
if (dirty) { e.preventDefault(); e.returnValue = ''; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="right_box">
|
||||||
|
<div class="content_top">
|
||||||
|
<ul class="path">
|
||||||
|
<li><a href="#">${rmsMenuPath}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div><!-- end content_top -->
|
||||||
|
<div class="content_middle" id="content_middle">
|
||||||
|
|
||||||
|
<div class="title">포탈 메뉴 관리</div>
|
||||||
|
|
||||||
|
<div class="board-toolbar">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" level="W" onclick="openModal()">
|
||||||
|
<i class="bi bi-plus-lg"></i> 메뉴 추가</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" level="W" onclick="initializeMenus()">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> 메뉴 초기화</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" level="W" onclick="reloadPortalCache()">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> 캐시 Reload</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" level="W" onclick="savePlacements()">
|
||||||
|
<i class="bi bi-save"></i> 저장</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#e7f1ff; border:1px solid #0d6efd33; padding:8px 15px; margin-bottom:10px; border-radius:6px; font-size:12px; color:#31507a;">
|
||||||
|
드래그로 위치를 조절한 뒤 <strong>저장</strong> 버튼으로 일괄 반영합니다.
|
||||||
|
기본 항목([기본] 배지)은 [-] 시 <strong>미배치</strong>로만 이동하고, 커스텀 항목은 미배치 시 <strong>삭제</strong>됩니다.
|
||||||
|
저장 후 <strong>캐시 Reload</strong> 를 실행해야 포탈에 즉시 반영됩니다(미실행 시 TTL 경과 후 반영).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-board">
|
||||||
|
<div class="menu-lane-strip" id="laneStrip"><!-- 렌더링 --></div>
|
||||||
|
<div class="menu-lane unplaced">
|
||||||
|
<div class="lane-header">
|
||||||
|
<span class="lane-title">미배치</span>
|
||||||
|
<span class="badge text-bg-warning badge-src">보관함</span>
|
||||||
|
</div>
|
||||||
|
<ul class="lane-body" id="unplacedBody"><!-- 렌더링 --></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 추가/수정 모달 -->
|
||||||
|
<div class="modal fade" id="menuModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalTitle">메뉴 추가</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="modalMode" value="INSERT">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="fMenuId">메뉴 ID (kebab-case)</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="fMenuId" placeholder="ex) custom-link">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="fMenuName">노출명</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="fMenuName">
|
||||||
|
<div class="dflt-hint" id="hintMenuName"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="fMenuPath">Path (그룹은 생략 가능)</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="fMenuPath" placeholder="/example 또는 https://...">
|
||||||
|
<div class="dflt-hint" id="hintMenuPath"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="fIconClass">아이콘 클래스 (마이페이지용, ex: fa-users)</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="fIconClass">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="fMenuSection">섹션</label>
|
||||||
|
<select class="form-select form-select-sm" id="fMenuSection">
|
||||||
|
<option value="GNB">GNB (상단 메뉴)</option>
|
||||||
|
<option value="MYPAGE">마이페이지</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="fGroupYn">
|
||||||
|
<label class="form-check-label" for="fGroupYn">그룹(상위 메뉴)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="fNewWindowYn">
|
||||||
|
<label class="form-check-label" for="fNewWindowYn">새 창 열기</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">노출 권한 (미선택 = 전체 노출)</label>
|
||||||
|
<div class="role-check-group" id="exposeRoleChecks"></div>
|
||||||
|
<div class="dflt-hint" id="hintExposeRoles"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">접근 권한 (미선택 = 제한 없음)</label>
|
||||||
|
<div class="role-check-group" id="accessRoleChecks"></div>
|
||||||
|
<div class="dflt-hint" id="hintAccessRoles"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" level="W" onclick="submitModal()">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- end content_middle -->
|
||||||
|
</div><!-- end right_box -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,16 +19,42 @@
|
|||||||
var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />';
|
var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />';
|
||||||
var combo;
|
var combo;
|
||||||
|
|
||||||
|
// 코드 상수 — PortalNoticeUI 와 동기화
|
||||||
|
var NOTICE_TYPE_INCIDENT = '3';
|
||||||
|
|
||||||
|
// IncidentState 라벨 — 상세 화면과 동일하게 유지
|
||||||
|
var STATE_LABELS = {
|
||||||
|
'INVESTIGATING': '조사중',
|
||||||
|
'IDENTIFIED': '원인 파악',
|
||||||
|
'MONITORING': '모니터링',
|
||||||
|
'RESOLVED': '해소',
|
||||||
|
'CANCELED': '취소'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatIncidentState(cellvalue, options, rowObject) {
|
||||||
|
if (!cellvalue) return '';
|
||||||
|
var label = STATE_LABELS[cellvalue] || cellvalue;
|
||||||
|
var closed = (cellvalue === 'RESOLVED' || cellvalue === 'CANCELED');
|
||||||
|
return closed
|
||||||
|
? '<span title="' + cellvalue + '">' + label + '</span>'
|
||||||
|
: '<span style="color: red;" title="' + cellvalue + '">' + label + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function formatNoticeType(cellvalue, options, rowObject) {
|
function formatNoticeType(cellvalue, options, rowObject) {
|
||||||
if (!combo || !combo.noticeTypeList) {
|
var name = cellvalue;
|
||||||
return cellvalue;
|
if (combo && combo.noticeTypeList) {
|
||||||
}
|
for (var i = 0; i < combo.noticeTypeList.length; i++) {
|
||||||
for (var i = 0; i < combo.noticeTypeList.length; i++) {
|
if (combo.noticeTypeList[i].CODE == cellvalue) {
|
||||||
if (combo.noticeTypeList[i].CODE == cellvalue) {
|
name = combo.noticeTypeList[i].NAME;
|
||||||
return combo.noticeTypeList[i].NAME;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cellvalue;
|
// 장애인데 종료 시각이 없으면 아직 진행 중이다
|
||||||
|
if (cellvalue == NOTICE_TYPE_INCIDENT && rowObject && !rowObject.endAt) {
|
||||||
|
return '<span style="color: red;">' + name + '-진행중</span>';
|
||||||
|
}
|
||||||
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatuseYn(cellvalue, options, rowObject) {
|
function formatuseYn(cellvalue, options, rowObject) {
|
||||||
@@ -115,14 +141,15 @@
|
|||||||
searchNoticeSubject: $('input[name=searchNoticeSubject]').val(),
|
searchNoticeSubject: $('input[name=searchNoticeSubject]').val(),
|
||||||
searchNoticeDetail: $('input[name=searchNoticeDetail]').val()
|
searchNoticeDetail: $('input[name=searchNoticeDetail]').val()
|
||||||
},
|
},
|
||||||
colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '마지막 수정일', '등록일', '등록자', '조회수'],
|
colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '장애상태', '마지막 수정일', '등록일', '등록자', '조회수'],
|
||||||
colModel:[
|
colModel:[
|
||||||
{ name : 'id' , align:'center', key:true, hidden:true},
|
{ name : 'id' , align:'center', key:true, hidden:true},
|
||||||
{ name : 'rowNum' , align:'center', width:60 },
|
{ name : 'rowNum' , align:'center', width:60 },
|
||||||
{ name : 'noticeType' , align:'center', width:60, formatter: formatNoticeType },
|
{ name : 'noticeType' , align:'center', width:100, formatter: formatNoticeType },
|
||||||
{ name : 'noticeSubject' , align:'left' , width:300, formatter: formatFile },
|
{ name : 'noticeSubject' , align:'left' , width:300, formatter: formatFile },
|
||||||
{ name : 'fixYn' , align:'center', width:60, formatter: formatFixYn },
|
{ name : 'fixYn' , align:'center', width:60, formatter: formatFixYn },
|
||||||
{ name : 'useYn' , align:'center', width:60, formatter: formatuseYn },
|
{ name : 'useYn' , align:'center', width:60, formatter: formatuseYn },
|
||||||
|
{ name : 'state' , align:'center', width:90, sortable:false, formatter: formatIncidentState },
|
||||||
{ name : 'lastModifiedDate', align:'center', width:120 , formatter: timeStampFormat},
|
{ name : 'lastModifiedDate', align:'center', width:120 , formatter: timeStampFormat},
|
||||||
{ name : 'createdDate' , align:'center', width:120, formatter: timeStampFormat },
|
{ name : 'createdDate' , align:'center', width:120, formatter: timeStampFormat },
|
||||||
{ name : 'inquirerName' , align:'center', width:100 },
|
{ name : 'inquirerName' , align:'center', width:100 },
|
||||||
|
|||||||
@@ -32,10 +32,35 @@
|
|||||||
// 영향 API 목록 (UI 상태)
|
// 영향 API 목록 (UI 상태)
|
||||||
var affectedApis = [];
|
var affectedApis = [];
|
||||||
|
|
||||||
|
// 장애 타임라인 (UI 상태)
|
||||||
|
var currentIncidentId = null;
|
||||||
|
var currentIncidentState = null;
|
||||||
|
|
||||||
|
// 종결 상태 — 재전이 불가 (재발은 새 장애로 등록)
|
||||||
|
var TERMINAL_STATES = ['RESOLVED', 'CANCELED'];
|
||||||
|
|
||||||
|
// IncidentState 라벨 — 상태 콤보 문구와 동일하게 유지
|
||||||
|
var STATE_LABELS = {
|
||||||
|
'INVESTIGATING': '조사중',
|
||||||
|
'IDENTIFIED': '원인 파악',
|
||||||
|
'MONITORING': '모니터링',
|
||||||
|
'RESOLVED': '해소',
|
||||||
|
'CANCELED': '취소'
|
||||||
|
};
|
||||||
|
|
||||||
function isIncidentType(t) {
|
function isIncidentType(t) {
|
||||||
return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE;
|
return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stateLabel(code) {
|
||||||
|
if (!code) return '';
|
||||||
|
return (STATE_LABELS[code] || code) + ' (' + code + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return $('<div>').text(text == null ? '' : text).html();
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
var key = "${param.id}";
|
var key = "${param.id}";
|
||||||
isDetail = key != "" && key != "null";
|
isDetail = key != "" && key != "null";
|
||||||
@@ -76,12 +101,28 @@
|
|||||||
// INCIDENT 만 상태 영역 표시
|
// INCIDENT 만 상태 영역 표시
|
||||||
if (t === NOTICE_TYPE_INCIDENT) {
|
if (t === NOTICE_TYPE_INCIDENT) {
|
||||||
$('.incident-state-row').show();
|
$('.incident-state-row').show();
|
||||||
|
$('.incident-endat-hint').show();
|
||||||
} else {
|
} else {
|
||||||
$('.incident-state-row').hide();
|
$('.incident-state-row').hide();
|
||||||
|
$('.incident-endat-hint').hide();
|
||||||
}
|
}
|
||||||
|
// 장애는 종결(해소·취소) 후에만 종료 시각을 손볼 수 있다. 진행 중에는 상태를 따른다.
|
||||||
|
var closed = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0;
|
||||||
|
$('#endAt').prop('readonly', t === NOTICE_TYPE_INCIDENT && !closed);
|
||||||
|
$('.incident-endat-hint').text(closed
|
||||||
|
? '※ 종결된 장애입니다. 실제 복구 시각으로 수정할 수 있습니다.'
|
||||||
|
: '※ 타임라인에서 해소·취소로 전이하면 자동 입력됩니다.');
|
||||||
} else {
|
} else {
|
||||||
$('.incident-row').hide();
|
$('.incident-row').hide();
|
||||||
$('.incident-state-row').hide();
|
$('.incident-state-row').hide();
|
||||||
|
$('.incident-endat-hint').hide();
|
||||||
|
$('#endAt').prop('readonly', false);
|
||||||
|
}
|
||||||
|
// 타임라인은 등록된 장애(수정 모드)에서만 다룬다
|
||||||
|
if (t === NOTICE_TYPE_INCIDENT && isDetail && currentIncidentId) {
|
||||||
|
$('#timelineSection').show();
|
||||||
|
} else {
|
||||||
|
$('#timelineSection').hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +233,150 @@
|
|||||||
renderAffectedApis();
|
renderAffectedApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────── 장애 처리 타임라인 ─────────────────────
|
||||||
|
|
||||||
|
/** 현재 상태 기준으로 전이 가능한 선택지만 남긴다. 실제 차단은 서버가 한다. */
|
||||||
|
function applyStateChangeGuard() {
|
||||||
|
var terminal = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0;
|
||||||
|
|
||||||
|
$('#timelineStateChange').prop('disabled', terminal);
|
||||||
|
if (terminal) {
|
||||||
|
$('#timelineStateChange').prop('checked', false);
|
||||||
|
$('#timelineNextState').prop('disabled', true).val('');
|
||||||
|
}
|
||||||
|
$('#timelineClosedHint').toggle(terminal);
|
||||||
|
$('#timelineResolveHint').toggle(!terminal);
|
||||||
|
|
||||||
|
// 현재와 동일한 상태로는 전이할 수 없다
|
||||||
|
$('#timelineNextState option').each(function() {
|
||||||
|
var v = $(this).val();
|
||||||
|
$(this).prop('disabled', v !== '' && v === currentIncidentState);
|
||||||
|
});
|
||||||
|
if ($('#timelineNextState').val() === currentIncidentState) {
|
||||||
|
$('#timelineNextState').val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTimeline() {
|
||||||
|
if (!currentIncidentId) {
|
||||||
|
renderTimeline([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
type: "POST", url: url, dataType: "json",
|
||||||
|
data: {cmd: 'TIMELINE_LIST', incidentId: currentIncidentId},
|
||||||
|
success: function (rows) { renderTimeline(rows || []); },
|
||||||
|
error: function (e) { alert(e.responseText); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(rows) {
|
||||||
|
var $tb = $('#timelineTbody');
|
||||||
|
$tb.empty();
|
||||||
|
if (!rows.length) {
|
||||||
|
$tb.append('<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.each(rows, function(_, row) {
|
||||||
|
var author = row.authorType === 'SYSTEM' ? 'System' : 'Admin';
|
||||||
|
var visible = row.visibleYn === 'Y';
|
||||||
|
var $tr = $('<tr>');
|
||||||
|
$tr.append('<td style="text-align:center;white-space:nowrap;">' + escapeHtml(row.eventAt) + '</td>');
|
||||||
|
$tr.append('<td style="text-align:center;">' + author + '</td>');
|
||||||
|
$tr.append('<td style="text-align:center;white-space:nowrap;">' + (row.stateAfter ? escapeHtml(stateLabel(row.stateAfter)) : '-') + '</td>');
|
||||||
|
$tr.append('<td>' + escapeHtml(row.body).replace(/\n/g, '<br>') + '</td>');
|
||||||
|
|
||||||
|
// 현재 상태(라벨) + 눌렀을 때 바뀔 상태(버튼) 를 나눠 표기 — 버튼 하나면 의미가 모호하다
|
||||||
|
var $visibleTd = $('<td style="text-align:center;white-space:nowrap;">');
|
||||||
|
$('<span>')
|
||||||
|
.text(visible ? '공개중' : '비공개')
|
||||||
|
.css({color: visible ? '#1B8C4A' : '#999', 'margin-right': '5px'})
|
||||||
|
.appendTo($visibleTd);
|
||||||
|
$('<button type="button" class="cssbtn" level="W" status="DETAIL" style="min-width:0;padding:3px 8px;">')
|
||||||
|
.text(visible ? '비공개로' : '공개로')
|
||||||
|
.click(function() { toggleTimelineVisible(row.timelineId, visible ? 'N' : 'Y'); })
|
||||||
|
.appendTo($visibleTd);
|
||||||
|
$tr.append($visibleTd);
|
||||||
|
|
||||||
|
var $deleteTd = $('<td style="text-align:center;">');
|
||||||
|
$('<button type="button" class="cssbtn smallBtn" level="W" status="DETAIL" style="min-width:0;width:90%;">')
|
||||||
|
.text('삭제')
|
||||||
|
.click(function() { deleteTimeline(row.timelineId); })
|
||||||
|
.appendTo($deleteTd);
|
||||||
|
$tr.append($deleteTd);
|
||||||
|
|
||||||
|
$tb.append($tr);
|
||||||
|
});
|
||||||
|
buttonControl(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTimeline() {
|
||||||
|
if (!currentIncidentId) {
|
||||||
|
alert('장애 정보를 먼저 저장하여 주십시오.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var body = $('#timelineBody').val();
|
||||||
|
if (!body || !$.trim(body)) {
|
||||||
|
alert('타임라인 메세지를 입력하여 주십시오.');
|
||||||
|
$('#timelineBody').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var stateChange = $('#timelineStateChange').is(':checked');
|
||||||
|
var nextState = $('#timelineNextState').val();
|
||||||
|
if (stateChange && !nextState) {
|
||||||
|
alert('변경할 상태를 선택하여 주십시오.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
type: "POST", url: url, dataType: "json",
|
||||||
|
data: {cmd: 'TIMELINE_ADD', incidentId: currentIncidentId, body: body,
|
||||||
|
stateChange: stateChange, nextState: nextState},
|
||||||
|
success: function (data) {
|
||||||
|
$('#timelineBody').val('');
|
||||||
|
$('#timelineStateChange').prop('checked', false).trigger('change');
|
||||||
|
// 상태 전이 시 서버가 상태/이전 상태/종료 시각을 함께 갱신한다
|
||||||
|
$('#stateText').text(data.state ? stateLabel(data.state) : '');
|
||||||
|
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
|
||||||
|
$('#endAt').val(data.endAt || '');
|
||||||
|
currentIncidentState = data.state || null;
|
||||||
|
applyStateChangeGuard();
|
||||||
|
// 종결 전이 직후부터 종료 시각을 손볼 수 있어야 한다
|
||||||
|
toggleIncidentFields();
|
||||||
|
loadTimeline();
|
||||||
|
},
|
||||||
|
error: function (e) { alert(e.responseText); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTimelineVisible(timelineId, visibleYn) {
|
||||||
|
$.ajax({
|
||||||
|
type: "POST", url: url,
|
||||||
|
data: {cmd: 'TIMELINE_VISIBLE', timelineId: timelineId, visibleYn: visibleYn},
|
||||||
|
success: loadTimeline,
|
||||||
|
error: function (e) { alert(e.responseText); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTimeline(timelineId) {
|
||||||
|
if (confirm("<%= localeMessage.getString("common.checkDelete")%>") != true) return;
|
||||||
|
$.ajax({
|
||||||
|
type: "POST", url: url,
|
||||||
|
data: {cmd: 'TIMELINE_DELETE', timelineId: timelineId},
|
||||||
|
success: loadTimeline,
|
||||||
|
error: function (e) { alert(e.responseText); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 장애/점검 공지는 최초 게시 후 게시유형을 바꿀 수 없다. */
|
||||||
|
function lockNoticeType(noticeType) {
|
||||||
|
if (!isIncidentType(noticeType)) return;
|
||||||
|
$('#noticeType').prop('disabled', true);
|
||||||
|
if (!$('#noticeTypeHidden').length) {
|
||||||
|
$('#ajaxForm').append('<input type="hidden" id="noticeTypeHidden" name="noticeType">');
|
||||||
|
}
|
||||||
|
$('#noticeTypeHidden').val(noticeType);
|
||||||
|
}
|
||||||
|
|
||||||
function detail(key) {
|
function detail(key) {
|
||||||
if (!isDetail) return;
|
if (!isDetail) return;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -207,17 +392,28 @@
|
|||||||
var decodedContent = decodeHTMLEntities(data.noticeDetail);
|
var decodedContent = decodeHTMLEntities(data.noticeDetail);
|
||||||
$('#contents').summernote('code', decodedContent);
|
$('#contents').summernote('code', decodedContent);
|
||||||
|
|
||||||
|
// 게시유형은 최초 저장 후 변경 불가 (disabled 는 전송되지 않아 hidden 으로 값을 유지)
|
||||||
|
lockNoticeType(data.noticeType);
|
||||||
|
|
||||||
// 장애/점검 필드
|
// 장애/점검 필드
|
||||||
$('#summary').val(data.summary || '');
|
$('#summary').val(data.summary || '');
|
||||||
$('#startedAt').val(data.startedAt || '');
|
$('#startedAt').val(data.startedAt || '');
|
||||||
$('#endAt').val(data.endAt || '');
|
$('#endAt').val(data.endAt || '');
|
||||||
$('#state').val(data.state || 'INVESTIGATING');
|
// 상태/이전 상태는 타임라인 게시로만 바뀐다 (직접 수정 불가)
|
||||||
$('#previousState').text(data.previousState || '없음');
|
$('#stateText').text(data.state ? stateLabel(data.state) : '');
|
||||||
|
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
|
||||||
affectedApis = (data.affectedApis || []).map(function(a){
|
affectedApis = (data.affectedApis || []).map(function(a){
|
||||||
return {apiId: a.apiId, apiName: a.apiName || ''};
|
return {apiId: a.apiId, apiName: a.apiName || ''};
|
||||||
});
|
});
|
||||||
renderAffectedApis();
|
renderAffectedApis();
|
||||||
|
|
||||||
|
currentIncidentId = data.incidentId || null;
|
||||||
|
currentIncidentState = data.state || null;
|
||||||
|
applyStateChangeGuard();
|
||||||
toggleIncidentFields();
|
toggleIncidentFields();
|
||||||
|
if (data.noticeType === NOTICE_TYPE_INCIDENT && currentIncidentId) {
|
||||||
|
loadTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
if (data.fileInfo) {
|
if (data.fileInfo) {
|
||||||
fileInfo = {
|
fileInfo = {
|
||||||
@@ -245,6 +441,13 @@
|
|||||||
$('#btn_addAffectedApi').click(openApiPopup);
|
$('#btn_addAffectedApi').click(openApiPopup);
|
||||||
$('#btn_removeAffectedApi').click(removeSelectedAffectedApis);
|
$('#btn_removeAffectedApi').click(removeSelectedAffectedApis);
|
||||||
|
|
||||||
|
$('#timelineStateChange').on('change', function() {
|
||||||
|
var checked = $(this).is(':checked');
|
||||||
|
$('#timelineNextState').prop('disabled', !checked);
|
||||||
|
if (!checked) $('#timelineNextState').val('');
|
||||||
|
});
|
||||||
|
$('#btn_addTimeline').click(addTimeline);
|
||||||
|
|
||||||
$('#fileInput').change(function(e) {
|
$('#fileInput').change(function(e) {
|
||||||
var file = e.target.files[0];
|
var file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -273,6 +476,11 @@
|
|||||||
alert('점검 종료 시각을 입력해 주십시오.');
|
alert('점검 종료 시각을 입력해 주십시오.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// datetime-local 값은 'YYYY-MM-DDTHH:mm' 이라 문자열 비교로 선후가 가려진다
|
||||||
|
if ($('#endAt').val() && $('#endAt').val() < $('#startedAt').val()) {
|
||||||
|
alert('종료 시각은 시작 시각보다 빠를 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (affectedApis.length === 0) {
|
if (affectedApis.length === 0) {
|
||||||
alert('영향 API를 1건 이상 선택해 주십시오.');
|
alert('영향 API를 1건 이상 선택해 주십시오.');
|
||||||
return;
|
return;
|
||||||
@@ -394,21 +602,15 @@
|
|||||||
<th>종료</th>
|
<th>종료</th>
|
||||||
<td>
|
<td>
|
||||||
<input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움">
|
<input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움">
|
||||||
</td>
|
<div class="incident-endat-hint" style="display:none;color:#888;font-size:12px;margin-top:3px;">
|
||||||
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- 상태/이전 상태는 타임라인 게시로만 전이한다 (직접 수정 불가) -->
|
||||||
|
<tr class="incident-row incident-state-row" style="display:none;">
|
||||||
|
<th>상태</th>
|
||||||
|
<td><span id="stateText"></span></td>
|
||||||
<th>이전 상태</th>
|
<th>이전 상태</th>
|
||||||
<td><span id="previousState">없음</span></td>
|
<td><span id="previousState">없음</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -435,6 +637,61 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- 타임라인은 [수정] 저장과 별개로 게시 즉시 반영되므로 폼 밖에 둔다 -->
|
||||||
|
<div id="timelineSection" style="display:none;margin-top:20px;">
|
||||||
|
<div class="title">타임라인</div>
|
||||||
|
<table class="table_row" cellspacing="0" style="width:100%;">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 130px"/>
|
||||||
|
<col style="width: 80px"/>
|
||||||
|
<col style="width: 140px"/>
|
||||||
|
<col/>
|
||||||
|
<col style="width: 160px"/>
|
||||||
|
<col style="width: 80px"/>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>시간</th>
|
||||||
|
<th>작성자</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>메세지</th>
|
||||||
|
<th>개발자포탈 공개</th>
|
||||||
|
<th>삭제</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="timelineTbody">
|
||||||
|
<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="timelineStateChange"> 상태 변경
|
||||||
|
</label>
|
||||||
|
<div class="select-style" style="width:220px;">
|
||||||
|
<select id="timelineNextState" disabled>
|
||||||
|
<option value="">(현재 상태 유지)</option>
|
||||||
|
<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>
|
||||||
|
<span id="timelineResolveHint" style="color:#888;font-size:12px;">※ 해소(RESOLVED)·취소(CANCELED)로 전이하면 종료 시각이 자동 입력됩니다.</span>
|
||||||
|
<span id="timelineClosedHint" style="display:none;color:#C0392B;font-size:12px;">
|
||||||
|
※ 종결된 장애입니다. 상태 변경 불가 — 재발 시 새 장애로 등록하십시오.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:5px;display:flex;gap:5px;">
|
||||||
|
<textarea id="timelineBody" style="flex:1;height:60px;"
|
||||||
|
placeholder="타임라인 메세지 입력 예 : 모니터링 5분간 정상 응답 확인 후 완료 상태 전이 예정"></textarea>
|
||||||
|
<button type="button" class="cssbtn" id="btn_addTimeline" level="W" status="DETAIL">
|
||||||
|
<i class="material-icons">send</i> 게시
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
var url = '<c:url value="/onl/apim/portalproperty/propertyMan.json" />';
|
var url = '<c:url value="/onl/apim/portalproperty/propertyMan.json" />';
|
||||||
var url_view = '<c:url value="/onl/apim/portalproperty/propertyMan.view" />';
|
var url_view = '<c:url value="/onl/apim/portalproperty/propertyMan.view" />';
|
||||||
var lastsel2;
|
var lastsel2;
|
||||||
|
var clickedCol; //클릭한 셀의 컬럼 인덱스(포커스 이동용)
|
||||||
|
|
||||||
function isValid() {
|
function isValid() {
|
||||||
if ($('input[name=prptyGroupName]').val() == "") {
|
if ($('input[name=prptyGroupName]').val() == "") {
|
||||||
@@ -103,6 +104,10 @@
|
|||||||
},
|
},
|
||||||
loadComplete: function () {
|
loadComplete: function () {
|
||||||
},
|
},
|
||||||
|
onCellSelect: function (rowid, iCol) {
|
||||||
|
//onSelectRow보다 먼저 발화 → 클릭한 컬럼 인덱스 저장
|
||||||
|
clickedCol = iCol;
|
||||||
|
},
|
||||||
onSelectRow: function (rowid, status) {
|
onSelectRow: function (rowid, status) {
|
||||||
if (lastsel2 != undefined) {
|
if (lastsel2 != undefined) {
|
||||||
if ($("#grid tr#" + lastsel2).attr("editable") == 1) { //editable=1 means row in edit mode
|
if ($("#grid tr#" + lastsel2).attr("editable") == 1) { //editable=1 means row in edit mode
|
||||||
@@ -110,7 +115,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$('#grid').restoreRow(lastsel2);
|
$('#grid').restoreRow(lastsel2);
|
||||||
$('#grid').editRow(rowid, true);
|
//focusField로 클릭한 셀에 포커스(미지정 시 첫 편집 컬럼으로 감)
|
||||||
|
$('#grid').editRow(rowid, {keys: true, focusField: clickedCol});
|
||||||
lastsel2 = rowid;
|
lastsel2 = rowid;
|
||||||
},
|
},
|
||||||
onSortCol: function () {
|
onSortCol: function () {
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullLoginId = id + "@" + domain;
|
const fullLoginId = id + "@" + domain;
|
||||||
const emailPattern = /^[A-Za-z0-9._-]*@[A-Za-z0-9][-A-Za-z0-9.]+\.[A-Za-z]{2,}$/;
|
const emailPattern = /^[A-Za-z0-9._%+-]*@[A-Za-z0-9][-A-Za-z0-9.]+\.[A-Za-z]{2,}$/;
|
||||||
if (!emailPattern.test(fullLoginId)) {
|
if (!emailPattern.test(fullLoginId)) {
|
||||||
alert(fullLoginId + " 은 올바른 이메일 형식이 아닙니다.");
|
alert(fullLoginId + " 은 올바른 이메일 형식이 아닙니다.");
|
||||||
return;
|
return;
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateEmailId(input) {
|
function validateEmailId(input) {
|
||||||
input.value = input.value.replace(/[^A-Za-z0-9._-]/g, '');
|
input.value = input.value.replace(/[^A-Za-z0-9._%+-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateDomainInput(input) {
|
function validateDomainInput(input) {
|
||||||
@@ -531,8 +531,8 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 신규 등록: 사유 불필요
|
// 신규 등록: 사유 불필요. 초기 패스워드는 비밀번호 초기화와 동일(이메일ID + subfix)
|
||||||
if (!confirm("<%= localeMessage.getString("common.checkSave")%>")) return;
|
if (!confirm("가입자를 등록하시겠습니까?\n초기 패스워드는 '이메일ID${passwordInitSubfix}' 입니다.")) return;
|
||||||
submitUser("INSERT", null);
|
submitUser("INSERT", null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#btn_password_reset").click(function () {
|
$("#btn_password_reset").click(function () {
|
||||||
if (!confirm("패스워드를 초기화 하시겠습니까?\n초기 패스워드는 '!' + 이메일ID 입니다.")) return;
|
if (!confirm("패스워드를 초기화 하시겠습니까?\n초기 패스워드는 '이메일ID${passwordInitSubfix}' 입니다.")) return;
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
search();
|
search();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("input[name=searchType]").click(function() {
|
||||||
|
search();
|
||||||
|
});
|
||||||
|
|
||||||
$("#btn_excel").click(function() {
|
$("#btn_excel").click(function() {
|
||||||
exportToExcel();
|
exportToExcel();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
gwAddress: "${gwAddress}",
|
gwAddress: "${gwAddress}",
|
||||||
useProxy: ${useProxy},
|
useProxy: ${useProxy},
|
||||||
tokenUseProxy: ${tokenUseProxy},
|
tokenUseProxy: ${tokenUseProxy},
|
||||||
|
exportEnabled: ${exportEnabled},
|
||||||
orgPopupUrl: "<c:url value='/onl/transaction/apim/apiSpecMan.view'/>",
|
orgPopupUrl: "<c:url value='/onl/transaction/apim/apiSpecMan.view'/>",
|
||||||
groupPopupUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.view'/>?cmd=GROUP_POPUP",
|
groupPopupUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.view'/>?cmd=GROUP_POPUP",
|
||||||
jsonUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>"
|
jsonUrl: "<c:url value='/onl/transaction/apim/djbApiSpecMan.json'/>"
|
||||||
@@ -93,6 +94,8 @@
|
|||||||
<button id="btn-newwin" class="hidden px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">새창으로 띄우기</button>
|
<button id="btn-newwin" class="hidden px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">새창으로 띄우기</button>
|
||||||
<button id="btn-regen" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">자동 생성(초기화)</button>
|
<button id="btn-regen" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">자동 생성(초기화)</button>
|
||||||
<button id="btn-save" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700">저장</button>
|
<button id="btn-save" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700">저장</button>
|
||||||
|
<%-- 내보내기 버튼은 PTL_PROPERTY(djb.openapi.export.enabled=true) 일 때만 노출. 기본 미노출. --%>
|
||||||
|
<c:if test="${exportEnabled}">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button id="btn-export" class="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">내보내기 ▾</button>
|
<button id="btn-export" class="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">내보내기 ▾</button>
|
||||||
<div id="export-menu" class="hidden absolute right-0 mt-1 w-44 bg-white border border-slate-200 rounded shadow-lg z-40">
|
<div id="export-menu" class="hidden absolute right-0 mt-1 w-44 bg-white border border-slate-200 rounded shadow-lg z-40">
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
<button data-export="json" class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-50">JSON 다운로드</button>
|
<button data-export="json" class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-50">JSON 다운로드</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</c:if>
|
||||||
<button id="btn-close" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">닫기</button>
|
<button id="btn-close" class="px-3 py-1.5 text-sm rounded border border-slate-300 bg-white hover:bg-slate-50">닫기</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -380,15 +380,15 @@
|
|||||||
{name: 'TRACKASISKEY2CTNT', hidden: true},
|
{name: 'TRACKASISKEY2CTNT', hidden: true},
|
||||||
|
|
||||||
{name: 'EAISVCSERNO_TMP', align: 'left', width: '220', formatter: trimFormatter},
|
{name: 'EAISVCSERNO_TMP', align: 'left', width: '220', formatter: trimFormatter},
|
||||||
{name: 'KEYMGTMSGCTNT', align: 'left', width: '240'},
|
{name: 'KEYMGTMSGCTNT', align: 'left', width: '300'},
|
||||||
{name: 'LOGPRCSSSERNO_TMP', align: 'center', width: '40'},
|
{name: 'LOGPRCSSSERNO_TMP', align: 'center', width: '40'},
|
||||||
{name: 'TELGMRECVTRANCD', align: 'left', width: '135', hidden: true},
|
{name: 'TELGMRECVTRANCD', align: 'left', width: '135', hidden: true},
|
||||||
{name: 'EAISVCNAME', align: 'left', width: '130'},
|
{name: 'EAISVCNAME', align: 'left', width: '160'},
|
||||||
{name: 'EAISVCDESC', align: 'left', width: '280'},
|
{name: 'EAISVCDESC', align: 'left', width: '280'},
|
||||||
{name: 'EAIBZWKDSTCD_TMP', align: 'center', width: '50'},
|
{name: 'EAIBZWKDSTCD_TMP', align: 'center', width: '50'},
|
||||||
{name: 'EXTBIZCD', align: 'center', width: '50', hidden: isApiGw},
|
{name: 'EXTBIZCD', align: 'center', width: '50', hidden: isApiGw},
|
||||||
{name: 'REFKEY', align: 'left', width: '280', hidden: isApiGw},
|
{name: 'REFKEY', align: 'left', width: '280', hidden: isApiGw},
|
||||||
{name: 'CLIENTNAME', align: 'left', width: '260', hidden: !isApiGw},
|
{name: 'CLIENTNAME', align: 'left', width: '120', hidden: !isApiGw},
|
||||||
{name: 'ORGNAME', align: 'left', width: '100', hidden: !isApiGw, formatter: orgNameFormatter},
|
{name: 'ORGNAME', align: 'left', width: '100', hidden: !isApiGw, formatter: orgNameFormatter},
|
||||||
{name: 'MSGDPSTYMS_TMP', align: 'center', width: '150', formatter: timeStampFormat2},
|
{name: 'MSGDPSTYMS_TMP', align: 'center', width: '150', formatter: timeStampFormat2},
|
||||||
{name: 'MSGPRCSSYMS', align: 'center', width: '150', formatter: timeStampFormat2},
|
{name: 'MSGPRCSSYMS', align: 'center', width: '150', formatter: timeStampFormat2},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ plugins {
|
|||||||
id 'eclipse-wtp'
|
id 'eclipse-wtp'
|
||||||
id 'idea'
|
id 'idea'
|
||||||
id 'war'
|
id 'war'
|
||||||
|
id 'org.cyclonedx.bom' version '3.2.4'
|
||||||
}
|
}
|
||||||
|
|
||||||
group 'com.eactive'
|
group 'com.eactive'
|
||||||
@@ -117,6 +118,12 @@ dependencies {
|
|||||||
//implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
//implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
|
||||||
|
|
||||||
|
// apistatus-draft.yml (자동 탐지 초안 양식) 로딩용.
|
||||||
|
// admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없어 직접 파싱한다.
|
||||||
|
// 2.x 를 쓴다 - 1.x 는 CVE-2022-1471(Constructor 역직렬화 RCE) 및
|
||||||
|
// CVE-2022-25857/38749~38752(DoS) 영향. 로딩도 SafeConstructor 로 한다.
|
||||||
|
implementation 'org.yaml:snakeyaml:2.4'
|
||||||
|
|
||||||
implementation "javax.jms:javax.jms-api:2.0"
|
implementation "javax.jms:javax.jms-api:2.0"
|
||||||
implementation "org.snmp4j:snmp4j:1.10.1"
|
implementation "org.snmp4j:snmp4j:1.10.1"
|
||||||
implementation "com.googlecode.json-simple:json-simple:1.1.1"
|
implementation "com.googlecode.json-simple:json-simple:1.1.1"
|
||||||
@@ -271,3 +278,5 @@ tasks.withType(JavaCompile) {
|
|||||||
options.forkOptions.memoryMaximumSize = '4g' // 컴파일러에 2GB 할당 (필요시 4g로 증량)
|
options.forkOptions.memoryMaximumSize = '4g' // 컴파일러에 2GB 할당 (필요시 4g로 증량)
|
||||||
options.encoding = 'UTF-8'
|
options.encoding = 'UTF-8'
|
||||||
}
|
}
|
||||||
|
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
|
||||||
|
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
|
||||||
|
*
|
||||||
|
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
|
||||||
|
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
|
||||||
|
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
|
||||||
|
*
|
||||||
|
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
|
||||||
|
* classpath 나 WAR 산출물에는 포함되지 않는다.
|
||||||
|
*
|
||||||
|
* 시트: 요약 / WAR 기준
|
||||||
|
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
|
||||||
|
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
|
||||||
|
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
|
||||||
|
*/
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
|
||||||
|
allowInsecureProtocol = true
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'org.apache.poi:poi-ooxml:3.17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
import org.apache.poi.ss.usermodel.BorderStyle
|
||||||
|
import org.apache.poi.ss.usermodel.FillPatternType
|
||||||
|
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||||
|
import org.apache.poi.ss.usermodel.IndexedColors
|
||||||
|
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||||
|
import org.apache.poi.ss.util.CellRangeAddress
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||||
|
|
||||||
|
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
|
||||||
|
ext.SBOM_CELL_LIMIT = 32000
|
||||||
|
|
||||||
|
task sbomXlsx {
|
||||||
|
group = 'sbom'
|
||||||
|
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
|
||||||
|
|
||||||
|
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
|
||||||
|
if (!project.hasProperty('sbomJson')) {
|
||||||
|
dependsOn 'cyclonedxBom'
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
File src = resolveBomJson(project)
|
||||||
|
File out = project.hasProperty('sbomOut')
|
||||||
|
? project.file(project.property('sbomOut'))
|
||||||
|
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
|
||||||
|
out.parentFile.mkdirs()
|
||||||
|
|
||||||
|
def bom = new JsonSlurper().parse(src, 'UTF-8')
|
||||||
|
def deploy = collectDeployJars(project)
|
||||||
|
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
|
||||||
|
|
||||||
|
def wb = new XSSFWorkbook()
|
||||||
|
def st = createStyles(wb)
|
||||||
|
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
|
||||||
|
writeWarSheet(wb, st, warRows)
|
||||||
|
|
||||||
|
out.withOutputStream { os -> wb.write(os) }
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
int unmatched = warRows.count { it.matched == 'N' }
|
||||||
|
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
|
||||||
|
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
|
||||||
|
String sbomFileName(Project p) {
|
||||||
|
def warTask = p.tasks.findByName('war')
|
||||||
|
if (warTask != null) {
|
||||||
|
String archive = warTask.archiveFileName.get()
|
||||||
|
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
|
||||||
|
}
|
||||||
|
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
|
||||||
|
return "${p.name}${ver}-sbom.xlsx"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
|
||||||
|
File resolveBomJson(Project p) {
|
||||||
|
if (p.hasProperty('sbomJson')) {
|
||||||
|
File f = p.file(p.property('sbomJson'))
|
||||||
|
if (!f.exists()) {
|
||||||
|
throw new GradleException("bom.json 없음: ${f.absolutePath}")
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
def candidates = [
|
||||||
|
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
|
||||||
|
new File(p.buildDir, 'reports/bom.json'),
|
||||||
|
]
|
||||||
|
File found = candidates.find { it.exists() }
|
||||||
|
if (found == null) {
|
||||||
|
throw new GradleException(
|
||||||
|
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
|
||||||
|
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 배포물에 packaging 되는 jar 목록.
|
||||||
|
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
|
||||||
|
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
|
||||||
|
*
|
||||||
|
* @return [label: 기준 설명, jars: 행 목록]
|
||||||
|
*/
|
||||||
|
Map collectDeployJars(Project p) {
|
||||||
|
def cfg = p.configurations.findByName('runtimeClasspath')
|
||||||
|
if (cfg == null) {
|
||||||
|
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
|
||||||
|
return [label: '(없음)', jars: []]
|
||||||
|
}
|
||||||
|
|
||||||
|
def warTask = p.tasks.findByName('war')
|
||||||
|
def files
|
||||||
|
String label
|
||||||
|
if (warTask != null) {
|
||||||
|
files = warTask.classpath.files
|
||||||
|
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
|
||||||
|
} else {
|
||||||
|
files = cfg.files
|
||||||
|
label = 'runtimeClasspath (war 태스크 없음)'
|
||||||
|
}
|
||||||
|
|
||||||
|
def coordByFile = [:]
|
||||||
|
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
|
||||||
|
def id = a.moduleVersion.id
|
||||||
|
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
|
||||||
|
}
|
||||||
|
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
|
||||||
|
def c = coordByFile[f]
|
||||||
|
[
|
||||||
|
file : f.name,
|
||||||
|
group : c?.group ?: '',
|
||||||
|
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
|
||||||
|
version: c?.version ?: '',
|
||||||
|
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
|
||||||
|
]
|
||||||
|
}.sort { it.file }
|
||||||
|
|
||||||
|
return [label: label, jars: jars]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
|
||||||
|
Map indexComponents(bom) {
|
||||||
|
String rootRef = bom.metadata?.component?.'bom-ref'
|
||||||
|
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
|
||||||
|
|
||||||
|
def index = [:]
|
||||||
|
bom.components?.each { c ->
|
||||||
|
def hashes = [:]
|
||||||
|
c.hashes?.each { h -> hashes[h.alg] = h.content }
|
||||||
|
|
||||||
|
def licenses = (c.licenses ?: []).collect { l ->
|
||||||
|
l.license?.id ?: l.license?.name ?: l.expression ?: ''
|
||||||
|
}.findAll { it }
|
||||||
|
|
||||||
|
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
|
||||||
|
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
|
||||||
|
licenses : licenses.join('; '),
|
||||||
|
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
|
||||||
|
purl : c.purl ?: '',
|
||||||
|
sha256 : hashes['SHA-256'] ?: '',
|
||||||
|
sha1 : hashes['SHA-1'] ?: '',
|
||||||
|
description: c.description ?: '',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
|
||||||
|
List joinWarRows(List warJars, Map index) {
|
||||||
|
def result = []
|
||||||
|
warJars.eachWithIndex { j, i ->
|
||||||
|
def m = j.coord ? index[j.coord] : null
|
||||||
|
result << [
|
||||||
|
no : i + 1,
|
||||||
|
file : j.file,
|
||||||
|
group : j.group,
|
||||||
|
name : j.name,
|
||||||
|
version : j.version,
|
||||||
|
direct : m?.direct ?: '',
|
||||||
|
licenses : m?.licenses ?: '',
|
||||||
|
licenseList: m?.licenseList ?: ['(미상)'],
|
||||||
|
purl : m?.purl ?: '',
|
||||||
|
sha256 : m?.sha256 ?: '',
|
||||||
|
sha1 : m?.sha1 ?: '',
|
||||||
|
matched : (m != null) ? 'Y' : 'N',
|
||||||
|
description: m?.description ?: '',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
Map createStyles(wb) {
|
||||||
|
def headFont = wb.createFont()
|
||||||
|
headFont.setBold(true)
|
||||||
|
headFont.setColor(IndexedColors.WHITE.getIndex())
|
||||||
|
|
||||||
|
def head = wb.createCellStyle()
|
||||||
|
head.setFont(headFont)
|
||||||
|
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
|
||||||
|
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
|
||||||
|
head.setAlignment(HorizontalAlignment.CENTER)
|
||||||
|
head.setVerticalAlignment(VerticalAlignment.CENTER)
|
||||||
|
head.setBorderBottom(BorderStyle.THIN)
|
||||||
|
|
||||||
|
def body = wb.createCellStyle()
|
||||||
|
body.setVerticalAlignment(VerticalAlignment.TOP)
|
||||||
|
|
||||||
|
def wrap = wb.createCellStyle()
|
||||||
|
wrap.setVerticalAlignment(VerticalAlignment.TOP)
|
||||||
|
wrap.setWrapText(true)
|
||||||
|
|
||||||
|
def labelFont = wb.createFont()
|
||||||
|
labelFont.setBold(true)
|
||||||
|
def label = wb.createCellStyle()
|
||||||
|
label.setFont(labelFont)
|
||||||
|
|
||||||
|
return [head: head, body: body, wrap: wrap, label: label]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
|
||||||
|
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
|
||||||
|
def row = sheet.createRow(0)
|
||||||
|
row.setHeightInPoints(20f)
|
||||||
|
headers.eachWithIndex { h, i ->
|
||||||
|
def cell = row.createCell(i)
|
||||||
|
cell.setCellValue(h)
|
||||||
|
cell.setCellStyle(style)
|
||||||
|
sheet.setColumnWidth(i, widths[i] * 256)
|
||||||
|
}
|
||||||
|
sheet.createFreezePane(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
def cellOf(row, int idx, value, style) {
|
||||||
|
def cell = row.createCell(idx)
|
||||||
|
String s = (value == null) ? '' : value.toString()
|
||||||
|
if (s.length() > SBOM_CELL_LIMIT) {
|
||||||
|
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
|
||||||
|
}
|
||||||
|
cell.setCellValue(s)
|
||||||
|
cell.setCellStyle(style)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
|
||||||
|
def sheet = wb.createSheet('요약')
|
||||||
|
def comp = bom.metadata?.component ?: [:]
|
||||||
|
def tool = bom.metadata?.tools?.components?.getAt(0)
|
||||||
|
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
|
||||||
|
|
||||||
|
def items = [
|
||||||
|
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
|
||||||
|
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
|
||||||
|
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
|
||||||
|
['serialNumber', bom.serialNumber ?: ''],
|
||||||
|
['생성 시각', bom.metadata?.timestamp ?: ''],
|
||||||
|
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
|
||||||
|
['원본 파일', src.absolutePath],
|
||||||
|
['배포 수록 jar', warRows.size()],
|
||||||
|
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
|
||||||
|
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
|
||||||
|
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
|
||||||
|
['라이선스 종류', licenseKinds.size()],
|
||||||
|
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
|
||||||
|
]
|
||||||
|
|
||||||
|
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
|
||||||
|
items.eachWithIndex { item, i ->
|
||||||
|
def row = sheet.createRow(i + 1)
|
||||||
|
cellOf(row, 0, item[0], st.label)
|
||||||
|
cellOf(row, 1, item[1], st.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라이선스 분포 (요약 하단)
|
||||||
|
def byLicense = [:].withDefault { 0 }
|
||||||
|
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
|
||||||
|
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
|
||||||
|
|
||||||
|
int base = items.size() + 2
|
||||||
|
def hdr = sheet.createRow(base)
|
||||||
|
cellOf(hdr, 0, '라이선스', st.head)
|
||||||
|
cellOf(hdr, 1, 'jar 수', st.head)
|
||||||
|
sorted.eachWithIndex { e, i ->
|
||||||
|
def row = sheet.createRow(base + 1 + i)
|
||||||
|
cellOf(row, 0, e.key, st.body)
|
||||||
|
cellOf(row, 1, e.value, st.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
|
||||||
|
def writeWarSheet(wb, st, List warRows) {
|
||||||
|
def sheet = wb.createSheet('WAR 기준')
|
||||||
|
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
|
||||||
|
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
|
||||||
|
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
|
||||||
|
writeHeader(sheet, st.head, headers, widths)
|
||||||
|
|
||||||
|
warRows.eachWithIndex { r, i ->
|
||||||
|
def row = sheet.createRow(i + 1)
|
||||||
|
cellOf(row, 0, r.no, st.body)
|
||||||
|
cellOf(row, 1, r.file, st.body)
|
||||||
|
cellOf(row, 2, r.group, st.body)
|
||||||
|
cellOf(row, 3, r.name, st.body)
|
||||||
|
cellOf(row, 4, r.version, st.body)
|
||||||
|
cellOf(row, 5, r.direct, st.body)
|
||||||
|
cellOf(row, 6, r.licenses, st.body)
|
||||||
|
cellOf(row, 7, r.purl, st.body)
|
||||||
|
cellOf(row, 8, r.sha256, st.body)
|
||||||
|
cellOf(row, 9, r.sha1, st.body)
|
||||||
|
cellOf(row, 10, r.matched, st.body)
|
||||||
|
cellOf(row, 11, r.description, st.wrap)
|
||||||
|
}
|
||||||
|
if (!warRows.isEmpty()) {
|
||||||
|
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -51,6 +51,9 @@ public class AuditLogPageRepositoryImpl implements AuditLogPageRepository {
|
|||||||
if (StringUtils.isNotBlank(command.getSearchRemoteAddress())) {
|
if (StringUtils.isNotBlank(command.getSearchRemoteAddress())) {
|
||||||
jpaQuery.where(qAuditLogEntity.remoteAddress.containsIgnoreCase(command.getSearchRemoteAddress()));
|
jpaQuery.where(qAuditLogEntity.remoteAddress.containsIgnoreCase(command.getSearchRemoteAddress()));
|
||||||
}
|
}
|
||||||
|
if (StringUtils.isNotBlank(command.getSearchParameters())) {
|
||||||
|
jpaQuery.where(qAuditLogEntity.parameters.containsIgnoreCase(command.getSearchParameters()));
|
||||||
|
}
|
||||||
|
|
||||||
long totalCount = jpaQuery.fetchOne();
|
long totalCount = jpaQuery.fetchOne();
|
||||||
List<AuditLogEntity> auditLogs = jpaQuery
|
List<AuditLogEntity> auditLogs = jpaQuery
|
||||||
|
|||||||
+1
@@ -18,6 +18,7 @@ public class SelectListAuditLogCommand {
|
|||||||
|
|
||||||
private String searchUserId;
|
private String searchUserId;
|
||||||
private String searchRemoteAddress;
|
private String searchRemoteAddress;
|
||||||
|
private String searchParameters;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyyMMddHHmmss")
|
@JsonFormat(pattern = "yyyyMMddHHmmss")
|
||||||
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
|
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package com.eactive.eai.rms.common.acl.sitemap;
|
package com.eactive.eai.rms.common.acl.sitemap;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -7,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import com.eactive.eai.rms.common.base.BaseAnnotationController;
|
import com.eactive.eai.rms.common.base.BaseAnnotationController;
|
||||||
|
import com.eactive.eai.rms.common.login.SessionManager;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -15,8 +20,10 @@ public class SitemapController extends BaseAnnotationController {
|
|||||||
private final SitemapService sitemapService;
|
private final SitemapService sitemapService;
|
||||||
|
|
||||||
@GetMapping(value = "/common/acl/sitemap/sitemapMan.view")
|
@GetMapping(value = "/common/acl/sitemap/sitemapMan.view")
|
||||||
public String view(@RequestParam(value = "serviceType", required = false) String serviceType, Model model) {
|
public String view(@RequestParam(value = "serviceType", required = false) String serviceType,
|
||||||
model.addAttribute("sitemapTree", sitemapService.getSitemapTree(serviceType));
|
HttpServletRequest request, Model model) {
|
||||||
|
List<String> roleIds = SessionManager.getRoleId(request);
|
||||||
|
model.addAttribute("sitemapTree", sitemapService.getSitemapTree(roleIds, serviceType));
|
||||||
return "/common/acl/sitemap/sitemapMan";
|
return "/common/acl/sitemap/sitemapMan";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ import com.eactive.eai.rms.common.acl.sitemap.ui.SitemapNode;
|
|||||||
|
|
||||||
public interface SitemapService {
|
public interface SitemapService {
|
||||||
|
|
||||||
public List<SitemapNode> getSitemapTree(String serviceType);
|
public List<SitemapNode> getSitemapTree(List<String> roleIds, String serviceType);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class SitemapServiceImpl extends BaseService implements SitemapService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private RoleMenuAuthService roleMenuAuthService;
|
private RoleMenuAuthService roleMenuAuthService;
|
||||||
|
|
||||||
public List<SitemapNode> getSitemapTree(String serviceType) {
|
public List<SitemapNode> getSitemapTree(List<String> roleIds, String serviceType) {
|
||||||
Menu root = menuService.getById("ROOT_DEV");
|
Menu root = menuService.getById("ROOT_DEV");
|
||||||
Menu langRoot = findLangRoot(root);
|
Menu langRoot = findLangRoot(root);
|
||||||
List<SitemapNode> tree = new ArrayList<>();
|
List<SitemapNode> tree = new ArrayList<>();
|
||||||
@@ -38,7 +38,7 @@ public class SitemapServiceImpl extends BaseService implements SitemapService {
|
|||||||
|
|
||||||
Set<String> serviceTypeMenuIds = StringUtils.isBlank(serviceType)
|
Set<String> serviceTypeMenuIds = StringUtils.isBlank(serviceType)
|
||||||
? null
|
? null
|
||||||
: new HashSet<>(roleMenuAuthService.findMenuIdsByServiceType(serviceType));
|
: new HashSet<>(roleMenuAuthService.findMenuIdsByRoleIdsAndServiceType(roleIds, serviceType));
|
||||||
|
|
||||||
for (Menu menu : sortedChildren(langRoot)) {
|
for (Menu menu : sortedChildren(langRoot)) {
|
||||||
if (isVisible(menu) && matchesServiceType(menu, serviceTypeMenuIds)) {
|
if (isVisible(menu) && matchesServiceType(menu, serviceTypeMenuIds)) {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class UserManService extends BaseService {
|
public class UserManService extends BaseService {
|
||||||
|
|
||||||
|
private static final String USER_STATUS_NORMAL = "1";
|
||||||
|
|
||||||
private final UserInfoService userInfoService;
|
private final UserInfoService userInfoService;
|
||||||
private final UserRoleService userRoleService;
|
private final UserRoleService userRoleService;
|
||||||
@@ -100,6 +100,7 @@ public class UserManService extends BaseService {
|
|||||||
|
|
||||||
UserInfo userInfo = userUIMapper.toEntity(userUI);
|
UserInfo userInfo = userUIMapper.toEntity(userUI);
|
||||||
userInfo.setRoleidnfiname(CommonConstants.DEPT_DEVELOPER);
|
userInfo.setRoleidnfiname(CommonConstants.DEPT_DEVELOPER);
|
||||||
|
userInfo.setStatus(USER_STATUS_NORMAL);
|
||||||
|
|
||||||
String subfix = monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
|
String subfix = monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
|
||||||
|
|
||||||
@@ -133,6 +134,12 @@ public class UserManService extends BaseService {
|
|||||||
public void update(UserUI userUI) {
|
public void update(UserUI userUI) {
|
||||||
UserInfo userInfo = userInfoService.getById(userUI.getUserId());
|
UserInfo userInfo = userInfoService.getById(userUI.getUserId());
|
||||||
userUIMapper.updateToEntity(userUI, userInfo);
|
userUIMapper.updateToEntity(userUI, userInfo);
|
||||||
|
|
||||||
|
// 계정상태를 정상으로 저장하면 로그인 실패횟수 초기화
|
||||||
|
if (USER_STATUS_NORMAL.equals(userInfo.getStatus())) {
|
||||||
|
userInfo.setLoginfailcount(0);
|
||||||
|
}
|
||||||
|
|
||||||
userInfoService.save(userInfo);
|
userInfoService.save(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +155,11 @@ public class UserManService extends BaseService {
|
|||||||
UserInfo userInfo = userInfoService.getById(userUI.getUserId());
|
UserInfo userInfo = userInfoService.getById(userUI.getUserId());
|
||||||
userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256, userUI.getUserId()+subfix));
|
userInfo.setPassword(DamoManager.getInstance().hash(DamoManager.SHA256, userUI.getUserId()+subfix));
|
||||||
userUIMapper.updateToEntity(userUI, userInfo);
|
userUIMapper.updateToEntity(userUI, userInfo);
|
||||||
|
|
||||||
|
// 비밀번호 초기화 시 계정상태를 정상으로 변경하고 로그인 실패횟수 초기화
|
||||||
|
userInfo.setLoginfailcount(0);
|
||||||
|
userInfo.setStatus(USER_STATUS_NORMAL);
|
||||||
|
|
||||||
userInfoService.save(userInfo);
|
userInfoService.save(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public interface UserUIMapper extends GenericMapper<UserUI, UserInfo> {
|
|||||||
@Mapping(source = "secondmentstdt", target = "SECONDMENTSTDT")
|
@Mapping(source = "secondmentstdt", target = "SECONDMENTSTDT")
|
||||||
@Mapping(source = "secondmentendt", target = "SECONDMENTENDT")
|
@Mapping(source = "secondmentendt", target = "SECONDMENTENDT")
|
||||||
@Mapping(source = "allowip", target = "allowIp")
|
@Mapping(source = "allowip", target = "allowIp")
|
||||||
|
@Mapping(source = "lastloginyms", target = "LASTLOGINYMS")
|
||||||
|
@Mapping(source = "regdyms", target = "REGDYMS")
|
||||||
UserUI toVo(UserInfo entity);
|
UserUI toVo(UserInfo entity);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -82,4 +82,14 @@ public class UserUI {
|
|||||||
|
|
||||||
@JsonProperty("STATUS")
|
@JsonProperty("STATUS")
|
||||||
private String status; // 사용자계정상태
|
private String status; // 사용자계정상태
|
||||||
|
|
||||||
|
@JsonProperty("LASTLOGINYMS")
|
||||||
|
@JsonFormat(pattern = LocalDateTimeFormatters.FORMAT_YYYYMMDDHHMMSS_14)
|
||||||
|
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
|
||||||
|
private LocalDateTime LASTLOGINYMS; // 최종로그인일시
|
||||||
|
|
||||||
|
@JsonProperty("REGDYMS")
|
||||||
|
@JsonFormat(pattern = LocalDateTimeFormatters.FORMAT_YYYYMMDDHHMMSS_14)
|
||||||
|
@DateTimeFormat(pattern = "yyyyMMddHHmmss")
|
||||||
|
private LocalDateTime REGDYMS; // 등록일시
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ public interface MonitoringContext {
|
|||||||
//비밀번호 초기화 접미사
|
//비밀번호 초기화 접미사
|
||||||
public static final String RMS_PASSWORD_INIT_SUBFIX = "rms.password.init.subfix";
|
public static final String RMS_PASSWORD_INIT_SUBFIX = "rms.password.init.subfix";
|
||||||
|
|
||||||
|
// 최대 로그인 실패 허용횟수 (0이면 계정 잠금 미적용)
|
||||||
|
public static final String RMS_PASSWORD_FAIL_COUNT = "rms.password.fail.count";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ public class MainController implements InterceptorSkipController {
|
|||||||
private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp";
|
private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp";
|
||||||
private static final String REDIRECT_CHANGE_PASSWORD_URL = "redirect:/emergency.jsp";
|
private static final String REDIRECT_CHANGE_PASSWORD_URL = "redirect:/emergency.jsp";
|
||||||
|
|
||||||
|
private static final String USER_STATUS_LOCKED = "2";
|
||||||
|
private static final int DEFAULT_MAX_LOGIN_FAIL_COUNT = 5;
|
||||||
|
|
||||||
private final LocaleMessage localeMessage;
|
private final LocaleMessage localeMessage;
|
||||||
private final MonitoringContext monitoringContext;
|
private final MonitoringContext monitoringContext;
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ public class MainController implements InterceptorSkipController {
|
|||||||
session.removeAttribute(RESULT_MSG);
|
session.removeAttribute(RESULT_MSG);
|
||||||
return LoginResponseDto.builder()
|
return LoginResponseDto.builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
.errorMessage(errorMsg != null ? errorMsg : "로그인에 실패했습니다.")
|
.errorMessage(toAlertMessage(errorMsg != null ? errorMsg : "로그인에 실패했습니다."))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +348,19 @@ public class MainController implements InterceptorSkipController {
|
|||||||
|
|
||||||
//logger.info("사용자 계정 상태: " + userInfo.getStatus());
|
//logger.info("사용자 계정 상태: " + userInfo.getStatus());
|
||||||
|
|
||||||
if ( userInfo.getStatus() != null && "2".equals(userInfo.getStatus()) ) {
|
if ( USER_STATUS_LOCKED.equals(userInfo.getStatus()) ) {
|
||||||
setLoginFailure(request, session, "login.checkstatus1",
|
int maxLoginFailCount = getMaxLoginFailCount();
|
||||||
"login.checkstatus2");
|
boolean lockedByFailCount = maxLoginFailCount > 0
|
||||||
|
&& userInfo.getLoginfailcount() != null
|
||||||
|
&& userInfo.getLoginfailcount() >= maxLoginFailCount;
|
||||||
|
|
||||||
|
if (lockedByFailCount) {
|
||||||
|
setLoginFailure(request, session, "login.accountlocked1",
|
||||||
|
"login.accountlocked2", String.valueOf(maxLoginFailCount));
|
||||||
|
} else {
|
||||||
|
setLoginFailure(request, session, "login.checkstatus1",
|
||||||
|
"login.checkstatus2");
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +387,9 @@ public class MainController implements InterceptorSkipController {
|
|||||||
/* 기존로직 - LDAP 사용 x */
|
/* 기존로직 - LDAP 사용 x */
|
||||||
if (!userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, dto.getPassword()))) {
|
if (!userInfo.getPassword().equals(DamoManager.getInstance().hash(DamoManager.SHA256, dto.getPassword()))) {
|
||||||
// password 다름.
|
// password 다름.
|
||||||
|
int failCount = increaseLoginFailCount(userInfo);
|
||||||
setLoginFailure(request, session, "login.loginfail1",
|
setLoginFailure(request, session, "login.loginfail1",
|
||||||
"login.loginfail2");
|
"login.loginfail2", failCount);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -389,8 +403,9 @@ public class MainController implements InterceptorSkipController {
|
|||||||
//if (!authenticate(userInfo.getUserid(), dto.getPassword())) {
|
//if (!authenticate(userInfo.getUserid(), dto.getPassword())) {
|
||||||
if (!authenticate(userInfo.getUserid(), password)) {
|
if (!authenticate(userInfo.getUserid(), password)) {
|
||||||
// password 다름.
|
// password 다름.
|
||||||
|
int failCount = increaseLoginFailCount(userInfo);
|
||||||
setLoginFailure(request, session, "login.loginfail1",
|
setLoginFailure(request, session, "login.loginfail1",
|
||||||
"login.loginfail2");
|
"login.loginfail2", failCount);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,6 +447,27 @@ public class MainController implements InterceptorSkipController {
|
|||||||
return userInfo;
|
return userInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 최대 로그인 실패 허용횟수 조회 (rms.password.fail.count, 0이면 계정 잠금 미적용)
|
||||||
|
private int getMaxLoginFailCount() {
|
||||||
|
return monitoringContext.getIntProperty(
|
||||||
|
MonitoringContext.RMS_PASSWORD_FAIL_COUNT, DEFAULT_MAX_LOGIN_FAIL_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 실패 횟수 증가, 임계치 도달 시 계정 잠금
|
||||||
|
private int increaseLoginFailCount(UserInfo userInfo) {
|
||||||
|
int failCount = (userInfo.getLoginfailcount() == null ? 0 : userInfo.getLoginfailcount()) + 1;
|
||||||
|
userInfo.setLoginfailcount(failCount);
|
||||||
|
|
||||||
|
int maxLoginFailCount = getMaxLoginFailCount();
|
||||||
|
if (maxLoginFailCount > 0 && failCount >= maxLoginFailCount) {
|
||||||
|
userInfo.setStatus(USER_STATUS_LOCKED);
|
||||||
|
logger.warn("로그인 " + maxLoginFailCount + "회 이상 실패로 계정 잠금 처리: " + userInfo.getUserid());
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfoService.save(userInfo);
|
||||||
|
return failCount;
|
||||||
|
}
|
||||||
|
|
||||||
// LDAP
|
// LDAP
|
||||||
public boolean authenticate(String userId, String password) {
|
public boolean authenticate(String userId, String password) {
|
||||||
String LDAP_URL = monitoringPropertyService.getPrpty2ValById("Monitoring", "ldap.domain");
|
String LDAP_URL = monitoringPropertyService.getPrpty2ValById("Monitoring", "ldap.domain");
|
||||||
@@ -496,6 +532,7 @@ public class MainController implements InterceptorSkipController {
|
|||||||
String lastLoginIp = IpUtil.getClientIp(request);
|
String lastLoginIp = IpUtil.getClientIp(request);
|
||||||
userInfo.setLastloginyms(LocalDateTime.now());
|
userInfo.setLastloginyms(LocalDateTime.now());
|
||||||
userInfo.setLastloginip(lastLoginIp);
|
userInfo.setLastloginip(lastLoginIp);
|
||||||
|
userInfo.setLoginfailcount(0);
|
||||||
userInfoService.save(userInfo);
|
userInfoService.save(userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,12 +593,32 @@ public class MainController implements InterceptorSkipController {
|
|||||||
return "redirect:/common/errors/errorLogon.jsp";
|
return "redirect:/common/errors/errorLogon.jsp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML 개행 태그(<BR/> 등)를 JS alert용 개행문자로 변환
|
||||||
|
private String toAlertMessage(String message) {
|
||||||
|
return message.replaceAll("(?i)<br\\s*/?>", "\n");
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 실패 시 로그 기록, 세션에 실패 메시지 설정
|
// 로그인 실패 시 로그 기록, 세션에 실패 메시지 설정
|
||||||
private void setLoginFailure(HttpServletRequest request,
|
private void setLoginFailure(HttpServletRequest request,
|
||||||
HttpSession session, String logKey, String msgKey) {
|
HttpSession session, String logKey, String msgKey,
|
||||||
|
String... msgArgs) {
|
||||||
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F",
|
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F",
|
||||||
localeMessage.getString(logKey));
|
localeMessage.getString(logKey));
|
||||||
session.setAttribute(RESULT_MSG, localeMessage.getString(msgKey));
|
session.setAttribute(RESULT_MSG, localeMessage.getString(msgKey, msgArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 실패 시 로그 기록, 세션에 실패 메시지(실패 횟수 포함) 설정
|
||||||
|
private void setLoginFailure(HttpServletRequest request,
|
||||||
|
HttpSession session, String logKey, String msgKey,
|
||||||
|
int failCount) {
|
||||||
|
UserAccessLogger.log(request, LOG_CATEGORY_LOGIN, "F",
|
||||||
|
localeMessage.getString(logKey));
|
||||||
|
String msg = localeMessage.getString(msgKey);
|
||||||
|
int maxLoginFailCount = getMaxLoginFailCount();
|
||||||
|
if (maxLoginFailCount > 0) {
|
||||||
|
msg += " (" + failCount + "/" + maxLoginFailCount + ")";
|
||||||
|
}
|
||||||
|
session.setAttribute(RESULT_MSG, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 타입 검증
|
// 서비스 타입 검증
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class RoleMenuAuthService extends AbstractEMSDataSerivce<RoleMenuAuth, Ro
|
|||||||
.fetch();
|
.fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> findMenuIdsByServiceType(String serviceType) {
|
public List<String> findMenuIdsByRoleIdsAndServiceType(List<String> roleIds, String serviceType) {
|
||||||
|
|
||||||
QRoleMenuAuth qRoleMenuAuth = QRoleMenuAuth.roleMenuAuth;
|
QRoleMenuAuth qRoleMenuAuth = QRoleMenuAuth.roleMenuAuth;
|
||||||
|
|
||||||
@@ -59,7 +59,9 @@ public class RoleMenuAuthService extends AbstractEMSDataSerivce<RoleMenuAuth, Ro
|
|||||||
.select(qRoleMenuAuth.id.menuId)
|
.select(qRoleMenuAuth.id.menuId)
|
||||||
.distinct()
|
.distinct()
|
||||||
.from(qRoleMenuAuth)
|
.from(qRoleMenuAuth)
|
||||||
.where(qRoleMenuAuth.id.serviceType.eq(serviceType))
|
.where(qRoleMenuAuth.id.roleId.in(roleIds),
|
||||||
|
qRoleMenuAuth.auth.in("R", "W"),
|
||||||
|
qRoleMenuAuth.id.serviceType.eq(serviceType))
|
||||||
.setHint(QueryHints.CACHEABLE, true)
|
.setHint(QueryHints.CACHEABLE, true)
|
||||||
.fetch();
|
.fetch();
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -65,6 +65,17 @@ public class MessageRequestService
|
|||||||
return repository.count(predicate);
|
return repository.count(predicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 특정 일자에 EMAIL 로 발송 요청된 건수 (UMS 일련번호 채번용). */
|
||||||
|
public long countEmailRequestsOnDate(LocalDate date) {
|
||||||
|
QMessageRequest q = QMessageRequest.messageRequest;
|
||||||
|
BooleanBuilder predicate = new BooleanBuilder();
|
||||||
|
predicate.and(q.messageType.in("EMAIL"));
|
||||||
|
predicate.and(q.requestDate.goe(date.atStartOfDay()));
|
||||||
|
predicate.and(q.requestDate.loe(date.atTime(23, 59, 59)));
|
||||||
|
return repository.count(predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** PENDING 상태인 건만 FAILED 로 전환한다. */
|
/** PENDING 상태인 건만 FAILED 로 전환한다. */
|
||||||
public void updateStatusToFailedIfPending(String id) {
|
public void updateStatusToFailedIfPending(String id) {
|
||||||
MessageRequest entity = getById(id);
|
MessageRequest entity = getById(id);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.eactive.eai.rms.data.entity.onl.djb.apistatus;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 판정 구간 내 오류가 1건 이상 집계된 API 의 오류율 (개발 모드 진단 로그용)
|
||||||
|
*
|
||||||
|
* <p>{@code ApiStatusRepository.findApiErrorRates} 결과. 장애 판정 임계값과 무관하게
|
||||||
|
* 오류율 0% 초과인 API 를 모두 담는다.</p>
|
||||||
|
*/
|
||||||
|
public interface ApiErrorRateRow {
|
||||||
|
|
||||||
|
/** API(EAI 서비스)명 */
|
||||||
|
String getEaisvcname();
|
||||||
|
|
||||||
|
/** 현재 상태 코드 - 정상(N), 점검(C), 지연(D), 장애(E) */
|
||||||
|
String getStatusCode();
|
||||||
|
|
||||||
|
/** 판정 구간 내 총 건수 (성공 + 타임아웃 + 시스템오류 + 업무오류) */
|
||||||
|
BigDecimal getTotal();
|
||||||
|
|
||||||
|
/** 판정 구간 내 성공 건수 */
|
||||||
|
BigDecimal getSuccess();
|
||||||
|
|
||||||
|
/** 오류율(%) - (총건수 - 성공건수) / 총건수 * 100 */
|
||||||
|
BigDecimal getErrorRate();
|
||||||
|
|
||||||
|
/** 오류 판정 구간 내 건수 가중 평균 응답시간(ms) */
|
||||||
|
BigDecimal getAvgRespTime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지연 판정 구간 내 총 건수.
|
||||||
|
*
|
||||||
|
* <p>지연은 오류와 판정 구간이 다르다({@code api.status.delay.range_minute}).
|
||||||
|
* 지연 임계 비교는 이 구간의 평균 응답시간으로 해야 실제 판정과 일치한다.</p>
|
||||||
|
*/
|
||||||
|
BigDecimal getDelayTotal();
|
||||||
|
|
||||||
|
/** 지연 판정 구간 내 건수 가중 평균 응답시간(ms) */
|
||||||
|
BigDecimal getDelayAvgRespTime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.eactive.eai.rms.data.entity.onl.djb.apistatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EAI 서비스명 조회 결과 (TSEAIHE01)
|
||||||
|
*/
|
||||||
|
public interface ApiNameRow {
|
||||||
|
String getEaisvcname();
|
||||||
|
String getEaisvcdesc();
|
||||||
|
}
|
||||||
+655
-7
@@ -1,18 +1,122 @@
|
|||||||
package com.eactive.eai.rms.ext.djb.apistatus;
|
package com.eactive.eai.rms.ext.djb.apistatus;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.entity.Auditable;
|
||||||
|
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.DjbApistatusIncidentTimeline;
|
||||||
|
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.portalNotice.entity.PortalNotice;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
|
||||||
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다.
|
||||||
|
*
|
||||||
|
* <p>탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를
|
||||||
|
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 로 기록한다.</p>
|
||||||
|
*
|
||||||
|
* <p>자동 등록 여부와 공지 게시 시점은 PTL_PROPERTY
|
||||||
|
* ({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 로 정한다.
|
||||||
|
* {@code OFF}면 자동 등록을 하지 않고, {@code DRAFT}(기본)면 관리자가 공지사항 관리에서
|
||||||
|
* 검수·게시해야 개발자포탈에 노출되며, {@code PUBLISH}면 탐지 즉시 노출된다.
|
||||||
|
* {@code OFF} 여도 이미 열려있는 장애의 복구 처리(ERROR_END/DELAY_END)는 계속 동작한다.</p>
|
||||||
|
*
|
||||||
|
* <p>점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다
|
||||||
|
* ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정).
|
||||||
|
* 따라서 CONTROL 이벤트를 다시 기록하면 순환이 되므로 기록하지 않는다.</p>
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class)
|
||||||
public class ApiStatusDetectionService {
|
public class ApiStatusDetectionService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
|
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
|
||||||
|
|
||||||
|
public static final String EVENT_CONTROL_START = "CONTROL_START";
|
||||||
|
public static final String EVENT_CONTROL_END = "CONTROL_END";
|
||||||
|
public static final String EVENT_ERROR_START = "ERROR_START";
|
||||||
|
public static final String EVENT_ERROR_END = "ERROR_END";
|
||||||
|
public static final String EVENT_DELAY_START = "DELAY_START";
|
||||||
|
public static final String EVENT_DELAY_END = "DELAY_END";
|
||||||
|
|
||||||
|
/** INTERFACE_ID 멱등 윈도우 (분) */
|
||||||
|
private static final int IDEMPOTENT_WINDOW_MINUTES = 5;
|
||||||
|
|
||||||
|
/** 종결 상태 - 신규 탐지 병합 대상에서 제외 */
|
||||||
|
private static final List<IncidentState> CLOSED_STATES =
|
||||||
|
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
|
||||||
|
|
||||||
|
private static final String AUTHOR_SYSTEM = "SYSTEM";
|
||||||
|
private static final String DETECTED_BY_AUTO = "AUTO";
|
||||||
|
private static final String NOTICE_TYPE_INCIDENT = "3";
|
||||||
|
private static final int NOTICE_SUBJECT_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
/** 자동 탐지 공지 처리 방식 프로퍼티 그룹 (개발자포탈과 동일 그룹을 쓴다) */
|
||||||
|
public static final String PROPERTY_GROUP = "Portal";
|
||||||
|
|
||||||
|
/** 자동 탐지 장애/지연의 공지 처리 방식 - OFF / DRAFT / PUBLISH */
|
||||||
|
public static final String KEY_AUTO_INCIDENT_MODE = "djb.apistatus.auto-incident-mode";
|
||||||
|
|
||||||
|
/** 자동 등록 안 함. 신규 등록·병합 모두 하지 않는다 (복구 처리는 계속 동작) */
|
||||||
|
public static final String MODE_OFF = "OFF";
|
||||||
|
|
||||||
|
/** 초안만 생성. 관리자가 공지사항 관리에서 검수·게시해야 개발자포탈에 노출된다 */
|
||||||
|
public static final String MODE_DRAFT = "DRAFT";
|
||||||
|
|
||||||
|
/** 초안 생성 후 곧바로 게시. 검수 없이 개발자포탈에 즉시 노출된다 */
|
||||||
|
public static final String MODE_PUBLISH = "PUBLISH";
|
||||||
|
|
||||||
|
/** 오탐이 그대로 고객에게 노출되지 않도록 기본은 검수(DRAFT) */
|
||||||
|
private static final String DEFAULT_AUTO_INCIDENT_MODE = MODE_DRAFT;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DjbApistatusIncidentRepository incidentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PortalNoticeService portalNoticeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiStatusRepository apiStatusRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */
|
||||||
|
@Autowired
|
||||||
|
private ApiStatusDraftTemplate draftTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 감지
|
* 이벤트 감지
|
||||||
* @param event "CONTROL_START" : 점검시작
|
* @param event "CONTROL_START" : 점검시작
|
||||||
@@ -25,17 +129,561 @@ public class ApiStatusDetectionService {
|
|||||||
*/
|
*/
|
||||||
public void detect(String event, List<String> apiIds) {
|
public void detect(String event, List<String> apiIds) {
|
||||||
log.debug("이벤트 감지: {} {}", event, apiIds);
|
log.debug("이벤트 감지: {} {}", event, apiIds);
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(event) || apiIds == null || apiIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> targets = apiIds.stream()
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case EVENT_ERROR_START:
|
||||||
|
openIncident(event, IncidentKind.INCIDENT, targets, "에러율 임계 초과", "장애");
|
||||||
|
break;
|
||||||
|
case EVENT_DELAY_START:
|
||||||
|
openIncident(event, IncidentKind.DELAY, targets, "응답시간 임계 초과", "응답지연");
|
||||||
|
break;
|
||||||
|
case EVENT_ERROR_END:
|
||||||
|
case EVENT_DELAY_END:
|
||||||
|
markRecovered(event, targets);
|
||||||
|
break;
|
||||||
|
case EVENT_CONTROL_START:
|
||||||
|
case EVENT_CONTROL_END:
|
||||||
|
// 점검은 관리자 등록 공지가 원천 - 재기록하지 않음
|
||||||
|
log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.warn("알 수 없는 이벤트: {}", event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API FSM 내 아직 장애로 남아있는 API 목록
|
/**
|
||||||
public void remainDownApiIds() {
|
* 미종결 장애·지연으로 남아있는 API 목록 (탐지 Job 폴링용)
|
||||||
|
*/
|
||||||
|
@Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
|
||||||
|
public List<String> remainDownApiIds() {
|
||||||
|
List<DjbApistatusIncident> openIncidents = incidentRepository
|
||||||
|
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES);
|
||||||
|
if (openIncidents.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Long> incidentIds = openIncidents.stream()
|
||||||
|
.map(DjbApistatusIncident::getIncidentId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds).stream()
|
||||||
|
.filter(api -> api.getRecoveredAt() == null)
|
||||||
|
.map(DjbApistatusIncidentApi::getApiId)
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────── 장애 발생 ──────────────
|
||||||
|
|
||||||
// 정상 동작 - detect()에서 일괄 처리 (복구 = 점검종료, 장애종료, 지연종료)
|
/**
|
||||||
// public void recovered(String event, String[] apiIds) {
|
* 신규 이슈 등록. 지연(DELAY)은 공지를 만들지 않고, 장애(INCIDENT)만 공지 초안/게시를 동반한다.
|
||||||
//
|
*/
|
||||||
// }
|
private void openIncident(String event, IncidentKind kind, List<String> apiIds,
|
||||||
|
String summary, String titleKeyword) {
|
||||||
|
// 0) OFF 면 신규 등록·병합·타임라인 기록을 모두 건너뛴다.
|
||||||
|
// 이미 열려있는 이슈의 복구(markRecovered)는 계속 동작해야 하므로 여기서만 막는다.
|
||||||
|
String mode = resolveAutoIncidentMode();
|
||||||
|
if (MODE_OFF.equals(mode)) {
|
||||||
|
log.info("자동 이슈 등록 비활성(OFF) - 기록하지 않음: {} {}", event, apiIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean autoPublish = MODE_PUBLISH.equals(mode);
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
String interfaceId = makeInterfaceId(event, apiIds, now);
|
||||||
|
|
||||||
|
// 1) 동일 윈도우 재탐지 - 타임라인만 남기고 종료
|
||||||
|
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
|
||||||
|
if (duplicated.isPresent()) {
|
||||||
|
appendTimeline(duplicated.get().getIncidentId(), null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 이미 열려있는 자동 등록 이슈가 해당 API 를 포함하면 영향 API 만 병합
|
||||||
|
// (관리자가 직접 작성한 장애 공지는 건드리지 않는다)
|
||||||
|
// 지연으로 열린 건에 장애가 얹히는 경우가 있어 지연·장애를 함께 찾는다.
|
||||||
|
List<DjbApistatusIncident> openIncidents = incidentRepository
|
||||||
|
.findOpenByApiIds(IncidentKind.DEGRADING, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
|
||||||
|
if (!openIncidents.isEmpty()) {
|
||||||
|
mergeIntoOpenIncident(openIncidents.get(0), event, kind, apiIds, now, autoPublish);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 신규 이슈 생성. 장애는 공지도 같이 만들고, PUBLISH 면 검수 없이 바로 게시한다.
|
||||||
|
Map<String, String> apiNames = resolveApiNames(apiIds);
|
||||||
|
String title = buildTitle(titleKeyword, apiIds, apiNames);
|
||||||
|
|
||||||
|
PortalNotice notice = kind == IncidentKind.DELAY
|
||||||
|
? null : createNotice(title, summary, apiIds, apiNames, now, autoPublish);
|
||||||
|
|
||||||
|
DjbApistatusIncident incident = new DjbApistatusIncident();
|
||||||
|
incident.setKind(kind);
|
||||||
|
incident.setState(IncidentState.INVESTIGATING);
|
||||||
|
incident.setTitle(title);
|
||||||
|
incident.setSummary(summary);
|
||||||
|
incident.setStartedAt(now);
|
||||||
|
incident.setDetectedBy(DETECTED_BY_AUTO);
|
||||||
|
incident.setInterfaceId(interfaceId);
|
||||||
|
incident.setNoticeId(notice == null ? null : notice.getId());
|
||||||
|
// 개발자포탈 노출 조건: 공지가 있으면 DRAFT_YN='N' + USE_YN='Y', 없으면(지연) DRAFT_YN='N' 만
|
||||||
|
incident.setDraftYn(autoPublish ? "N" : "Y");
|
||||||
|
incident.setFixYn("N");
|
||||||
|
stampAudit(incident, now);
|
||||||
|
|
||||||
|
DjbApistatusIncident saved;
|
||||||
|
try {
|
||||||
|
saved = incidentRepository.saveAndFlush(incident);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 만든 공지를 정리하고 타임라인만 남긴다
|
||||||
|
log.info("이슈 중복 감지 (interfaceId={}) - 기존 이슈에 타임라인 추가", interfaceId, e);
|
||||||
|
if (notice != null) {
|
||||||
|
portalNoticeService.deleteById(notice.getId());
|
||||||
|
}
|
||||||
|
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
|
||||||
|
appendTimeline(existing.getIncidentId(), null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
|
||||||
|
// 타임라인도 고객 화면에 그대로 나가므로 인터페이스 ID 대신 게시 API 명 + 건수로 적는다
|
||||||
|
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_DETECTED,
|
||||||
|
"summary", summary, "event", event, "affected", affectedSummary(apiIds, apiNames)), now);
|
||||||
|
|
||||||
|
log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}",
|
||||||
|
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
|
||||||
|
autoPublish ? MODE_PUBLISH : MODE_DRAFT, apiIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 탐지 장애/지연의 공지 처리 방식.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 를 읽는다.
|
||||||
|
* 최초 접근 시 기본값으로 row 가 자동 생성된다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code OFF} - 자동 등록 안 함 (복구 처리는 계속)</li>
|
||||||
|
* <li>{@code DRAFT}(기본) - 초안만 생성, 관리자 검수 후 게시</li>
|
||||||
|
* <li>{@code PUBLISH} - 초안 생성 후 즉시 게시</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>조회 실패나 알 수 없는 값은 검수 쪽(DRAFT)으로 떨어뜨린다 - 오탐이 고객 화면에
|
||||||
|
* 그대로 나가는 것도, 진짜 장애를 아예 안 남기는 것도 기본 동작으로는 위험하기 때문이다.</p>
|
||||||
|
*/
|
||||||
|
private String resolveAutoIncidentMode() {
|
||||||
|
try {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROPERTY_GROUP, KEY_AUTO_INCIDENT_MODE, DEFAULT_AUTO_INCIDENT_MODE,
|
||||||
|
"API 상태 자동 탐지 공지 처리 방식 - OFF: 자동 등록 안 함, "
|
||||||
|
+ "DRAFT: 초안만 생성(관리자 검수 후 게시), PUBLISH: 초안 생성 후 즉시 게시");
|
||||||
|
String normalized = StringUtils.trimToEmpty(value);
|
||||||
|
if (MODE_OFF.equalsIgnoreCase(normalized)) {
|
||||||
|
return MODE_OFF;
|
||||||
|
}
|
||||||
|
if (MODE_PUBLISH.equalsIgnoreCase(normalized)) {
|
||||||
|
return MODE_PUBLISH;
|
||||||
|
}
|
||||||
|
if (!MODE_DRAFT.equalsIgnoreCase(normalized)) {
|
||||||
|
log.warn("알 수 없는 자동 탐지 공지 처리 방식 '{}' - 기본값 {} 적용", value, DEFAULT_AUTO_INCIDENT_MODE);
|
||||||
|
}
|
||||||
|
return MODE_DRAFT;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("자동 탐지 공지 처리 방식 조회 실패 - 기본값 {} 적용", DEFAULT_AUTO_INCIDENT_MODE, e);
|
||||||
|
return DEFAULT_AUTO_INCIDENT_MODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, IncidentKind kind,
|
||||||
|
List<String> apiIds, LocalDateTime now, boolean autoPublish) {
|
||||||
|
Long incidentId = incident.getIncidentId();
|
||||||
|
|
||||||
|
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
|
||||||
|
boolean escalated = escalateIfNeeded(incident, event, kind, now, autoPublish);
|
||||||
|
|
||||||
|
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
|
||||||
|
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
|
||||||
|
if (autoPublish) {
|
||||||
|
publishIfPending(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream()
|
||||||
|
.map(DjbApistatusIncidentApi::getApiId)
|
||||||
|
.collect(Collectors.toCollection(HashSet::new));
|
||||||
|
|
||||||
|
List<String> added = apiIds.stream()
|
||||||
|
.filter(apiId -> !mapped.contains(apiId))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (added.isEmpty()) {
|
||||||
|
// 격상을 남긴 턴에는 같은 시각·같은 이벤트로 "재탐지 신호 수신"이 겹쳐 찍힌다.
|
||||||
|
// 격상 줄이 더 구체적인 정보이므로 재탐지 줄은 생략한다.
|
||||||
|
if (!escalated) {
|
||||||
|
appendTimeline(incidentId, null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
|
||||||
|
}
|
||||||
|
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}, escalated={}",
|
||||||
|
incidentId, apiIds, escalated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> addedNames = resolveApiNames(added);
|
||||||
|
saveIncidentApis(incidentId, added, addedNames, now);
|
||||||
|
appendTimeline(incidentId, null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_API_ADDED,
|
||||||
|
"event", event, "affected", affectedSummary(added, addedNames)), now);
|
||||||
|
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지연(DELAY)으로 열린 이슈에 장애(ERROR_START)가 감지되면 종류를 장애로 올린다.
|
||||||
|
*
|
||||||
|
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
|
||||||
|
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
|
||||||
|
*
|
||||||
|
* @return 실제로 격상하여 타임라인을 남겼으면 true
|
||||||
|
*/
|
||||||
|
private boolean escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
|
||||||
|
LocalDateTime now, boolean autoPublish) {
|
||||||
|
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||||
|
.map(DjbApistatusIncidentApi::getApiId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
Map<String, String> apiNames = resolveApiNames(apiIds);
|
||||||
|
String title = buildTitle("장애", apiIds, apiNames);
|
||||||
|
String summary = "에러율 임계 초과";
|
||||||
|
|
||||||
|
incident.setKind(IncidentKind.INCIDENT);
|
||||||
|
incident.setTitle(title);
|
||||||
|
incident.setSummary(summary);
|
||||||
|
if (incident.getNoticeId() == null) {
|
||||||
|
// 이미 노출 중이던(DRAFT_YN='N') 지연이 격상되는데 공지가 미게시로 붙으면
|
||||||
|
// 보이던 이슈가 사라진다. 그 경우엔 공지도 게시 상태로 만든다.
|
||||||
|
boolean publishNotice = autoPublish || "N".equals(incident.getDraftYn());
|
||||||
|
PortalNotice notice = createNotice(title, summary, apiIds, apiNames, now, publishNotice);
|
||||||
|
incident.setNoticeId(notice.getId());
|
||||||
|
}
|
||||||
|
incident.setLastModifiedBy(AUTHOR_SYSTEM);
|
||||||
|
incident.setLastModifiedDate(now);
|
||||||
|
incidentRepository.save(incident);
|
||||||
|
|
||||||
|
appendTimeline(incident.getIncidentId(), null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_ESCALATED, "event", event), now);
|
||||||
|
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
|
||||||
|
incident.getIncidentId(), incident.getNoticeId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아직 초안으로 남아있는 자동 탐지 이슈를 게시 상태로 올린다 (PUBLISH 모드에서만 호출).
|
||||||
|
*
|
||||||
|
* <p>대상은 자동 등록(DETECTED_BY='AUTO') 건뿐이다. 관리자가 공지사항 관리에서 손댄 건은
|
||||||
|
* DETECTED_BY 가 'MANUAL' 로 바뀌므로 여기 오지 않는다.</p>
|
||||||
|
*
|
||||||
|
* <p>지연은 연결 공지가 없으므로 DRAFT_YN 만 바꾸면 노출된다.</p>
|
||||||
|
*/
|
||||||
|
private void publishIfPending(DjbApistatusIncident incident) {
|
||||||
|
if (!"Y".equals(incident.getDraftYn())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
incident.setDraftYn("N");
|
||||||
|
incidentRepository.save(incident);
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(incident.getNoticeId())) {
|
||||||
|
portalNoticeService.findById(incident.getNoticeId())
|
||||||
|
.filter(notice -> !"Y".equals(notice.getUseYn()))
|
||||||
|
.ifPresent(notice -> {
|
||||||
|
notice.setUseYn("Y");
|
||||||
|
portalNoticeService.save(notice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
log.info("자동 탐지 이슈 뒤늦은 게시: incidentId={}, noticeId={}",
|
||||||
|
incident.getIncidentId(), incident.getNoticeId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── 복구 ──────────────
|
||||||
|
|
||||||
|
private void markRecovered(String event, List<String> apiIds) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
List<DjbApistatusIncidentApi> candidates =
|
||||||
|
incidentApiRepository.findByApiIdInAndRecoveredAtIsNull(apiIds);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
log.debug("복구 대상 장애 없음: {} {}", event, apiIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복구는 이벤트 종류를 구분하지 않는다. ERROR_END 하나로 그 API 의 지연 건까지 닫는다
|
||||||
|
// (상태머신상 E → N 으로 돌아가므로 DELAY_END 가 따로 오지 않는다).
|
||||||
|
Map<Long, DjbApistatusIncident> openIncidents = incidentRepository
|
||||||
|
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES).stream()
|
||||||
|
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||||
|
|
||||||
|
Map<Long, List<String>> recoveredByIncident = new HashMap<>();
|
||||||
|
for (DjbApistatusIncidentApi api : candidates) {
|
||||||
|
if (!openIncidents.containsKey(api.getIncidentId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
api.setRecoveredAt(now);
|
||||||
|
api.setLastModifiedBy(AUTHOR_SYSTEM);
|
||||||
|
api.setLastModifiedDate(now);
|
||||||
|
incidentApiRepository.save(api);
|
||||||
|
|
||||||
|
recoveredByIncident.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
|
||||||
|
.add(api.getApiId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recoveredByIncident.isEmpty()) {
|
||||||
|
log.debug("복구 대상 장애 없음 (미종결 장애 미포함): {} {}", event, apiIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<Long, List<String>> entry : recoveredByIncident.entrySet()) {
|
||||||
|
Long incidentId = entry.getKey();
|
||||||
|
List<String> recoveredApis = entry.getValue();
|
||||||
|
DjbApistatusIncident incident = openIncidents.get(incidentId);
|
||||||
|
|
||||||
|
boolean allRecovered = incidentApiRepository.countByIncidentIdAndRecoveredAtIsNull(incidentId) == 0;
|
||||||
|
IncidentState nextState = allRecovered ? IncidentState.RESOLVED : IncidentState.MONITORING;
|
||||||
|
|
||||||
|
if (incident.getState() != nextState) {
|
||||||
|
incident.setPreviousState(incident.getState());
|
||||||
|
incident.setState(nextState);
|
||||||
|
}
|
||||||
|
if (allRecovered) {
|
||||||
|
incident.setEndAt(now);
|
||||||
|
}
|
||||||
|
incident.setLastModifiedBy(AUTHOR_SYSTEM);
|
||||||
|
incident.setLastModifiedDate(now);
|
||||||
|
incidentRepository.save(incident);
|
||||||
|
|
||||||
|
String body = draftTemplate.text(
|
||||||
|
allRecovered ? ApiStatusDraftTemplate.TL_RECOVERED_ALL
|
||||||
|
: ApiStatusDraftTemplate.TL_RECOVERED_PART,
|
||||||
|
"event", event,
|
||||||
|
"affected", affectedSummary(recoveredApis, resolveApiNames(recoveredApis)));
|
||||||
|
appendTimeline(incidentId, nextState, body, now);
|
||||||
|
|
||||||
|
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── 내부 헬퍼 ──────────────
|
||||||
|
|
||||||
|
private String makeInterfaceId(String event, List<String> sortedApiIds, LocalDateTime now) {
|
||||||
|
long windowSeconds = IDEMPOTENT_WINDOW_MINUTES * 60L;
|
||||||
|
long flooredEpoch = now.atZone(ZoneId.systemDefault()).toEpochSecond()
|
||||||
|
/ windowSeconds * windowSeconds;
|
||||||
|
String joined = String.join(",", sortedApiIds);
|
||||||
|
String interfaceId = event + ":" + joined + ":" + flooredEpoch;
|
||||||
|
|
||||||
|
// INTERFACE_ID 는 VARCHAR2(200) - 초과 시 API 목록을 해시로 축약
|
||||||
|
if (interfaceId.length() > 200) {
|
||||||
|
interfaceId = event + ":#" + Integer.toHexString(joined.hashCode())
|
||||||
|
+ ":" + sortedApiIds.size() + ":" + flooredEpoch;
|
||||||
|
}
|
||||||
|
return interfaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> resolveApiNames(List<String> apiIds) {
|
||||||
|
Map<String, String> names = new HashMap<>();
|
||||||
|
try {
|
||||||
|
for (ApiNameRow row : apiStatusRepository.findApiNames(apiIds)) {
|
||||||
|
names.put(row.getEaisvcname(), row.getEaisvcdesc());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("API 명 조회 실패 - API ID 를 그대로 사용", e);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apiLabel(String apiId, Map<String, String> apiNames) {
|
||||||
|
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영향 인터페이스를 개발자포탈 게시 여부로 가른 결과.
|
||||||
|
* 게시된 것(= API)만 이름을 드러내고 나머지는 건수로만 표기한다.
|
||||||
|
*/
|
||||||
|
private static final class ApiSplit {
|
||||||
|
private final List<String> published;
|
||||||
|
private final int hiddenCount;
|
||||||
|
|
||||||
|
private ApiSplit(List<String> published, int hiddenCount) {
|
||||||
|
this.published = published;
|
||||||
|
this.hiddenCount = hiddenCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 인터페이스 판정. 조회에 실패하면 전부 비게시로 보아 ID·명칭 노출을 막는다. */
|
||||||
|
private ApiSplit splitPublished(List<String> apiIds) {
|
||||||
|
if (apiIds == null || apiIds.isEmpty()) {
|
||||||
|
return new ApiSplit(Collections.emptyList(), 0);
|
||||||
|
}
|
||||||
|
Set<String> publishedIds;
|
||||||
|
try {
|
||||||
|
publishedIds = new HashSet<>(apiStatusRepository.findPublishedApiIds(apiIds));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 실패 시 노출하는 쪽으로 기울면 내부 인터페이스가 고객 화면에 새어 나간다
|
||||||
|
log.warn("게시 API 판정 실패 - 전부 GW 인터페이스로 묶어 표기", e);
|
||||||
|
publishedIds = Collections.emptySet();
|
||||||
|
}
|
||||||
|
List<String> published = new ArrayList<>();
|
||||||
|
for (String apiId : apiIds) {
|
||||||
|
if (publishedIds.contains(apiId)) {
|
||||||
|
published.add(apiId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ApiSplit(published, apiIds.size() - published.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */
|
||||||
|
private String hiddenLabel(int hiddenCount) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영향 인터페이스 한 줄 요약. 게시 API 는 이름으로, 나머지는 건수로 묶는다.
|
||||||
|
* 예) {@code 계좌조회, 이체 · GW 인터페이스 2건}
|
||||||
|
*/
|
||||||
|
private String affectedSummary(List<String> apiIds, Map<String, String> apiNames) {
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (String apiId : split.published) {
|
||||||
|
parts.add(apiLabel(apiId, apiNames));
|
||||||
|
}
|
||||||
|
String names = String.join(", ", parts);
|
||||||
|
if (split.hiddenCount == 0) {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
return names.isEmpty() ? hiddenLabel(split.hiddenCount) : names + " · " + hiddenLabel(split.hiddenCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 제목. 게시 API 가 하나도 없으면 인터페이스 명을 쓰지 않고 건수로만 적는다.
|
||||||
|
*/
|
||||||
|
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
if (split.published.isEmpty()) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_HIDDEN_ONLY,
|
||||||
|
"gw", hiddenLabel(apiIds.size()), "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
String first = apiLabel(split.published.get(0), apiNames);
|
||||||
|
if (apiIds.size() > 1) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MULTI,
|
||||||
|
"first", first, "rest", String.valueOf(apiIds.size() - 1), "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_SINGLE, "first", first, "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 탐지 장애 공지 생성.
|
||||||
|
*
|
||||||
|
* <p>본문에는 관리자가 검수하며 채울 항목(발생 원인·영향 범위·조치 내용·예상 복구 시간)을
|
||||||
|
* 미리 넣어 둔다. 초안이면 작성 힌트를 함께 남기고, 이미 게시되는 경우엔 고객에게
|
||||||
|
* 그대로 노출되므로 "확인 중입니다" 로 채워 둔다.</p>
|
||||||
|
*
|
||||||
|
* @param publish true 면 게시(USE_YN='Y'), false 면 초안(USE_YN='N')
|
||||||
|
*/
|
||||||
|
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
|
||||||
|
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
|
||||||
|
StringBuilder detail = new StringBuilder();
|
||||||
|
detail.append(draftTemplate.text(publish
|
||||||
|
? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH
|
||||||
|
: ApiStatusDraftTemplate.BODY_LEAD_DRAFT,
|
||||||
|
"summary", summary));
|
||||||
|
|
||||||
|
// 게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다.
|
||||||
|
// 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다.
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
detail.append("<p><b>")
|
||||||
|
.append(draftTemplate.text(ApiStatusDraftTemplate.BODY_AFFECTED_HEADING))
|
||||||
|
.append("</b></p><ul>");
|
||||||
|
for (String apiId : split.published) {
|
||||||
|
detail.append("<li>").append(apiLabel(apiId, apiNames)).append("</li>");
|
||||||
|
}
|
||||||
|
if (split.hiddenCount > 0) {
|
||||||
|
detail.append("<li>").append(hiddenLabel(split.hiddenCount)).append("</li>");
|
||||||
|
}
|
||||||
|
detail.append("</ul>");
|
||||||
|
|
||||||
|
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
|
||||||
|
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
|
||||||
|
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||||
|
detail.append("<p>").append(publish
|
||||||
|
? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)
|
||||||
|
: draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()))
|
||||||
|
.append("</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalNotice notice = new PortalNotice();
|
||||||
|
notice.setId(null);
|
||||||
|
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
|
||||||
|
notice.setNoticeDetail(detail.toString());
|
||||||
|
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
|
||||||
|
notice.setUseYn(publish ? "Y" : "N");
|
||||||
|
notice.setFixYn("N");
|
||||||
|
notice.setReadCount(0L);
|
||||||
|
notice.setInquirerName(AUTHOR_SYSTEM);
|
||||||
|
notice.setCreatedBy(AUTHOR_SYSTEM);
|
||||||
|
notice.setCreatedDate(now);
|
||||||
|
|
||||||
|
portalNoticeService.save(notice);
|
||||||
|
return notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveIncidentApis(Long incidentId, Collection<String> apiIds,
|
||||||
|
Map<String, String> apiNames, LocalDateTime now) {
|
||||||
|
List<DjbApistatusIncidentApi> rows = new ArrayList<>();
|
||||||
|
for (String apiId : apiIds) {
|
||||||
|
DjbApistatusIncidentApi api = new DjbApistatusIncidentApi();
|
||||||
|
api.setIncidentId(incidentId);
|
||||||
|
api.setApiId(apiId);
|
||||||
|
api.setApiName(apiNames.get(apiId));
|
||||||
|
stampAudit(api, now);
|
||||||
|
rows.add(api);
|
||||||
|
}
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
incidentApiRepository.saveAll(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendTimeline(Long incidentId, IncidentState stateAfter, String body, LocalDateTime now) {
|
||||||
|
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
|
||||||
|
timeline.setIncidentId(incidentId);
|
||||||
|
timeline.setEventAt(now);
|
||||||
|
timeline.setStateAfter(stateAfter);
|
||||||
|
timeline.setBody(StringUtils.abbreviate(body, 4000));
|
||||||
|
timeline.setAuthorType(AUTHOR_SYSTEM);
|
||||||
|
timeline.setVisibleYn("Y");
|
||||||
|
stampAudit(timeline, now);
|
||||||
|
incidentTimelineRepository.save(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄러 스레드는 로그인 세션이 없어 AuditorAware 가 비어있다. CREATED_BY 는 NOT NULL 이므로 직접 채운다.
|
||||||
|
*/
|
||||||
|
private void stampAudit(Auditable entity, LocalDateTime now) {
|
||||||
|
entity.setCreatedBy(AUTHOR_SYSTEM);
|
||||||
|
entity.setCreatedDate(now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.eactive.eai.rms.ext.djb.apistatus;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.yaml.snakeyaml.LoaderOptions;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Status 자동 탐지 초안 양식({@code classpath:apistatus-draft.yml}).
|
||||||
|
*
|
||||||
|
* <p>자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다.
|
||||||
|
* admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다
|
||||||
|
* ({@code <context:property-placeholder>} 는 properties 전용이고, 검수 섹션이 리스트라
|
||||||
|
* properties 로는 인덱스 키로 흩어진다).</p>
|
||||||
|
*
|
||||||
|
* <p>파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로
|
||||||
|
* 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ApiStatusDraftTemplate {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiStatusDraftTemplate.class);
|
||||||
|
|
||||||
|
private static final String RESOURCE = "apistatus-draft.yml";
|
||||||
|
|
||||||
|
// ---- 키 ----
|
||||||
|
public static final String GW_LABEL = "gw-label";
|
||||||
|
|
||||||
|
public static final String TITLE_SINGLE = "title.single";
|
||||||
|
public static final String TITLE_MULTI = "title.multi";
|
||||||
|
public static final String TITLE_HIDDEN_ONLY = "title.hidden-only";
|
||||||
|
|
||||||
|
public static final String BODY_LEAD_PUBLISH = "body.lead-publish";
|
||||||
|
public static final String BODY_LEAD_DRAFT = "body.lead-draft";
|
||||||
|
public static final String BODY_AFFECTED_HEADING = "body.affected-heading";
|
||||||
|
public static final String BODY_FILLED_TEXT = "body.filled-text";
|
||||||
|
public static final String BODY_DRAFT_TEXT = "body.draft-text";
|
||||||
|
|
||||||
|
public static final String TL_DETECTED = "timeline.detected";
|
||||||
|
public static final String TL_API_ADDED = "timeline.api-added";
|
||||||
|
public static final String TL_REDETECTED = "timeline.redetected";
|
||||||
|
public static final String TL_ESCALATED = "timeline.escalated";
|
||||||
|
public static final String TL_RECOVERED_ALL = "timeline.recovered-all";
|
||||||
|
public static final String TL_RECOVERED_PART = "timeline.recovered-part";
|
||||||
|
|
||||||
|
/** yml 을 못 읽었을 때 쓰는 기본 문구 (기존 하드코딩과 동일) */
|
||||||
|
private static final Map<String, String> DEFAULTS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, String> defaults = new LinkedHashMap<>();
|
||||||
|
defaults.put(GW_LABEL, "GW 인터페이스 {count}건");
|
||||||
|
defaults.put(TITLE_SINGLE, "[자동감지] {first} API {keyword}");
|
||||||
|
defaults.put(TITLE_MULTI, "[자동감지] {first} 외 {rest}종 API {keyword}");
|
||||||
|
defaults.put(TITLE_HIDDEN_ONLY, "[자동감지] {gw} {keyword}");
|
||||||
|
defaults.put(BODY_LEAD_PUBLISH, "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>");
|
||||||
|
defaults.put(BODY_LEAD_DRAFT, "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
|
||||||
|
defaults.put(BODY_AFFECTED_HEADING, "영향 API");
|
||||||
|
defaults.put(BODY_FILLED_TEXT, "확인 중입니다.");
|
||||||
|
defaults.put(BODY_DRAFT_TEXT, "[작성 필요] {hint}");
|
||||||
|
defaults.put(TL_DETECTED, "{summary} 자동 감지 ({event})\n영향 API: {affected}");
|
||||||
|
defaults.put(TL_API_ADDED, "영향 API 추가 감지 ({event})\n{affected}");
|
||||||
|
defaults.put(TL_REDETECTED, "재탐지 신호 수신 ({event})");
|
||||||
|
defaults.put(TL_ESCALATED, "지연에서 장애로 격상 ({event})");
|
||||||
|
defaults.put(TL_RECOVERED_ALL, "전체 복구 확인 ({event})\n{affected}");
|
||||||
|
defaults.put(TL_RECOVERED_PART, "일부 복구 확인 ({event})\n{affected}");
|
||||||
|
DEFAULTS = Collections.unmodifiableMap(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자가 검수 시 채워 넣을 항목 */
|
||||||
|
public static final class Section {
|
||||||
|
private final String title;
|
||||||
|
private final String hint;
|
||||||
|
|
||||||
|
public Section(String title, String hint) {
|
||||||
|
this.title = title;
|
||||||
|
this.hint = hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHint() {
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Section> DEFAULT_SECTIONS = Collections.unmodifiableList(new ArrayList<Section>() {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
{
|
||||||
|
add(new Section("발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"));
|
||||||
|
add(new Section("영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"));
|
||||||
|
add(new Section("조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"));
|
||||||
|
add(new Section("예상 복구 시간", "예) 복구 완료 시 실제 시각으로 갱신"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 점(.) 으로 평탄화한 문구 맵 */
|
||||||
|
private Map<String, String> texts = DEFAULTS;
|
||||||
|
private List<Section> sections = DEFAULT_SECTIONS;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void load() {
|
||||||
|
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||||
|
if (in == null) {
|
||||||
|
log.info("{} 없음 - 기본 초안 양식 사용", RESOURCE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SafeConstructor - 임의 클래스 생성(CVE-2022-1471 계열)을 막고 스칼라/맵/리스트만 읽는다
|
||||||
|
Object root = new Yaml(new SafeConstructor(new LoaderOptions())).load(in);
|
||||||
|
Map<String, Object> draft = asMap(asMap(root).get("draft"));
|
||||||
|
if (draft.isEmpty()) {
|
||||||
|
log.warn("{} 에 draft 섹션이 없음 - 기본 초안 양식 사용", RESOURCE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> loaded = new LinkedHashMap<>(DEFAULTS);
|
||||||
|
flatten("", draft, loaded);
|
||||||
|
this.texts = loaded;
|
||||||
|
this.sections = readSections(draft);
|
||||||
|
log.info("초안 양식 로딩 완료: {} (섹션 {}개)", RESOURCE, sections.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 문구 설정 문제로 장애 탐지가 멈추면 안 된다
|
||||||
|
log.warn("{} 로딩 실패 - 기본 초안 양식 사용", RESOURCE, e);
|
||||||
|
this.texts = DEFAULTS;
|
||||||
|
this.sections = DEFAULT_SECTIONS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문구 조회 + 치환. {@code args} 는 {키, 값, 키, 값...} 순으로 넘긴다.
|
||||||
|
* 정의되지 않은 치환자는 그대로 남겨 어떤 키가 빠졌는지 화면에서 드러나게 한다.
|
||||||
|
*/
|
||||||
|
public String text(String key, String... args) {
|
||||||
|
String template = texts.get(key);
|
||||||
|
if (template == null) {
|
||||||
|
template = DEFAULTS.get(key);
|
||||||
|
}
|
||||||
|
if (template == null) {
|
||||||
|
log.warn("정의되지 않은 초안 문구 키: {}", key);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String result = template;
|
||||||
|
for (int i = 0; i + 1 < args.length; i += 2) {
|
||||||
|
result = result.replace("{" + args[i] + "}", args[i + 1] == null ? "" : args[i + 1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Section> getSections() {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── 내부 헬퍼 ──────────────
|
||||||
|
|
||||||
|
/** 중첩 맵을 {@code body.lead-draft} 형태의 평탄 키로 편다. 리스트(sections)는 별도로 읽는다. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void flatten(String prefix, Map<String, Object> source, Map<String, String> target) {
|
||||||
|
for (Map.Entry<String, Object> entry : source.entrySet()) {
|
||||||
|
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
|
||||||
|
Object value = entry.getValue();
|
||||||
|
if (value instanceof Map) {
|
||||||
|
flatten(key, (Map<String, Object>) value, target);
|
||||||
|
} else if (value != null && !(value instanceof List)) {
|
||||||
|
target.put(key, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Section> readSections(Map<String, Object> draft) {
|
||||||
|
Object body = asMap(draft.get("body")).get("sections");
|
||||||
|
if (!(body instanceof List)) {
|
||||||
|
return DEFAULT_SECTIONS;
|
||||||
|
}
|
||||||
|
List<Section> result = new ArrayList<>();
|
||||||
|
for (Object item : (List<?>) body) {
|
||||||
|
Map<String, Object> section = asMap(item);
|
||||||
|
Object title = section.get("title");
|
||||||
|
if (title == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object hint = section.get("hint");
|
||||||
|
result.add(new Section(String.valueOf(title), hint == null ? "" : String.valueOf(hint)));
|
||||||
|
}
|
||||||
|
return result.isEmpty() ? DEFAULT_SECTIONS : Collections.unmodifiableList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> asMap(Object value) {
|
||||||
|
return value instanceof Map ? (Map<String, Object>) value : Collections.<String, Object>emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ import java.util.List;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
import com.eactive.eai.data.jpa.BaseRepository;
|
import com.eactive.eai.data.jpa.BaseRepository;
|
||||||
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow;
|
||||||
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
|
||||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
|
||||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
|
||||||
|
|
||||||
@@ -24,7 +28,8 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
|||||||
@Query(nativeQuery = true, value =
|
@Query(nativeQuery = true, value =
|
||||||
" SELECT EAISVCNAME"
|
" SELECT EAISVCNAME"
|
||||||
+ " , (SELECT EAISVCDESC FROM TSEAIHE01 WHERE A.EAISVCNAME = EAISVCNAME) AS EAISVCDESC"
|
+ " , (SELECT EAISVCDESC FROM TSEAIHE01 WHERE A.EAISVCNAME = EAISVCNAME) AS EAISVCDESC"
|
||||||
+ " , PREV_STATUS_CODE "
|
// 따옴표 없는 PREV_STATUS_CODE 는 Oracle 이 대문자로 올려 getPrevStatusCode() 와 매칭되지 않는다 (null 반환)
|
||||||
|
+ " , PREV_STATUS_CODE AS \"prevStatusCode\""
|
||||||
+ " , EVENT"
|
+ " , EVENT"
|
||||||
+ " FROM ("
|
+ " FROM ("
|
||||||
+ " SELECT EAISVCNAME"
|
+ " SELECT EAISVCNAME"
|
||||||
@@ -39,12 +44,15 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
|||||||
+ " FROM ("
|
+ " FROM ("
|
||||||
+ " SELECT A.EAISVCNAME"
|
+ " SELECT A.EAISVCNAME"
|
||||||
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
|
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
|
||||||
|
// 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을
|
||||||
|
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CONTROL_START 가 발생한다.
|
||||||
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
|
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
|
||||||
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
|
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
|
||||||
|
+ " AND Y.KIND = 'MAINTENANCE'"
|
||||||
+ " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) "
|
+ " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) "
|
||||||
+ " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN "
|
+ " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN "
|
||||||
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
|
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
|
||||||
+ " WHEN (TOTAL - SUCCESS) * 100 / TOTAL > :errorRate THEN 'Y'"
|
+ " WHEN (TOTAL - SUCCESS) * 100 / TOTAL >= :errorRate THEN 'Y'"
|
||||||
+ " ELSE 'N' END"
|
+ " ELSE 'N' END"
|
||||||
+ " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
+ " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
||||||
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
|
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
|
||||||
@@ -54,7 +62,7 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
|||||||
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
|
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
|
||||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN"
|
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN"
|
||||||
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
|
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
|
||||||
+ " WHEN RESP_SUM / TOTAL > :delayAvgRespTime THEN 'Y'"
|
+ " WHEN RESP_SUM / TOTAL >= :delayAvgRespTime THEN 'Y'"
|
||||||
+ " ELSE 'N' END"
|
+ " ELSE 'N' END"
|
||||||
+ " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
+ " FROM (SELECT NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
||||||
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
|
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
|
||||||
@@ -73,4 +81,88 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
|||||||
@Param("delayRangeMinute") int delayRangeMinute,
|
@Param("delayRangeMinute") int delayRangeMinute,
|
||||||
@Param("delayAvgRespTime") int delayAvgRespTime
|
@Param("delayAvgRespTime") int delayAvgRespTime
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 판정 구간에 트래픽이 있는 API 의 오류율·지연 판정값. 개발 모드(-Deai.systemmode=D) 진단 로그 전용.
|
||||||
|
*
|
||||||
|
* <p>{@code findApiStatusEvents} 는 임계값을 넘고 <b>상태 전이까지 일어난</b> API 만 돌려주므로
|
||||||
|
* "오류는 나는데 왜 장애로 안 잡히나", "느린데 왜 지연이 아닌가" 를 볼 수 없다. 이 쿼리는
|
||||||
|
* 임계값·현재 상태와 무관하게 집계값을 그대로 보여준다.</p>
|
||||||
|
*
|
||||||
|
* <p>오류가 0건이어도 지연 판정값은 확인해야 하므로 오류 발생 여부로 거르지 않는다.</p>
|
||||||
|
*
|
||||||
|
* <p>컬럼 alias 는 반드시 <b>따옴표로 감싼 camelCase</b> 로 둔다. 인터페이스 프로젝션은 tuple
|
||||||
|
* alias 를 getter 프로퍼티명으로 찾는데, 따옴표가 없으면 Oracle 이 alias 를 대문자로 올려
|
||||||
|
* {@code STATUS_CODE} 와 {@code statusCode} 가 매칭되지 않아 <b>조용히 null</b> 이 된다
|
||||||
|
* (단일 단어는 대소문자만 달라 우연히 매칭된다).</p>
|
||||||
|
*/
|
||||||
|
@Query(nativeQuery = true, value =
|
||||||
|
" SELECT N.API_NAME AS \"eaisvcname\""
|
||||||
|
+ " , NVL(B.STATUS_CODE,'N') AS \"statusCode\""
|
||||||
|
+ " , NVL(S.TOTAL,0) AS \"total\""
|
||||||
|
+ " , NVL(S.SUCCESS,0) AS \"success\""
|
||||||
|
+ " , CASE WHEN NVL(S.TOTAL,0) > 0 THEN ROUND((S.TOTAL - S.SUCCESS) * 100 / S.TOTAL, 2) END AS \"errorRate\""
|
||||||
|
+ " , CASE WHEN NVL(S.TOTAL,0) > 0 THEN ROUND(S.RESP_SUM / S.TOTAL, 2) END AS \"avgRespTime\""
|
||||||
|
+ " , NVL(D.TOTAL,0) AS \"delayTotal\""
|
||||||
|
+ " , CASE WHEN NVL(D.TOTAL,0) > 0 THEN ROUND(D.RESP_SUM / D.TOTAL, 2) END AS \"delayAvgRespTime\""
|
||||||
|
// 두 창 중 넓은 쪽으로 대상 API 를 먼저 뽑는다. 오류 창만 기준으로 잡으면 오류 창에 거래가
|
||||||
|
// 없을 때 지연 판정값까지 통째로 사라진다 (창 길이가 서로 다르다)
|
||||||
|
+ " FROM (SELECT DISTINCT API_NAME"
|
||||||
|
+ " FROM API_STATS_MINUTE"
|
||||||
|
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
|
||||||
|
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(GREATEST(:errorRangeMinute, :delayRangeMinute),'MINUTE'),'YYYYMMDDHH24MI')"
|
||||||
|
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')) N"
|
||||||
|
+ " LEFT OUTER JOIN (SELECT API_NAME"
|
||||||
|
+ " , NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
||||||
|
+ " , NVL(SUM(SUCCESS_CNT),0) AS SUCCESS"
|
||||||
|
+ " , NVL(SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME),0) AS RESP_SUM"
|
||||||
|
+ " FROM API_STATS_MINUTE"
|
||||||
|
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
|
||||||
|
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
|
||||||
|
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')"
|
||||||
|
+ " GROUP BY API_NAME) S ON N.API_NAME = S.API_NAME"
|
||||||
|
// 지연은 판정 구간이 따로다(delayRangeMinute). 오류 구간 평균으로 비교하면 실제 판정과 어긋난다
|
||||||
|
+ " LEFT OUTER JOIN (SELECT API_NAME"
|
||||||
|
+ " , NVL(SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT),0) AS TOTAL"
|
||||||
|
+ " , NVL(SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME),0) AS RESP_SUM"
|
||||||
|
+ " FROM API_STATS_MINUTE"
|
||||||
|
+ " WHERE TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
|
||||||
|
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:delayRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
|
||||||
|
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI')"
|
||||||
|
+ " GROUP BY API_NAME) D ON N.API_NAME = D.API_NAME"
|
||||||
|
+ " LEFT OUTER JOIN API_STATUS B ON N.API_NAME = B.EAISVCNAME"
|
||||||
|
// 두 창 중 어느 쪽이든 거래가 있으면 찍는다 (오류 0건, 오류 창 무거래 모두 포함)
|
||||||
|
+ " WHERE NVL(S.TOTAL,0) > 0 OR NVL(D.TOTAL,0) > 0"
|
||||||
|
+ " ORDER BY CASE WHEN NVL(S.TOTAL,0) > 0 THEN (S.TOTAL - S.SUCCESS) / S.TOTAL ELSE -1 END DESC, N.API_NAME")
|
||||||
|
List<ApiErrorRateRow> findApiErrorRates(
|
||||||
|
@Param("errorRangeMinute") int errorRangeMinute,
|
||||||
|
@Param("delayRangeMinute") int delayRangeMinute
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EAI 서비스명 조회. 장애/점검 영향 API 의 API_NAME 캐시용.
|
||||||
|
*/
|
||||||
|
@Query(nativeQuery = true, value =
|
||||||
|
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
|
||||||
|
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발자포탈에 게시되는 인터페이스(= API) 만 추린다.
|
||||||
|
*
|
||||||
|
* <p>GW 인터페이스는 전부 탐지 대상이지만 개발자포탈에 노출되는 것은 그 일부다.
|
||||||
|
* 자동 초안(제목·공지 본문·타임라인)에 게시되지 않은 인터페이스 ID 가 그대로 박히면
|
||||||
|
* 고객 화면에 내부 인터페이스가 드러나므로, 여기서 걸러 나머지는 건수로만 표기한다.</p>
|
||||||
|
*
|
||||||
|
* <p>판정 기준은 포털 "오픈 API" 목록과 같은 두 조건이다 - 노출 중인 API 그룹에 편성돼 있고
|
||||||
|
* PTL_API_SPEC_INFO 에 스펙이 있을 것. 역할·소속에 따른 사용자별 공개 범위는 admin 에
|
||||||
|
* 사용자 컨텍스트가 없어 적용하지 않는다(= 포털 화면이 한 번 더 좁힌다).</p>
|
||||||
|
*/
|
||||||
|
@Query(nativeQuery = true, value =
|
||||||
|
" SELECT DISTINCT GA.API_ID"
|
||||||
|
+ " FROM API_GROUP_API GA"
|
||||||
|
+ " JOIN API_GROUP G ON G.ID = GA.API_GROUP_ID"
|
||||||
|
+ " WHERE GA.API_ID IN (:apiIds)"
|
||||||
|
+ " AND G.DISPLAY_YN = '1'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM EMSAPP.PTL_API_SPEC_INFO S WHERE S.API_ID = GA.API_ID)")
|
||||||
|
List<String> findPublishedApiIds(@Param("apiIds") Collection<String> apiIds);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.eactive.eai.rms.ext.djb.apistatus;
|
package com.eactive.eai.rms.ext.djb.apistatus;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -13,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import com.eactive.eai.rms.data.entity.man.role.Role;
|
import com.eactive.eai.rms.data.entity.man.role.Role;
|
||||||
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow;
|
||||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
|
||||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
|
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
|
||||||
import com.eactive.eai.rms.ext.djb.ums.UmsManager;
|
import com.eactive.eai.rms.ext.djb.ums.UmsManager;
|
||||||
@@ -36,11 +39,18 @@ public class ApiStatusService {
|
|||||||
|
|
||||||
public void updateApiStatus(HashMap<String, String> param) {
|
public void updateApiStatus(HashMap<String, String> param) {
|
||||||
|
|
||||||
|
int errorRate = Integer.parseInt(param.get("errorRate"));
|
||||||
|
int errorRangeMinute = Integer.parseInt(param.get("errorRangeMinute"));
|
||||||
|
int delayRangeMinute = Integer.parseInt(param.get("delayRangeMinute"));
|
||||||
|
int delayAvgRespTime = Integer.parseInt(param.get("delayAvgRespTime"));
|
||||||
|
|
||||||
|
logErrorRates(errorRate, errorRangeMinute, delayRangeMinute, delayAvgRespTime);
|
||||||
|
|
||||||
List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents(
|
List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents(
|
||||||
Integer.parseInt(param.get("errorRate")),
|
errorRate,
|
||||||
Integer.parseInt(param.get("errorRangeMinute")),
|
errorRangeMinute,
|
||||||
Integer.parseInt(param.get("delayRangeMinute")),
|
delayRangeMinute,
|
||||||
Integer.parseInt(param.get("delayAvgRespTime"))
|
delayAvgRespTime
|
||||||
);
|
);
|
||||||
|
|
||||||
HashMap<String, List<String>> eventMap = new HashMap<>();
|
HashMap<String, List<String>> eventMap = new HashMap<>();
|
||||||
@@ -88,6 +98,85 @@ public class ApiStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 판정 구간에 거래가 있는 API 의 오류율·지연 판정값을 debug 로그로 남긴다.
|
||||||
|
* 개발 모드(-Deai.systemmode=D) 에서만 동작한다.
|
||||||
|
*
|
||||||
|
* <p>상태 전이 조건을 못 넘긴 API 는 {@code findApiStatusEvents} 결과에 나오지 않아
|
||||||
|
* "오류는 나는데 장애로 안 잡힌다", "느린데 지연이 아니다" 를 로그로 확인할 수 없다.
|
||||||
|
* 임계값·현재 상태와 무관하게 집계값을 그대로 찍어 판정 결과와 대조할 수 있게 한다.</p>
|
||||||
|
*
|
||||||
|
* <p>오류 0건이어도 지연 판정값은 봐야 하므로 거래만 있으면 무조건 찍는다.</p>
|
||||||
|
*
|
||||||
|
* <p>진단 목적이므로 조회 실패가 상태 판정 자체를 막아서는 안 된다 - 예외는 삼킨다.</p>
|
||||||
|
*/
|
||||||
|
private void logErrorRates(int errorRate, int errorRangeMinute, int delayRangeMinute, int delayAvgRespTime) {
|
||||||
|
if (!isDevMode() || !log.isDebugEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<ApiErrorRateRow> rows = apiStatusRepository.findApiErrorRates(errorRangeMinute, delayRangeMinute);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
log.debug("상태 판정 진단: 최근 {}분(오류)/{}분(지연) 거래 발생 API 없음",
|
||||||
|
errorRangeMinute, delayRangeMinute);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("상태 판정 진단: 장애 임계 ").append(errorRate).append("% 초과(최근 ").append(errorRangeMinute)
|
||||||
|
.append("분), 지연 임계 ").append(delayAvgRespTime).append("ms 초과(최근 ").append(delayRangeMinute)
|
||||||
|
.append("분), 대상 ").append(rows.size()).append("건");
|
||||||
|
|
||||||
|
for (ApiErrorRateRow row : rows) {
|
||||||
|
sb.append("\n - ").append(row.getEaisvcname())
|
||||||
|
.append(" 현재상태=").append(row.getStatusCode())
|
||||||
|
.append("\n 오류율=").append(row.getErrorRate()).append("%")
|
||||||
|
.append(" (임계 대비 ").append(ratioOf(row.getErrorRate(), errorRate)).append("%)")
|
||||||
|
.append(", 총 ").append(row.getTotal()).append("건, 성공 ").append(row.getSuccess()).append("건")
|
||||||
|
.append(" → ").append(thresholdMark(row.getErrorRate(), errorRate))
|
||||||
|
.append("\n 평균응답=").append(row.getDelayAvgRespTime()).append("ms")
|
||||||
|
.append(" (임계 대비 ").append(ratioOf(row.getDelayAvgRespTime(), delayAvgRespTime)).append("%)")
|
||||||
|
.append(", 총 ").append(row.getDelayTotal()).append("건")
|
||||||
|
.append(" → ").append(thresholdMark(row.getDelayAvgRespTime(), delayAvgRespTime));
|
||||||
|
}
|
||||||
|
log.debug(sb.toString());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("상태 판정 진단 로그 생성 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임계값 대비 비율(%). 100% 면 임계값과 같은 값이고, 판정은 '초과'(>) 라 100% 는 아직 안 걸린다.
|
||||||
|
*/
|
||||||
|
private String ratioOf(BigDecimal value, int threshold) {
|
||||||
|
if (value == null || threshold <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return value.multiply(BigDecimal.valueOf(100))
|
||||||
|
.divide(BigDecimal.valueOf(threshold), 2, RoundingMode.HALF_UP)
|
||||||
|
.toPlainString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임계 초과 여부 표시. 판정 조건이 '초과'(>) 이므로 임계값과 같은 값은 초과가 아니다.
|
||||||
|
*/
|
||||||
|
private String thresholdMark(BigDecimal value, int threshold) {
|
||||||
|
if (value == null) {
|
||||||
|
return "데이터없음";
|
||||||
|
}
|
||||||
|
return value.compareTo(BigDecimal.valueOf(threshold)) > 0 ? "임계초과" : "임계이하";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발 모드 여부. -Deai.systemmode=D 로 기동한 경우에만 진단 로그를 남긴다.
|
||||||
|
*/
|
||||||
|
private boolean isDevMode() {
|
||||||
|
return "D".equalsIgnoreCase(System.getProperty("eai.systemmode", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private String resolveStatusCode(String event) {
|
private String resolveStatusCode(String event) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "CONTROL_START": return "C"; //점검
|
case "CONTROL_START": return "C"; //점검
|
||||||
|
|||||||
@@ -61,13 +61,12 @@ public class ApiUseStatsService
|
|||||||
|
|
||||||
|
|
||||||
private Query getDataQuery(ApiStatsSearch search) {
|
private Query getDataQuery(ApiStatsSearch search) {
|
||||||
|
|
||||||
StringBuilder where = buildNativeWhere(search);
|
StringBuilder where = buildNativeWhere(search);
|
||||||
|
|
||||||
String dataSql = "";
|
String dataSql = "";
|
||||||
if ("ORG".equals(search.getSearchType())) {
|
if ("ORG".equals(search.getSearchType())) {
|
||||||
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
||||||
+ ", B.ORGNAME"
|
+ ", NVL(B.ORGNAME, D.ORG_NAME) AS ORGNAME"
|
||||||
+ ", SUM(A.TOTAL_CNT)"
|
+ ", SUM(A.TOTAL_CNT)"
|
||||||
+ ", SUM(A.SUCCESS_CNT)"
|
+ ", SUM(A.SUCCESS_CNT)"
|
||||||
+ ", CASE WHEN SUM(A.TOTAL_CNT) > 0 THEN SUM(A.SUCCESS_CNT) * 100 / SUM(A.TOTAL_CNT) ELSE NULL END"
|
+ ", CASE WHEN SUM(A.TOTAL_CNT) > 0 THEN SUM(A.SUCCESS_CNT) * 100 / SUM(A.TOTAL_CNT) ELSE NULL END"
|
||||||
@@ -77,13 +76,41 @@ public class ApiUseStatsService
|
|||||||
+ ", CASE WHEN SUM(A.TOTAL_CNT) > 0 THEN TRUNC(SUM(A.TOTAL_CNT * A.AVG_RESP_TIME) / SUM(A.TOTAL_CNT)) ELSE NULL END"
|
+ ", CASE WHEN SUM(A.TOTAL_CNT) > 0 THEN TRUNC(SUM(A.TOTAL_CNT * A.AVG_RESP_TIME) / SUM(A.TOTAL_CNT)) ELSE NULL END"
|
||||||
+ ", MIN(A.MIN_RESP_TIME)"
|
+ ", MIN(A.MIN_RESP_TIME)"
|
||||||
+ ", MAX(A.MAX_RESP_TIME)"
|
+ ", MAX(A.MAX_RESP_TIME)"
|
||||||
+ " FROM API_STATS_DAY A"
|
+ " FROM ("
|
||||||
|
+ " SELECT A.STAT_TIME "
|
||||||
|
+ " , A.API_NAME"
|
||||||
|
+ " , A.CLIENT_ID "
|
||||||
|
+ " , A.TOTAL_CNT"
|
||||||
|
+ " , A.SUCCESS_CNT"
|
||||||
|
+ " , A.AVG_RESP_TIME"
|
||||||
|
+ " , A.TIMEOUT_CNT"
|
||||||
|
+ " , A.SYSTEM_ERR_CNT"
|
||||||
|
+ " , A.MIN_RESP_TIME"
|
||||||
|
+ " , A.MAX_RESP_TIME"
|
||||||
|
+ " , CASE WHEN API_NAME LIKE '%1' THEN SUBSTR(INBOUND_ADAPTER,2,3) ELSE SUBSTR(OUTBOUND_ADAPTER,2,3) END AS ORG_CODE"
|
||||||
|
+ " FROM API_STATS_DAY A "
|
||||||
|
+ " WHERE A.STAT_TIME >= :searchStartDate "
|
||||||
|
+ " AND A.STAT_TIME <= :searchEndDate ";
|
||||||
|
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(search.getSearchApiName())) {
|
||||||
|
dataSql += " AND A.API_NAME LIKE :searchApiName ";
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSql += " ) A"
|
||||||
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
||||||
+ " LEFT OUTER JOIN TSEAIHE01 C ON A.API_NAME = C.EAISVCNAME "
|
+ " LEFT OUTER JOIN EMSAPP.PTL_ORG D ON A.ORG_CODE = D.ORG_CODE"
|
||||||
+ where
|
+ " WHERE 1=1 ";
|
||||||
+ " GROUP BY B.ORGNAME"
|
|
||||||
+ " ORDER BY ORGNAME";
|
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
|
||||||
} else if ("API".equals(search.getSearchType())) {
|
dataSql += " AND UPPER(NVL(B.ORGNAME, D.ORG_NAME)) LIKE :searchOrgName ";
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSql += " GROUP BY NVL(B.ORGNAME, D.ORG_NAME)"
|
||||||
|
+ " ORDER BY ORGNAME";
|
||||||
|
|
||||||
|
}
|
||||||
|
else if ("API".equals(search.getSearchType())) {
|
||||||
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
||||||
+ ", A.API_NAME"
|
+ ", A.API_NAME"
|
||||||
+ ", SUM(A.TOTAL_CNT)"
|
+ ", SUM(A.TOTAL_CNT)"
|
||||||
@@ -97,11 +124,11 @@ public class ApiUseStatsService
|
|||||||
+ ", MAX(A.MAX_RESP_TIME)"
|
+ ", MAX(A.MAX_RESP_TIME)"
|
||||||
+ " FROM API_STATS_DAY A "
|
+ " FROM API_STATS_DAY A "
|
||||||
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
||||||
+ " LEFT OUTER JOIN TSEAIHE01 C ON A.API_NAME = C.EAISVCNAME "
|
|
||||||
+ where
|
+ where
|
||||||
+ " GROUP BY A.API_NAME"
|
+ " GROUP BY A.API_NAME"
|
||||||
+ " ORDER BY API_NAME";
|
+ " ORDER BY API_NAME";
|
||||||
} else if ("DATE".equals(search.getSearchType())) {
|
}
|
||||||
|
else if ("DATE".equals(search.getSearchType())) {
|
||||||
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
dataSql = "SELECT COUNT(*) OVER () AS RECORDS "
|
||||||
+ ", TO_CHAR(STAT_TIME,'YYYY-MM-DD') AS STAT_TIME "
|
+ ", TO_CHAR(STAT_TIME,'YYYY-MM-DD') AS STAT_TIME "
|
||||||
+ ", SUM(A.TOTAL_CNT)"
|
+ ", SUM(A.TOTAL_CNT)"
|
||||||
@@ -115,23 +142,24 @@ public class ApiUseStatsService
|
|||||||
+ ", MAX(A.MAX_RESP_TIME)"
|
+ ", MAX(A.MAX_RESP_TIME)"
|
||||||
+ " FROM API_STATS_DAY A "
|
+ " FROM API_STATS_DAY A "
|
||||||
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
+ " LEFT OUTER JOIN TSEAIAU01 B ON A.CLIENT_ID = B.CLIENTID"
|
||||||
+ " LEFT OUTER JOIN TSEAIHE01 C ON A.API_NAME = C.EAISVCNAME "
|
|
||||||
+ where
|
+ where
|
||||||
+ " GROUP BY TO_CHAR(STAT_TIME,'YYYY-MM-DD')"
|
+ " GROUP BY TO_CHAR(STAT_TIME,'YYYY-MM-DD')"
|
||||||
+ " ORDER BY STAT_TIME";
|
+ " ORDER BY STAT_TIME";
|
||||||
}
|
}
|
||||||
|
|
||||||
Query dataQuery = entityManager.createNativeQuery(dataSql);
|
Query dataQuery = entityManager.createNativeQuery(dataSql);
|
||||||
if (search.getParsedStartDate() != null) {
|
|
||||||
dataQuery.setParameter("searchStartDate", search.getParsedStartDate());
|
|
||||||
dataQuery.setParameter("searchEndDate", search.getParsedEndDate());
|
dataQuery.setParameter("searchStartDate", search.getParsedStartDate());
|
||||||
|
dataQuery.setParameter("searchEndDate", search.getParsedEndDate());
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(search.getSearchApiName())) {
|
||||||
|
dataQuery.setParameter("searchApiName", "%" + search.getSearchApiName().toUpperCase() + "%");
|
||||||
}
|
}
|
||||||
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
|
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
|
||||||
dataQuery.setParameter("searchOrgName", "%" + search.getSearchOrgName().toUpperCase() + "%");
|
dataQuery.setParameter("searchOrgName", "%" + search.getSearchOrgName().toUpperCase() + "%");
|
||||||
}
|
}
|
||||||
if (StringUtils.isNotBlank(search.getSearchApiName())) {
|
|
||||||
dataQuery.setParameter("searchApiName", "%" + search.getSearchApiName().toUpperCase() + "%");
|
|
||||||
}
|
|
||||||
return dataQuery;
|
return dataQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,19 +191,17 @@ public class ApiUseStatsService
|
|||||||
|
|
||||||
calculateDateRange(search);
|
calculateDateRange(search);
|
||||||
|
|
||||||
if (search.getParsedStartDate() != null) {
|
where.append(" AND A.STAT_TIME >= :searchStartDate ");
|
||||||
where.append(" AND A.STAT_TIME >= :searchStartDate ");
|
where.append(" AND A.STAT_TIME <= :searchEndDate ");
|
||||||
where.append(" AND A.STAT_TIME <= :searchEndDate ");
|
|
||||||
|
if (StringUtils.isNotBlank(search.getSearchApiName())) {
|
||||||
|
where.append(" AND A.API_NAME LIKE :searchApiName ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
|
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
|
||||||
where.append(" AND UPPER(B.ORGNAME) LIKE :searchOrgName ");
|
where.append(" AND UPPER(B.ORGNAME) LIKE :searchOrgName ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(search.getSearchApiName())) {
|
|
||||||
where.append(" AND UPPER(C.EAISVCDESC) LIKE :searchApiName ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ public class UmsService {
|
|||||||
|
|
||||||
private static final Gson gson = new Gson();
|
private static final Gson gson = new Gson();
|
||||||
|
|
||||||
private static final String SYS_DVCD = "API"; //시스템코드
|
private static final String SYS_DVCD = "API"; //시스템코드
|
||||||
private static final String BZWK_DVCD = "99"; //업무구분코드
|
private static final String BZWK_DVCD = "99"; //업무구분코드
|
||||||
private static final String SUB_BZWK_CD = "9019"; //세부구분코드
|
private static final String BZWK_CD = "999"; //업무코드
|
||||||
private static final String TEAM_DVCD = "00"; //팀구분코드 TODO: 변경해야함
|
private static final String BZWK_DCLS_DVCD = "9019"; //세부구분코드
|
||||||
|
private static final String SUB_BZWK_CD = "999"; //서브업무코드 TODO: 변경할것
|
||||||
|
private static final String TEAM_DVCD = "99"; //팀구분코드 TODO: 변경해야함
|
||||||
|
|
||||||
|
|
||||||
private final MonitoringPropertyService propertyService;
|
private final MonitoringPropertyService propertyService;
|
||||||
@@ -199,38 +201,33 @@ public class UmsService {
|
|||||||
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
||||||
|
|
||||||
}
|
}
|
||||||
else if ("SMS".equals(messageRequest.getMessageType())
|
else if ("SMS".equals(messageRequest.getMessageType())) {
|
||||||
|| "KAKAO".equals(messageRequest.getMessageType())) {
|
|
||||||
|
|
||||||
// Sms 데이터
|
// Sms 데이터
|
||||||
SmsDataVO dataVO = new SmsDataVO();
|
SmsDataVO dataVO = new SmsDataVO();
|
||||||
dataVO.setSnd_dman_dt(jsonData[0]);
|
dataVO.setSnd_dman_dt(jsonData[0]);
|
||||||
dataVO.setSnd_dman_id(this.getSndDmanId(messageRequest.getMessageType()));
|
dataVO.setSnd_dman_id(this.getSndDmanId(messageRequest.getMessageType()));
|
||||||
dataVO.setSnd_dvcd("EAI");
|
dataVO.setSnd_dvcd("EAI");
|
||||||
dataVO.setMsg_titl(StringUtil.chunkString(messageRequest.getSubject(), 40, false)); // MS949 40byte 초과시 절단
|
dataVO.setSms_msg_titl(StringUtil.chunkString(messageRequest.getSubject(), 40, false)); // MS949 40byte 초과시 절단
|
||||||
dataVO.setMsg_ctnt(messageRequest.getMessage());
|
dataVO.setSms_msg_ctnt(messageRequest.getMessage());
|
||||||
dataVO.setRecvr_no(PhoneNumberUtil.digitsOnly(messageRequest.getPhone()));
|
dataVO.setRecvr_no(PhoneNumberUtil.digitsOnly(messageRequest.getPhone()));
|
||||||
dataVO.setSndn_no("15880079"); //발신번호
|
dataVO.setSndn_no("15880079"); //발신번호
|
||||||
dataVO.setSnd_empno("APIM"); //요청자
|
dataVO.setSnd_empno("APIM"); //요청자
|
||||||
dataVO.setSnd_emp_dprm_cd("132"); //요청부서코드
|
dataVO.setSnd_emp_dprm_cd("132"); //요청부서코드
|
||||||
if ("SMS".equals(messageRequest.getMessageType())) {
|
dataVO.setLtrs_snd_tycd("2"); // 1:SMS 2:LMS/MMS
|
||||||
dataVO.setLtrs_snd_tycd("1"); // 1:SMS 2:LMS/MMS
|
dataVO.setMsg_snd_tycd("1008"); // 알림톡 메세지 종류 0000:SMS, 1000:LMS, 1008:알림톡, ....
|
||||||
dataVO.setMsg_snd_tycd("0000"); // 알림톡 메세지 종류 0000:SMS, 1000:LMS, 1008:알림톡, ....
|
//ADMIN으로 시작하면 제주은행 임직원, 나머지는 제주은행 고객용
|
||||||
} else if ("KAKAO".equals(messageRequest.getMessageType())) {
|
if (messageRequest.getMessageCode().name().startsWith("ADMIN")) {
|
||||||
dataVO.setLtrs_snd_tycd("2"); // 1:SMS 2:LMS/MMS
|
dataVO.setAlmtk_snd_prfl_key(propertyService.getPropertyValue("Monitoring", "djb.ums.sms.almtk_snd_prtl_key.intra", "")); // 제주은행 임직원 플러스친구아이디
|
||||||
dataVO.setMsg_snd_tycd("1008"); // 알림톡 메세지 종류 0000:SMS, 1000:LMS, 1008:알림톡, ....
|
} else {
|
||||||
//ADMIN으로 시작하면 제주은행 임직원, 나머지는 제주은행 고객용
|
dataVO.setAlmtk_snd_prfl_key(propertyService.getPropertyValue("Monitoring", "djb.ums.sms.almtk_snd_prtl_key.public", "")); // 제주은행 플러스친구아이디
|
||||||
if (messageRequest.getMessageCode().name().startsWith("ADMIN")) {
|
}
|
||||||
dataVO.setAlmtk_snd_prfl_key(propertyService.getPropertyValue("Monitoring", "djb.ums.sms.almtk_snd_prtl_key.intra", "")); // 제주은행 임직원 플러스친구아이디
|
dataVO.setAlmtk_tmplt_cd(BZWK_DVCD + TEAM_DVCD + BZWK_DCLS_DVCD + "001"); // 템플릿코드
|
||||||
} else {
|
dataVO.setAlmtk_err_ltrs_snd_yn("Y"); // 오류자문자발송여부
|
||||||
dataVO.setAlmtk_snd_prfl_key(propertyService.getPropertyValue("Monitoring", "djb.ums.sms.almtk_snd_prtl_key.public", "")); // 제주은행 플러스친구아이디
|
dataVO.setAlmtk_btn_trnm_tycd("2"); // 카카오버튼전송방식 1-format string 2-JSON 3-XML
|
||||||
}
|
|
||||||
dataVO.setAlmtk_tmplt_cd(BZWK_DVCD + TEAM_DVCD + SUB_BZWK_CD + "001"); // 템플릿코드
|
|
||||||
dataVO.setAlmtk_err_ltrs_snd_yn("Y"); // 오류자문자발송여부
|
|
||||||
dataVO.setAlmtk_btn_trnm_tycd("2"); // 카카오버튼전송방식 1-format string 2-JSON 3-XML
|
|
||||||
}
|
|
||||||
dataVO.setSms_trnm_bzwk_dvcd(BZWK_DVCD); //업무구분코드
|
dataVO.setSms_trnm_bzwk_dvcd(BZWK_DVCD); //업무구분코드
|
||||||
dataVO.setSms_bzwk_dcls_dvcd(SUB_BZWK_CD); //업무세분코드
|
dataVO.setSms_bzwk_dcls_dvcd(BZWK_DCLS_DVCD); //업무세분코드
|
||||||
dataVO.setSms_sys_dvcd("83"); //시스템코드
|
dataVO.setSms_sys_dvcd("83"); //시스템코드
|
||||||
dataVO.setTx_dt(jsonData[0]); //거래일자
|
dataVO.setTx_dt(jsonData[0]); //거래일자
|
||||||
dataVO.setTx_tktm(jsonData[1].substring(0,6)); //거래시간
|
dataVO.setTx_tktm(jsonData[1].substring(0,6)); //거래시간
|
||||||
@@ -244,8 +241,8 @@ public class UmsService {
|
|||||||
// 헤더 + 데이터
|
// 헤더 + 데이터
|
||||||
rtnVal = "{\"HEAD\":" + header + " ,\r\n" + "\"DATA\":\r\n" + " [" + "{\r\n"
|
rtnVal = "{\"HEAD\":" + header + " ,\r\n" + "\"DATA\":\r\n" + " [" + "{\r\n"
|
||||||
+ " \"data_dvcd\":\"IO\", \r\n" + " \"BIZ_DATA\":\r\n" + " {\r\n"
|
+ " \"data_dvcd\":\"IO\", \r\n" + " \"BIZ_DATA\":\r\n" + " {\r\n"
|
||||||
+ " \"ums_if_data_ncnt\":1, \r\n"
|
+ " \"ums_if_data_ccnt\":1, \r\n"
|
||||||
+ " \"ums_if_data\":" + data
|
+ " \"ltrsAlmtkSndLnkdInputDto\":" + data
|
||||||
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -253,24 +250,24 @@ public class UmsService {
|
|||||||
|
|
||||||
// 이메일 데이터
|
// 이메일 데이터
|
||||||
EmailDataVO dataVO = new EmailDataVO();
|
EmailDataVO dataVO = new EmailDataVO();
|
||||||
dataVO.setUms_evnt_id(""); // TODO: UMS이벤트ID(필수)
|
dataVO.setUms_evnt_id(propertyService.getPropertyValue("Monitoring", "djb.ums.email.ums_evnt_id", "")); // UMS이벤트ID(필수)
|
||||||
dataVO.setSnd_dman_dt(jsonData[0]);
|
dataVO.setSnd_dman_dt(jsonData[0]);
|
||||||
dataVO.setSnd_dman_id("");
|
dataVO.setSnd_dman_id(this.getSndDmanId(messageRequest.getMessageType()));
|
||||||
dataVO.setSnd_dman_sno("1");
|
dataVO.setSnd_dman_sno("1");
|
||||||
dataVO.setEmail_titl(messageRequest.getSubject()); // 메일제목
|
dataVO.setEmail_titl(messageRequest.getSubject()); // 메일제목
|
||||||
dataVO.setCstno(""); // TODO: 고객번호(필수)
|
dataVO.setCstno(propertyService.getPropertyValue("Monitoring", "djb.ums.email.cstno", "")); // 고객번호(필수)
|
||||||
dataVO.setSendr_emaddr(""); // TODO: 발신자 이메일
|
dataVO.setSendr_emaddr(propertyService.getPropertyValue("Monitoring", "djb.ums.email.sendr_emaddr", "")); // 발신자 이메일
|
||||||
dataVO.setSendr_nm(""); // TODO: 발신자 이름
|
dataVO.setSendr_nm(propertyService.getPropertyValue("Monitoring", "djb.ums.email.sendr_nm", "")); // 발신자 이름
|
||||||
dataVO.setSndbk_emaddr(""); // TODO: 리턴 이메일
|
dataVO.setSndbk_emaddr(propertyService.getPropertyValue("Monitoring", "djb.ums.email.sndbk_emaddr", "")); // 리턴 이메일
|
||||||
dataVO.setRecvr_emaddr(messageRequest.getEmail()); // 수신자 이메일(필수)
|
dataVO.setRecvr_emaddr(messageRequest.getEmail()); // 수신자 이메일(필수)
|
||||||
dataVO.setRecvr_nm(messageRequest.getUsername()); // 수신자명(필수)
|
dataVO.setRecvr_nm(messageRequest.getUsername()); // 수신자명(필수)
|
||||||
dataVO.setTmplt_mpng_info(messageRequest.getMessage()); // 탬플릿매핑정보
|
dataVO.setTmplt_mpng_info(messageRequest.getMessage()); // 탬플릿매핑정보
|
||||||
dataVO.setTmplt_mpng_rptt_info(""); // 탬플릿매핑반복정보
|
dataVO.setTmplt_mpng_rptt_info(""); // 탬플릿매핑반복정보
|
||||||
dataVO.setSnd_empno("APIM"); // 발신자ID(필수)
|
dataVO.setSnd_empno("APIM"); // 발신자ID(필수)
|
||||||
dataVO.setSnd_emp_dprm_cd("132"); // 발신자부서코드(필수)
|
dataVO.setSnd_emp_dprm_cd("132"); // 발신자부서코드(필수)
|
||||||
dataVO.setBzwk_cd(BZWK_DVCD); // 업무구분코드
|
dataVO.setBzwk_cd(BZWK_CD); // 업무코드
|
||||||
dataVO.setSub_bzwk_cd(SUB_BZWK_CD); // 업무세부코드
|
dataVO.setSub_bzwk_cd(SUB_BZWK_CD); // 서브업무코드
|
||||||
dataVO.setSys_dvcd(SYS_DVCD);
|
dataVO.setSys_dvcd(SYS_DVCD); // 시스템구분코드
|
||||||
dataVO.setTx_dt(jsonData[0]); // 거래일자
|
dataVO.setTx_dt(jsonData[0]); // 거래일자
|
||||||
dataVO.setTx_tktm(jsonData[1].substring(0,6)); // 거래시간
|
dataVO.setTx_tktm(jsonData[1].substring(0,6)); // 거래시간
|
||||||
|
|
||||||
@@ -283,8 +280,8 @@ public class UmsService {
|
|||||||
// 헤더 + 데이터
|
// 헤더 + 데이터
|
||||||
rtnVal = "{\"HEAD\":" + header + " ,\r\n" + "\"DATA\":\r\n" + " [" + "{\r\n"
|
rtnVal = "{\"HEAD\":" + header + " ,\r\n" + "\"DATA\":\r\n" + " [" + "{\r\n"
|
||||||
+ " \"data_dvcd\":\"IO\", \r\n" + " \"BIZ_DATA\":\r\n" + " {\r\n"
|
+ " \"data_dvcd\":\"IO\", \r\n" + " \"BIZ_DATA\":\r\n" + " {\r\n"
|
||||||
+ " \"ums_if_data_ncnt\":1, \r\n"
|
+ " \"ums_if_data_ccnt\":1, \r\n"
|
||||||
+ " \"ums_if_data\":" + data
|
+ " \"eMAILSndLnkdInputDto\":" + data
|
||||||
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
+ " }\r\n" + " }\r\n" + " ]\r\n" + "}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,19 +346,21 @@ public class UmsService {
|
|||||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
|
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
|
||||||
String yyyymmdd = sdf.format(new Date());
|
String yyyymmdd = sdf.format(new Date());
|
||||||
|
|
||||||
String uuid = SYS_DVCD + BZWK_DVCD + SUB_BZWK_CD + yyyymmdd + "O" ;
|
String uuid = "";
|
||||||
|
|
||||||
if ("SMS".equals(messageType)) {
|
if ("EMAIL".equals(messageType)) {
|
||||||
uuid += "S";
|
uuid = SYS_DVCD + BZWK_CD + SUB_BZWK_CD + yyyymmdd + "O" + "E" ;
|
||||||
} else if ("LMS".equals(messageType)) {
|
|
||||||
uuid += "L";
|
long seq = messageRequestService.countEmailRequestsOnDate(LocalDate.now()) + 1;
|
||||||
} else if ("KAKAO".equals(messageType)) {
|
uuid += String.format("%013d", seq);
|
||||||
uuid += "K";
|
|
||||||
|
} else if ("SMS".equals(messageType)) { //kakao 알림톡
|
||||||
|
uuid = SYS_DVCD + BZWK_DVCD + BZWK_DCLS_DVCD + yyyymmdd + "O" + "K";
|
||||||
|
|
||||||
|
long seq = messageRequestService.countSmsKakaoRequestsOnDate(LocalDate.now()) + 1;
|
||||||
|
uuid += String.format("%013d", seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
long seq = messageRequestService.countSmsKakaoRequestsOnDate(LocalDate.now()) + 1;
|
|
||||||
uuid += String.format("%014d", seq);
|
|
||||||
|
|
||||||
return uuid;
|
return uuid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ public class SmsDataVO {
|
|||||||
private String ltrs_snd_tycd;
|
private String ltrs_snd_tycd;
|
||||||
|
|
||||||
/** LMS 제목 */
|
/** LMS 제목 */
|
||||||
private String msg_titl;
|
private String sms_msg_titl;
|
||||||
|
|
||||||
/** 메세지 */
|
/** 메세지 */
|
||||||
private String msg_ctnt;
|
private String sms_msg_ctnt;
|
||||||
|
|
||||||
/** 수신자번호 */
|
/** 수신자번호 */
|
||||||
private String recvr_no;
|
private String recvr_no;
|
||||||
|
|||||||
+17
-2
@@ -66,8 +66,8 @@ public class PortalApprovalManController extends BaseAnnotationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=APPROVE")
|
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=APPROVE")
|
||||||
public ResponseEntity<Void> approve(String id) {
|
public ResponseEntity<Void> approve(String id, @RequestParam(required = false) String gwAction) {
|
||||||
portalApprovalManService.approve(id, SessionManager.getLoginVo().getUserId());
|
portalApprovalManService.approve(id, SessionManager.getLoginVo().getUserId(), gwAction);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +77,21 @@ public class PortalApprovalManController extends BaseAnnotationController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=FORCE_REJECT")
|
||||||
|
public ResponseEntity<Void> forceReject(String id, String rejectReason) {
|
||||||
|
if (StringUtils.isBlank(rejectReason)) {
|
||||||
|
throw new BizException("강제 반려 사유를 입력해 주세요.");
|
||||||
|
}
|
||||||
|
portalApprovalManService.forceReject(id, SessionManager.getLoginVo().getUserId(), rejectReason);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=DELETE")
|
||||||
|
public ResponseEntity<Void> delete(String id) {
|
||||||
|
portalApprovalManService.deleteApprovalRequest(id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=REDEPLOY")
|
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=REDEPLOY")
|
||||||
public ResponseEntity<Void> redeploy(String id) {
|
public ResponseEntity<Void> redeploy(String id) {
|
||||||
portalApprovalManService.redeploy(id);
|
portalApprovalManService.redeploy(id);
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package com.eactive.eai.rms.onl.apim.approval;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.GwAction;
|
||||||
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||||
import com.eactive.apim.portal.approval.entity.Approval;
|
import com.eactive.apim.portal.approval.entity.Approval;
|
||||||
import com.eactive.apim.portal.approval.entity.ApprovalType;
|
import com.eactive.apim.portal.approval.entity.ApprovalType;
|
||||||
|
import com.eactive.apim.portal.approval.entity.ApproverStatus;
|
||||||
import com.eactive.apim.portal.approval.statemachine.*;
|
import com.eactive.apim.portal.approval.statemachine.*;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
@@ -41,6 +46,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import javax.persistence.EntityManager;
|
import javax.persistence.EntityManager;
|
||||||
import javax.persistence.PersistenceContext;
|
import javax.persistence.PersistenceContext;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -52,6 +58,7 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
private static final String HARD_DELETE_FLAG_KEY = "approval.hard-delete.enabled";
|
private static final String HARD_DELETE_FLAG_KEY = "approval.hard-delete.enabled";
|
||||||
|
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
|
private final PortalApprovalUIMapper portalApprovalUIMapper;
|
||||||
private final ApprovalStateMachine approvalStateMachine;
|
private final ApprovalStateMachine approvalStateMachine;
|
||||||
private final AppRequestRepository appRequestRepository;
|
private final AppRequestRepository appRequestRepository;
|
||||||
private final PortalAppApprovalListener portalAppApprovalListener;
|
private final PortalAppApprovalListener portalAppApprovalListener;
|
||||||
@@ -73,12 +80,18 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
private EntityManager entityManager;
|
private EntityManager entityManager;
|
||||||
|
|
||||||
public Page<PortalApprovalUI> selectList(Pageable pageable, PortalApprovalUISearch portalApprovalUISearch) {
|
public Page<PortalApprovalUI> selectList(Pageable pageable, PortalApprovalUISearch portalApprovalUISearch) {
|
||||||
|
// 목록 그리드는 Approval 자체 필드(제목/유형/상태/요청자/생성일/예상완료일)만 사용하므로
|
||||||
|
// 행별 상세 재조회(getDetailWithMaskOption: AppRequest + API 목록 + AGW 크로스 조회) 없이 경량 매핑한다.
|
||||||
Page<Approval> approval = approvalService.findAll(pageable, portalApprovalUISearch);
|
Page<Approval> approval = approvalService.findAll(pageable, portalApprovalUISearch);
|
||||||
return approval.map(entity -> {
|
return approval.map(entity -> {
|
||||||
try {
|
try {
|
||||||
return getDetailWithMaskOption(entity.getId(), false);
|
PortalApprovalUI ui = portalApprovalUIMapper.toVo(entity);
|
||||||
|
if (ui.getRequester() == null) {
|
||||||
|
ui.setRequester(createDeletedUserPlaceholder());
|
||||||
|
}
|
||||||
|
return ui;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("승인 상세 조회 실패 - ID: {}, 오류: {}", entity.getId(), e.getMessage());
|
logger.error("승인 목록 매핑 실패 - ID: {}, 오류: {}", entity.getId(), e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -200,6 +213,7 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
if (optRequest.isPresent()) {
|
if (optRequest.isPresent()) {
|
||||||
AppRequest appRequest = optRequest.get();
|
AppRequest appRequest = optRequest.get();
|
||||||
appApprovalUI.setRequestType(appRequest.getType());
|
appApprovalUI.setRequestType(appRequest.getType());
|
||||||
|
appApprovalUI.setGwAction(appRequest.getGwAction());
|
||||||
|
|
||||||
|
|
||||||
if (appRequest.getApiGroupList() == null || appRequest.getApiGroupList().isEmpty()) {
|
if (appRequest.getApiGroupList() == null || appRequest.getApiGroupList().isEmpty()) {
|
||||||
@@ -317,7 +331,7 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
return placeholder;
|
return placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void approve(String id, String approverId) {
|
public void approve(String id, String approverId, String gwAction) {
|
||||||
Map options = new HashMap<>();
|
Map options = new HashMap<>();
|
||||||
|
|
||||||
Approval approval = approvalService.findById(id).orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
Approval approval = approvalService.findById(id).orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
||||||
@@ -325,6 +339,18 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
throw new IllegalArgumentException("아직 승인 차례가 아닙니다.");
|
throw new IllegalArgumentException("아직 승인 차례가 아닙니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 이용해지(DELETE) 승인이면 GW 처리방식(차단/완전삭제)을 신청서에 영속화한다.
|
||||||
|
// 같은 EMS 트랜잭션이므로 최종 승인 시 listener 가 이 값을 읽고, REDEPLOY 재처리에서도 유지된다.
|
||||||
|
// 미지정(null)은 안전 기본값인 차단(BLOCK)으로 처리.
|
||||||
|
if (approval.getApprovalType().equals(ApprovalType.APP)) {
|
||||||
|
appRequestRepository.findById(approval.getTargetId()).ifPresent(request -> {
|
||||||
|
if (AppRequestType.DELETE.equals(request.getType())) {
|
||||||
|
request.setGwAction("DELETE".equalsIgnoreCase(gwAction) ? GwAction.DELETE : GwAction.BLOCK);
|
||||||
|
appRequestRepository.save(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
options.put(ApprovalConstants.APPROVER, approverId);
|
options.put(ApprovalConstants.APPROVER, approverId);
|
||||||
sendEvent(id, ApprovalEvent.APPROVE, options);
|
sendEvent(id, ApprovalEvent.APPROVE, options);
|
||||||
}
|
}
|
||||||
@@ -342,6 +368,51 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
sendEvent(id, ApprovalEvent.DENY, options);
|
sendEvent(id, ApprovalEvent.DENY, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강제 반려: 승인자 목록에 삭제된 사용자가 있어 승인 진행이 불가능한 건을
|
||||||
|
* 승인자가 아닌 관리자도 반려 처리할 수 있다. (요청됨/진행중 상태에서만 허용)
|
||||||
|
*/
|
||||||
|
public void forceReject(String id, String adminUserId, String rejectReason) {
|
||||||
|
Approval approval = approvalService.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
||||||
|
|
||||||
|
ApprovalState currentStatus = approval.getApprovalStatus();
|
||||||
|
if (!(currentStatus instanceof RequestedState) && !(currentStatus instanceof ProcessingState)) {
|
||||||
|
throw new BizException("요청됨/진행중 상태의 승인 요청만 강제 반려할 수 있습니다.");
|
||||||
|
}
|
||||||
|
boolean hasDeletedApprover = approval.getApprovers().stream()
|
||||||
|
.anyMatch(approver -> approver.getUser() == null);
|
||||||
|
if (!hasDeletedApprover) {
|
||||||
|
throw new BizException("승인자 목록에 삭제된 사용자가 있는 경우에만 강제 반려할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
approval.getApprovers().forEach(approver -> {
|
||||||
|
if (approver.getUser() == null
|
||||||
|
&& (ApproverStatus.CURRENT.equals(approver.getApproverStatus())
|
||||||
|
|| ApproverStatus.PENDING.equals(approver.getApproverStatus()))) {
|
||||||
|
approver.setApproverStatus(ApproverStatus.DENIED);
|
||||||
|
approver.setApprovedDate(LocalDateTime.now());
|
||||||
|
approver.setApprovalMessage("[관리자 강제 반려: " + adminUserId + "] " + rejectReason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
approval.setApprovalStatus(new DeniedState());
|
||||||
|
|
||||||
|
Map<String, Object> options = new HashMap<>();
|
||||||
|
options.put(ApprovalConstants.APPROVER, adminUserId);
|
||||||
|
options.put((String) ApprovalConstants.MESSAGE, rejectReason);
|
||||||
|
options.put("authorizer", portalApprovalAuthorizer);
|
||||||
|
if (approval.getApprovalType().equals(ApprovalType.APP)) {
|
||||||
|
options.put("listener", portalAppApprovalListener);
|
||||||
|
} else if (approval.getApprovalType().equals(ApprovalType.USER)) {
|
||||||
|
options.put("listener", portalUserApprovalListener);
|
||||||
|
}
|
||||||
|
// DENY 이벤트는 authorizer 검증에 걸리므로 상태를 직접 DENIED 로 만든 뒤 END 로 롤백만 수행
|
||||||
|
approvalStateMachine.transition(approval, ApprovalEvent.END, options);
|
||||||
|
|
||||||
|
approvalService.save(approval);
|
||||||
|
logger.info("Approval {} force-rejected by admin {} (deleted approver present)", id, adminUserId);
|
||||||
|
}
|
||||||
|
|
||||||
public void update(String id, String expectEndDate) {
|
public void update(String id, String expectEndDate) {
|
||||||
Approval approval = approvalService.findById(id)
|
Approval approval = approvalService.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
||||||
@@ -385,6 +456,45 @@ public class PortalApprovalManService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삭제된 대상 사용자 또는 삭제된 법인이 포함되어 승인·반려가 불가능한 승인 요청을 물리 삭제한다.
|
||||||
|
* 사용자(PortalUser)/법인(PortalOrg) row 는 건드리지 않고 PTL_APPROVER/PTL_APPROVAL row 만 제거한다.
|
||||||
|
* 임의 삭제 방지를 위해 실제로 삭제된 사용자/법인이 포함된 USER 유형 요청만 허용한다.
|
||||||
|
*/
|
||||||
|
public void deleteApprovalRequest(String id) {
|
||||||
|
Approval approval = approvalService.findById(id)
|
||||||
|
.orElseThrow(() -> new BizException("승인 정보를 찾을 수 없습니다. ID: " + id));
|
||||||
|
if (!approval.getApprovalType().equals(ApprovalType.USER)) {
|
||||||
|
throw new BizException("법인사용자 승인 요청만 삭제할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> optUser = portalUserRepository.findById(approval.getTargetId());
|
||||||
|
boolean userDeleted = !optUser.isPresent()
|
||||||
|
|| "반려된 사용자".equals(optUser.get().getUserName())
|
||||||
|
|| "삭제된 사용자".equals(optUser.get().getUserName());
|
||||||
|
boolean orgDeleted = false;
|
||||||
|
if (optUser.isPresent()) {
|
||||||
|
PortalOrg org = optUser.get().getPortalOrg();
|
||||||
|
orgDeleted = org == null
|
||||||
|
|| OrgStatus.REMOVED.equals(org.getOrgStatus())
|
||||||
|
|| "삭제된법인".equals(org.getOrgName());
|
||||||
|
}
|
||||||
|
if (!(userDeleted || orgDeleted)) {
|
||||||
|
throw new BizException("삭제된 사용자 또는 법인이 포함된 요청만 삭제할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
logger.info("Approval {} deleted (deleted target user/org present)", id);
|
||||||
|
}
|
||||||
|
|
||||||
public void sendEvent(String id, ApprovalEvent event, Map<String, Object> options) {
|
public void sendEvent(String id, ApprovalEvent event, Map<String, Object> options) {
|
||||||
approvalService.findById(id).ifPresent(approval -> {
|
approvalService.findById(id).ifPresent(approval -> {
|
||||||
options.put("authorizer", portalApprovalAuthorizer);
|
options.put("authorizer", portalApprovalAuthorizer);
|
||||||
|
|||||||
+36
-4
@@ -2,6 +2,7 @@ package com.eactive.eai.rms.onl.apim.approval.app;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.GwAction;
|
||||||
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||||
import com.eactive.apim.portal.approval.entity.Approval;
|
import com.eactive.apim.portal.approval.entity.Approval;
|
||||||
import com.eactive.apim.portal.approval.service.ApprovalDeployException;
|
import com.eactive.apim.portal.approval.service.ApprovalDeployException;
|
||||||
@@ -72,7 +73,7 @@ public class PortalAppApprovalListener implements ApprovalListener {
|
|||||||
|
|
||||||
// 위 메서드가 반환되면 트랜잭션이 커밋되어 Portal DB에 영구 저장됨
|
// 위 메서드가 반환되면 트랜잭션이 커밋되어 Portal DB에 영구 저장됨
|
||||||
// 이제 APIGW 동기화 및 GW 리로드 수행
|
// 이제 APIGW 동기화 및 GW 리로드 수행
|
||||||
syncTargetServer(type, credentialUI);
|
syncTargetServer(appRequest, credentialUI);
|
||||||
sendApprovalResult(appRequest);
|
sendApprovalResult(appRequest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,12 @@ public class PortalAppApprovalListener implements ApprovalListener {
|
|||||||
} else if (type.equals(AppRequestType.MODIFY)) {
|
} else if (type.equals(AppRequestType.MODIFY)) {
|
||||||
credentialUI = modifyClientUI(appRequest);
|
credentialUI = modifyClientUI(appRequest);
|
||||||
} else if (type.equals(AppRequestType.DELETE)) {
|
} else if (type.equals(AppRequestType.DELETE)) {
|
||||||
credentialManService.delete(appRequest.getClientId());
|
// REDEPLOY 재처리 시 credential 이 이미 삭제되어 있을 수 있으므로 부재는 정상 통과시킨다.
|
||||||
|
try {
|
||||||
|
credentialManService.delete(appRequest.getClientId());
|
||||||
|
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
|
||||||
|
logger.warn("포털 credential 이미 삭제됨 (재처리 통과) - clientId: {}", appRequest.getClientId());
|
||||||
|
}
|
||||||
credentialUI = new CredentialUI();
|
credentialUI = new CredentialUI();
|
||||||
credentialUI.setClientid(appRequest.getClientId());
|
credentialUI.setClientid(appRequest.getClientId());
|
||||||
}
|
}
|
||||||
@@ -119,7 +125,9 @@ public class PortalAppApprovalListener implements ApprovalListener {
|
|||||||
messageSendService.sendMessage(MessageCode.APP_APPROVE, recipient, params);
|
messageSendService.sendMessage(MessageCode.APP_APPROVE, recipient, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void syncTargetServer(AppRequestType action, CredentialUI credentialUI) throws ApprovalDeployException {
|
private void syncTargetServer(AppRequest appRequest, CredentialUI credentialUI) throws ApprovalDeployException {
|
||||||
|
|
||||||
|
AppRequestType action = appRequest.getType();
|
||||||
|
|
||||||
DataSourceType type = DataSourceTypeManager.getDataSourceType("APIGW");
|
DataSourceType type = DataSourceTypeManager.getDataSourceType("APIGW");
|
||||||
DataSourceContextHolder.setDataSourceType(type);
|
DataSourceContextHolder.setDataSourceType(type);
|
||||||
@@ -129,7 +137,7 @@ public class PortalAppApprovalListener implements ApprovalListener {
|
|||||||
} else if (action.equals(AppRequestType.MODIFY)) {
|
} else if (action.equals(AppRequestType.MODIFY)) {
|
||||||
clientManService.update(credentialUIMapper.mapClient(credentialUI));
|
clientManService.update(credentialUIMapper.mapClient(credentialUI));
|
||||||
} else if (action.equals(AppRequestType.DELETE)) {
|
} else if (action.equals(AppRequestType.DELETE)) {
|
||||||
clientManService.delete(credentialUI.getClientid());
|
applyGwActionForDelete(appRequest.getGwAction(), credentialUI.getClientid());
|
||||||
}
|
}
|
||||||
|
|
||||||
CommonCommand.builder()
|
CommonCommand.builder()
|
||||||
@@ -138,6 +146,30 @@ public class PortalAppApprovalListener implements ApprovalListener {
|
|||||||
.build().broadcast(agentUtilService);
|
.build().broadcast(agentUtilService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 이용해지 승인 시 GW 클라이언트(TSEAIAU01) 처리.
|
||||||
|
* 승인 시점에 관리자가 선택해 PTL_APP_REQUEST.GW_ACTION 에 영속화된 값을 따른다.
|
||||||
|
* - DELETE: 행 완전 삭제 / - BLOCK 또는 null(미지정): 차단(appstatus='0')
|
||||||
|
* GW 에 행이 이미 없으면(선삭제·재처리) 목적이 달성된 상태이므로 통과시키고,
|
||||||
|
* 그 외 오류는 ApprovalDeployException 으로 던져 FAILED(재처리 가능) 상태를 만든다.
|
||||||
|
*/
|
||||||
|
private void applyGwActionForDelete(GwAction gwAction, String clientId) throws ApprovalDeployException {
|
||||||
|
try {
|
||||||
|
if (GwAction.DELETE.equals(gwAction)) {
|
||||||
|
clientManService.delete(clientId);
|
||||||
|
logger.info("GW 클라이언트 완전 삭제 - clientId: {}", clientId);
|
||||||
|
} else {
|
||||||
|
clientManService.blockClient(clientId);
|
||||||
|
logger.info("GW 클라이언트 차단(appstatus=0) - clientId: {}", clientId);
|
||||||
|
}
|
||||||
|
} catch (org.springframework.dao.EmptyResultDataAccessException | javax.persistence.EntityNotFoundException e) {
|
||||||
|
logger.warn("GW 클라이언트가 이미 존재하지 않아 처리 생략 (재처리 통과) - clientId: {}", clientId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("GW 클라이언트 해지 처리 실패 - clientId: {}, gwAction: {}", clientId, gwAction, e);
|
||||||
|
throw new ApprovalDeployException("GW 클라이언트 해지 처리에 실패했습니다: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CredentialUI modifyClientUI(AppRequest appRequest) {
|
private CredentialUI modifyClientUI(AppRequest appRequest) {
|
||||||
CredentialUI existingCredential = credentialManService.selectDetail(appRequest.getClientId());
|
CredentialUI existingCredential = credentialManService.selectDetail(appRequest.getClientId());
|
||||||
existingCredential.getApiList().clear();
|
existingCredential.getApiList().clear();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.eai.rms.onl.apim.approval.app;
|
package com.eactive.eai.rms.onl.apim.approval.app;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.GwAction;
|
||||||
import com.eactive.eai.rms.onl.apim.apigroup.ui.ApiGroupUI;
|
import com.eactive.eai.rms.onl.apim.apigroup.ui.ApiGroupUI;
|
||||||
import com.eactive.eai.rms.onl.apim.approval.PortalApprovalUI;
|
import com.eactive.eai.rms.onl.apim.approval.PortalApprovalUI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -26,4 +27,7 @@ public class PortalAppApprovalUI extends PortalApprovalUI {
|
|||||||
private List<String> prevApiGroupNameList = new ArrayList<>();
|
private List<String> prevApiGroupNameList = new ArrayList<>();
|
||||||
|
|
||||||
private AppRequestType requestType;
|
private AppRequestType requestType;
|
||||||
|
|
||||||
|
//DELETE 승인 시 선택된 GW 처리방식 (승인 완료 건 상세 표시용)
|
||||||
|
private GwAction gwAction;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -82,9 +82,9 @@ public class MessageRequestManService extends BaseService {
|
|||||||
return t == null ? "" : t.format(f);
|
return t == null ? "" : t.format(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 상태 검색 콤보 (PENDING/SENT/FAILED). */
|
/** 상태 검색 콤보 (PENDING/SENT/FAILED/SKIPPED). SKIPPED 는 행번 아닌 ID 로 발송을 건너뛴 건. */
|
||||||
public List<ComboVo> statusCombo() {
|
public List<ComboVo> statusCombo() {
|
||||||
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED"})
|
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED", "SKIPPED"})
|
||||||
.map(s -> {
|
.map(s -> {
|
||||||
ComboVo v = new ComboVo();
|
ComboVo v = new ComboVo();
|
||||||
v.setCode(s);
|
v.setCode(s);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalmenu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import org.apache.log4j.Logger;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eapim-portal 메뉴 캐시 리로드 클라이언트 (admin → portal 내부 호출).
|
||||||
|
*
|
||||||
|
* <p>호출 URL 은 PTL_PROPERTY {@code Portal / portal.internal.menu-reload-url} 로 관리한다
|
||||||
|
* (기본 {@code http://127.0.0.1:39130/internal/menu/reload}).
|
||||||
|
* portal 측은 {@code menu.internal.allow-ips} 허용 IP 목록으로 요청을 검증한다.
|
||||||
|
* 오류는 결과 Map 으로 감싸 화면에 사유를 표시한다 — 예외를 전파하지 않는다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PortalMenuCacheClient {
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(PortalMenuCacheClient.class);
|
||||||
|
|
||||||
|
static final String PROP_GROUP = "Portal";
|
||||||
|
static final String PROP_RELOAD_URL = "portal.internal.menu-reload-url";
|
||||||
|
static final String DEFAULT_RELOAD_URL = "http://127.0.0.1:39130/internal/menu/reload";
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public PortalMenuCacheClient(PortalPropertyService portalPropertyService, RestTemplate restTemplate) {
|
||||||
|
this.portalPropertyService = portalPropertyService;
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code {success, message, itemCount?, reloadedAt?}}
|
||||||
|
*/
|
||||||
|
public Map<String, Object> reload() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
String url = resolveUrl();
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
|
||||||
|
Map<String, Object> body = response.getBody();
|
||||||
|
if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) {
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("message", "포탈 메뉴 캐시 리로드 완료");
|
||||||
|
result.put("itemCount", body.get("itemCount"));
|
||||||
|
result.put("reloadedAt", body.get("reloadedAt"));
|
||||||
|
} else {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "포탈 응답 오류: " + (body == null ? response.getStatusCode() : body));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "포탈 호출 실패(" + url + "): " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveUrl() {
|
||||||
|
try {
|
||||||
|
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_RELOAD_URL,
|
||||||
|
DEFAULT_RELOAD_URL, "eapim-portal 메뉴 캐시 리로드 내부 URL");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("리로드 URL 조회 실패 - 기본값 사용: " + DEFAULT_RELOAD_URL, e);
|
||||||
|
return DEFAULT_RELOAD_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalmenu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.menu.entity.PortalRole;
|
||||||
|
import com.eactive.eai.rms.common.base.BaseAnnotationController;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포탈메뉴관리 (파트너포탈 > 포탈관리 > 메뉴 관리).
|
||||||
|
*
|
||||||
|
* <p>cmd 규약: TRANSACTION_ 계열과 INSERT/UPDATE/DELETE/INITIALIZE 는
|
||||||
|
* AuthorizeInterceptor 가 W 권한을 자동 요구한다.</p>
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalMenuManController extends BaseAnnotationController {
|
||||||
|
|
||||||
|
private final PortalMenuManService portalMenuManService;
|
||||||
|
private final PortalMenuCacheClient portalMenuCacheClient;
|
||||||
|
|
||||||
|
@GetMapping(value = "/onl/apim/portalmenu/portalMenuMan.view")
|
||||||
|
public void view() {
|
||||||
|
// view
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 항목(+배치) + 역할 사전 일괄 조회 */
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=LIST_ALL")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<Map<String, Object>> listAll() {
|
||||||
|
List<PortalMenuUI> items = portalMenuManService.selectAll();
|
||||||
|
List<PortalRole> roles = portalMenuManService.selectRoles();
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("items", items);
|
||||||
|
body.put("roles", roles);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=INSERT")
|
||||||
|
public ResponseEntity<String> insert(PortalMenuUI portalMenuUI) {
|
||||||
|
portalMenuManService.insert(portalMenuUI);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=UPDATE")
|
||||||
|
public ResponseEntity<String> update(PortalMenuUI portalMenuUI) {
|
||||||
|
portalMenuManService.update(portalMenuUI);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기본 항목은 미배치 전환, 커스텀 항목은 하드 삭제 */
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=DELETE")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(String menuId) {
|
||||||
|
boolean removed = portalMenuManService.delete(menuId);
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("removed", removed);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 배치 일괄 저장 (JSON body: PortalMenuUI[{menuId,parentId,sortOrder,visibleYn}]) */
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=TRANSACTION_PLACEMENT")
|
||||||
|
public ResponseEntity<String> savePlacements(@RequestBody List<PortalMenuUI> placements) {
|
||||||
|
portalMenuManService.savePlacements(placements);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메뉴 초기화 — eapim-portal 시딩 기본값으로 복원 */
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=INITIALIZE")
|
||||||
|
public ResponseEntity<String> initialize() {
|
||||||
|
portalMenuManService.initialize();
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 포탈 메뉴 캐시 리로드 (TRANSACTION_ prefix → W 권한 요구) */
|
||||||
|
@PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=TRANSACTION_RELOAD")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<Map<String, Object>> reloadPortalCache() {
|
||||||
|
return ResponseEntity.ok(portalMenuCacheClient.reload());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalmenu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||||
|
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
|
||||||
|
import com.eactive.apim.portal.menu.entity.PortalRole;
|
||||||
|
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
|
||||||
|
import com.eactive.apim.portal.menu.repository.PortalRoleRepository;
|
||||||
|
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
|
||||||
|
import com.eactive.eai.rms.common.base.BaseService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포탈메뉴관리 오케스트레이션.
|
||||||
|
*
|
||||||
|
* <p>가드:
|
||||||
|
* <ul>
|
||||||
|
* <li>PORTAL(기본) 항목 — 삭제 시 미배치 전환만, 식별자/구조 필드(group/section) 수정 금지</li>
|
||||||
|
* <li>ADMIN(커스텀) 항목 — 삭제 시 하드 삭제</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(transactionManager = "transactionManagerForEMS")
|
||||||
|
public class PortalMenuManService extends BaseService {
|
||||||
|
|
||||||
|
private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$");
|
||||||
|
|
||||||
|
private final PortalMenuDataService portalMenuDataService;
|
||||||
|
private final PortalMenuItemRepository menuItemRepository;
|
||||||
|
private final PortalRoleRepository roleRepository;
|
||||||
|
private final PortalMenuUIMapper portalMenuUIMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public PortalMenuManService(PortalMenuDataService portalMenuDataService,
|
||||||
|
PortalMenuItemRepository menuItemRepository,
|
||||||
|
PortalRoleRepository roleRepository,
|
||||||
|
PortalMenuUIMapper portalMenuUIMapper) {
|
||||||
|
this.portalMenuDataService = portalMenuDataService;
|
||||||
|
this.menuItemRepository = menuItemRepository;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.portalMenuUIMapper = portalMenuUIMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전체 항목(+배치 병합) 목록 */
|
||||||
|
public List<PortalMenuUI> selectAll() {
|
||||||
|
Map<String, PortalMenuPlacement> placementsById = portalMenuDataService.loadAllPlacements().stream()
|
||||||
|
.collect(Collectors.toMap(PortalMenuPlacement::getMenuId, placement -> placement));
|
||||||
|
|
||||||
|
return portalMenuDataService.loadAllItems().stream()
|
||||||
|
.map(item -> {
|
||||||
|
PortalMenuUI ui = portalMenuUIMapper.toVo(item);
|
||||||
|
PortalMenuPlacement placement = placementsById.get(item.getMenuId());
|
||||||
|
if (placement != null) {
|
||||||
|
ui.setPlaced(true);
|
||||||
|
ui.setParentId(placement.getParentId());
|
||||||
|
ui.setSortOrder(placement.getSortOrder());
|
||||||
|
ui.setVisibleYn(placement.getVisibleYn());
|
||||||
|
} else {
|
||||||
|
ui.setPlaced(false);
|
||||||
|
ui.setVisibleYn("Y");
|
||||||
|
}
|
||||||
|
return ui;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 역할 사전 (권한 선택 체크박스 소스) */
|
||||||
|
public List<PortalRole> selectRoles() {
|
||||||
|
return roleRepository.findAllByOrderBySortOrderAsc();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(PortalMenuUI ui) {
|
||||||
|
String menuId = ui.getMenuId() == null ? "" : ui.getMenuId().trim();
|
||||||
|
if (!MENU_ID_PATTERN.matcher(menuId).matches()) {
|
||||||
|
throw new IllegalArgumentException("메뉴 ID 는 kebab-case(소문자/숫자/하이픈)만 허용합니다: " + menuId);
|
||||||
|
}
|
||||||
|
if (menuItemRepository.findById(menuId).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("이미 존재하는 메뉴 ID 입니다: " + menuId);
|
||||||
|
}
|
||||||
|
PortalMenuItem item = new PortalMenuItem();
|
||||||
|
item.setMenuId(menuId);
|
||||||
|
item.setSourceType(PortalMenuItem.SOURCE_ADMIN);
|
||||||
|
applyEditableFields(item, ui, true);
|
||||||
|
menuItemRepository.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(PortalMenuUI ui) {
|
||||||
|
PortalMenuItem item = menuItemRepository.findById(ui.getMenuId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴: " + ui.getMenuId()));
|
||||||
|
applyEditableFields(item, ui, !item.isPortalSource());
|
||||||
|
menuItemRepository.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 수정 가능 필드 반영. 구조 필드(group/section)는 커스텀 항목만 수정 허용
|
||||||
|
* (기본 항목의 구조는 menu.yml 소유).
|
||||||
|
*/
|
||||||
|
private void applyEditableFields(PortalMenuItem item, PortalMenuUI ui, boolean allowStructure) {
|
||||||
|
item.setMenuName(required(ui.getMenuName(), "노출명"));
|
||||||
|
item.setMenuPath(trimToNull(ui.getMenuPath()));
|
||||||
|
item.setExposeRoles(trimToNull(ui.getExposeRoles()));
|
||||||
|
item.setAccessRoles(trimToNull(ui.getAccessRoles()));
|
||||||
|
item.setIconClass(trimToNull(ui.getIconClass()));
|
||||||
|
item.setNewWindowYn("Y".equals(ui.getNewWindowYn()) ? "Y" : "N");
|
||||||
|
if (allowStructure) {
|
||||||
|
item.setGroupYn("Y".equals(ui.getGroupYn()) ? "Y" : "N");
|
||||||
|
item.setMenuSection(PortalMenuItem.SECTION_MYPAGE.equals(ui.getMenuSection())
|
||||||
|
? PortalMenuItem.SECTION_MYPAGE : PortalMenuItem.SECTION_GNB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true 이면 항목까지 삭제(커스텀), false 이면 미배치 전환만(기본) */
|
||||||
|
public boolean delete(String menuId) {
|
||||||
|
return portalMenuDataService.deleteMenu(menuId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 배치 일괄 저장 (replace-all) */
|
||||||
|
public void savePlacements(List<PortalMenuUI> placements) {
|
||||||
|
Set<String> knownIds = portalMenuDataService.loadAllItems().stream()
|
||||||
|
.map(PortalMenuItem::getMenuId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
|
||||||
|
List<PortalMenuPlacement> rows = new ArrayList<>();
|
||||||
|
for (PortalMenuUI ui : placements) {
|
||||||
|
if (!knownIds.contains(ui.getMenuId())) {
|
||||||
|
throw new IllegalArgumentException("존재하지 않는 메뉴가 배치에 포함되어 있습니다: " + ui.getMenuId());
|
||||||
|
}
|
||||||
|
if (!seen.add(ui.getMenuId())) {
|
||||||
|
throw new IllegalArgumentException("배치에 중복된 메뉴가 있습니다: " + ui.getMenuId());
|
||||||
|
}
|
||||||
|
PortalMenuPlacement row = new PortalMenuPlacement();
|
||||||
|
row.setMenuId(ui.getMenuId());
|
||||||
|
row.setParentId(trimToNull(ui.getParentId()));
|
||||||
|
row.setSortOrder(ui.getSortOrder() == null ? 0 : ui.getSortOrder());
|
||||||
|
row.setVisibleYn("N".equals(ui.getVisibleYn()) ? "N" : "Y");
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
portalMenuDataService.replacePlacements(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메뉴 초기화 — eapim-portal 시딩 기본값으로 복원 */
|
||||||
|
public void initialize() {
|
||||||
|
portalMenuDataService.resetToDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String label) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(label + " 은(는) 필수입니다.");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalmenu;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포탈메뉴관리 화면 UI 모델 — PTL_MENU_ITEM + PTL_MENU_PLACEMENT 병합 뷰.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalMenuUI {
|
||||||
|
|
||||||
|
private String menuId;
|
||||||
|
private String menuName;
|
||||||
|
private String menuPath;
|
||||||
|
private String groupYn;
|
||||||
|
private String menuSection;
|
||||||
|
private String newWindowYn;
|
||||||
|
private String iconClass;
|
||||||
|
private String exposeRoles;
|
||||||
|
private String accessRoles;
|
||||||
|
private String sourceType;
|
||||||
|
|
||||||
|
private String dfltMenuName;
|
||||||
|
private String dfltMenuPath;
|
||||||
|
private String dfltExposeRoles;
|
||||||
|
private String dfltAccessRoles;
|
||||||
|
private String dfltParentId;
|
||||||
|
private Integer dfltSortOrder;
|
||||||
|
|
||||||
|
// 배치(placement) 병합 필드 — 미배치면 placed=false
|
||||||
|
private boolean placed;
|
||||||
|
private String parentId;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private String visibleYn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalmenu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
|
import org.mapstruct.MappingTarget;
|
||||||
|
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
|
public interface PortalMenuUIMapper {
|
||||||
|
|
||||||
|
PortalMenuUI toVo(PortalMenuItem entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 화면 반영 — 식별자/출처/기본값(DFLT_*)/구조 필드는 서비스에서 별도 통제.
|
||||||
|
*/
|
||||||
|
@Mapping(target = "menuId", ignore = true)
|
||||||
|
@Mapping(target = "sourceType", ignore = true)
|
||||||
|
@Mapping(target = "dfltMenuName", ignore = true)
|
||||||
|
@Mapping(target = "dfltMenuPath", ignore = true)
|
||||||
|
@Mapping(target = "dfltExposeRoles", ignore = true)
|
||||||
|
@Mapping(target = "dfltAccessRoles", ignore = true)
|
||||||
|
@Mapping(target = "dfltParentId", ignore = true)
|
||||||
|
@Mapping(target = "dfltSortOrder", ignore = true)
|
||||||
|
@Mapping(target = "dfltVisibleYn", ignore = true)
|
||||||
|
@Mapping(target = "createdBy", ignore = true)
|
||||||
|
@Mapping(target = "createdDate", ignore = true)
|
||||||
|
@Mapping(target = "lastModifiedBy", ignore = true)
|
||||||
|
@Mapping(target = "lastModifiedDate", ignore = true)
|
||||||
|
void updateToEntity(PortalMenuUI ui, @MappingTarget PortalMenuItem entity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.eactive.eai.rms.onl.apim.portalnotice;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장애 처리 타임라인 (DJB_APISTATUS_INCIDENT_TIMELINE) 화면 표시용 VO.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class IncidentTimelineUI {
|
||||||
|
|
||||||
|
private Long timelineId;
|
||||||
|
|
||||||
|
private Long incidentId;
|
||||||
|
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
|
||||||
|
private LocalDateTime eventAt;
|
||||||
|
|
||||||
|
/** 상태 변경이 있었던 항목만 값이 있다. (INVESTIGATING/IDENTIFIED/MONITORING/RESOLVED/CANCELED) */
|
||||||
|
private String stateAfter;
|
||||||
|
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
/** SYSTEM(자동 탐지) / OPERATOR(관리자 입력) */
|
||||||
|
private String authorType;
|
||||||
|
|
||||||
|
/** 개발자포탈 공개 여부 */
|
||||||
|
private String visibleYn;
|
||||||
|
}
|
||||||
+25
@@ -15,6 +15,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -86,4 +87,28 @@ public class PortalNoticeManController extends BaseAnnotationController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_LIST")
|
||||||
|
public ResponseEntity<List<IncidentTimelineUI>> selectTimeline(Long incidentId) {
|
||||||
|
return ResponseEntity.ok(portalNoticeManService.selectTimeline(incidentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_ADD")
|
||||||
|
public ResponseEntity<PortalNoticeUI> appendTimeline(Long incidentId, String body,
|
||||||
|
@RequestParam(defaultValue = "false") boolean stateChange,
|
||||||
|
String nextState) {
|
||||||
|
return ResponseEntity.ok(portalNoticeManService.appendTimeline(incidentId, body, stateChange, nextState));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_VISIBLE")
|
||||||
|
public ResponseEntity<String> updateTimelineVisible(Long timelineId, String visibleYn) {
|
||||||
|
portalNoticeManService.updateTimelineVisible(timelineId, visibleYn);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_DELETE")
|
||||||
|
public ResponseEntity<String> deleteTimeline(Long timelineId) {
|
||||||
|
portalNoticeManService.deleteTimeline(timelineId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-8
@@ -3,6 +3,7 @@ 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.DjbApistatusIncident;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
|
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.DjbApistatusIncidentApiId;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
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.entity.IncidentState;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||||
@@ -36,6 +37,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -90,12 +92,40 @@ public class PortalNoticeManService extends BaseService {
|
|||||||
|
|
||||||
|
|
||||||
public Page<PortalNoticeUI> selectList(Pageable pageable, PortalNoticeUISearch portalNoticeUISearch) {
|
public Page<PortalNoticeUI> selectList(Pageable pageable, PortalNoticeUISearch portalNoticeUISearch) {
|
||||||
return portalNoticeService.findAll(pageable, portalNoticeUISearch)
|
Page<PortalNoticeUI> page = portalNoticeService.findAll(pageable, portalNoticeUISearch)
|
||||||
.map(portalNotice -> {
|
.map(portalNotice -> {
|
||||||
PortalNoticeUI ui = portalNoticeUIMapper.toVo(portalNotice);
|
PortalNoticeUI ui = portalNoticeUIMapper.toVo(portalNotice);
|
||||||
setFileInfo(portalNotice, ui);
|
setFileInfo(portalNotice, ui);
|
||||||
return ui;
|
return ui;
|
||||||
});
|
});
|
||||||
|
populateIncidentSummary(page.getContent());
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목록에서 장애 진행 여부(종료 시각 미입력)를 표기하기 위해 incident 정보를 한 번에 붙인다.
|
||||||
|
*/
|
||||||
|
private void populateIncidentSummary(List<PortalNoticeUI> rows) {
|
||||||
|
List<String> noticeIds = rows.stream()
|
||||||
|
.filter(ui -> isIncidentKind(ui.getNoticeType()))
|
||||||
|
.map(PortalNoticeUI::getId)
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (noticeIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, DjbApistatusIncident> byNoticeId = incidentRepository.findByNoticeIdIn(noticeIds).stream()
|
||||||
|
.collect(Collectors.toMap(DjbApistatusIncident::getNoticeId, incident -> incident, (a, b) -> a));
|
||||||
|
|
||||||
|
for (PortalNoticeUI ui : rows) {
|
||||||
|
DjbApistatusIncident incident = byNoticeId.get(ui.getId());
|
||||||
|
if (incident == null) continue;
|
||||||
|
ui.setIncidentId(incident.getIncidentId());
|
||||||
|
ui.setStartedAt(incident.getStartedAt());
|
||||||
|
ui.setEndAt(incident.getEndAt());
|
||||||
|
ui.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setFileInfo(PortalNotice portalNotice, PortalNoticeUI ui) {
|
private void setFileInfo(PortalNotice portalNotice, PortalNoticeUI ui) {
|
||||||
@@ -172,11 +202,17 @@ public class PortalNoticeManService extends BaseService {
|
|||||||
portalNoticeService.save(portalNotice);
|
portalNoticeService.save(portalNotice);
|
||||||
|
|
||||||
if (isIncidentKind(portalNoticeUI.getNoticeType())) {
|
if (isIncidentKind(portalNoticeUI.getNoticeType())) {
|
||||||
persistIncident(portalNoticeUI, portalNotice, true);
|
DjbApistatusIncident incident = persistIncident(portalNoticeUI, portalNotice, true);
|
||||||
|
if (incident.getKind() == IncidentKind.INCIDENT) {
|
||||||
|
// 최초 발생 항목을 남겨야 개발자포탈 장애 카드가 자동 탐지 건과 같은 모양으로 보인다.
|
||||||
|
String body = StringUtils.defaultIfBlank(incident.getSummary(), incident.getTitle());
|
||||||
|
writeTimeline(incident.getIncidentId(), incident.getStartedAt(),
|
||||||
|
IncidentState.INVESTIGATING, body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistIncident(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice, boolean isCreate) {
|
private DjbApistatusIncident persistIncident(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice, boolean isCreate) {
|
||||||
IncidentKind kind = toKind(portalNoticeUI.getNoticeType());
|
IncidentKind kind = toKind(portalNoticeUI.getNoticeType());
|
||||||
|
|
||||||
DjbApistatusIncident incident = incidentRepository.findByNoticeId(portalNotice.getId())
|
DjbApistatusIncident incident = incidentRepository.findByNoticeId(portalNotice.getId())
|
||||||
@@ -185,6 +221,8 @@ public class PortalNoticeManService extends BaseService {
|
|||||||
incident.setKind(kind);
|
incident.setKind(kind);
|
||||||
incident.setTitle(portalNotice.getNoticeSubject());
|
incident.setTitle(portalNotice.getNoticeSubject());
|
||||||
incident.setSummary(portalNoticeUI.getSummary());
|
incident.setSummary(portalNoticeUI.getSummary());
|
||||||
|
IncidentState stateBefore = incident.getState();
|
||||||
|
LocalDateTime endAtBefore = incident.getEndAt();
|
||||||
incident.setStartedAt(portalNoticeUI.getStartedAt() != null
|
incident.setStartedAt(portalNoticeUI.getStartedAt() != null
|
||||||
? portalNoticeUI.getStartedAt() : LocalDateTime.now());
|
? portalNoticeUI.getStartedAt() : LocalDateTime.now());
|
||||||
incident.setEndAt(portalNoticeUI.getEndAt());
|
incident.setEndAt(portalNoticeUI.getEndAt());
|
||||||
@@ -194,18 +232,43 @@ public class PortalNoticeManService extends BaseService {
|
|||||||
incident.setFixYn(StringUtils.defaultIfBlank(portalNotice.getFixYn(), "N"));
|
incident.setFixYn(StringUtils.defaultIfBlank(portalNotice.getFixYn(), "N"));
|
||||||
|
|
||||||
if (kind == IncidentKind.INCIDENT) {
|
if (kind == IncidentKind.INCIDENT) {
|
||||||
IncidentState newState = StringUtils.isNotBlank(portalNoticeUI.getState())
|
// 상태는 타임라인 게시로만 전이한다(화면에서 select 제거). 폼에 값이 없으면 현재 상태를 보존해야
|
||||||
? IncidentState.valueOf(portalNoticeUI.getState())
|
// 저장할 때마다 INVESTIGATING 으로 되돌아가는 회귀가 생기지 않는다.
|
||||||
: IncidentState.INVESTIGATING;
|
IncidentState newState;
|
||||||
if (!isCreate && incident.getState() != newState) {
|
if (StringUtils.isNotBlank(portalNoticeUI.getState())) {
|
||||||
incident.setPreviousState(incident.getState());
|
newState = IncidentState.valueOf(portalNoticeUI.getState());
|
||||||
|
} else {
|
||||||
|
newState = stateBefore != null ? stateBefore : IncidentState.INVESTIGATING;
|
||||||
|
}
|
||||||
|
// 종결된 장애는 되돌릴 수 없다 — 재발은 새 장애로 등록한다 (appendTimeline 과 같은 규칙)
|
||||||
|
if (!isCreate && isTerminal(stateBefore) && newState != stateBefore) {
|
||||||
|
throw new IllegalArgumentException("이미 종결된 장애입니다. 상태를 변경할 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (!isCreate && stateBefore != newState) {
|
||||||
|
incident.setPreviousState(stateBefore);
|
||||||
}
|
}
|
||||||
incident.setState(newState);
|
incident.setState(newState);
|
||||||
|
|
||||||
|
// 종료 시각은 상태를 따른다. 비종결인데 종료 시각이 남으면 개발자포탈에서
|
||||||
|
// '종료 시각이 있는데 진행 중'인 모순 카드가 된다.
|
||||||
|
// 종결된 건은 실제 복구 시각으로 손보정할 수 있어야 하므로 폼 입력을 그대로 받는다.
|
||||||
|
if (isTerminal(newState)) {
|
||||||
|
if (incident.getEndAt() == null) {
|
||||||
|
incident.setEndAt(endAtBefore != null ? endAtBefore : LocalDateTime.now());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
incident.setEndAt(null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
incident.setState(null);
|
incident.setState(null);
|
||||||
incident.setPreviousState(null);
|
incident.setPreviousState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 장애·점검 공통 — 종료가 시작보다 빠른 구간은 가동률 집계를 음수로 만든다
|
||||||
|
if (incident.getEndAt() != null && incident.getEndAt().isBefore(incident.getStartedAt())) {
|
||||||
|
throw new IllegalArgumentException("종료 시각은 시작 시각보다 빠를 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
DjbApistatusIncident saved = incidentRepository.save(incident);
|
DjbApistatusIncident saved = incidentRepository.save(incident);
|
||||||
|
|
||||||
// 영향 API 동기화 — 단순 전체 삭제 후 재삽입
|
// 영향 API 동기화 — 단순 전체 삭제 후 재삽입
|
||||||
@@ -225,6 +288,111 @@ public class PortalNoticeManService extends BaseService {
|
|||||||
incidentApiRepository.saveAll(rows);
|
incidentApiRepository.saveAll(rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── 장애 처리 타임라인 ─────────────────────
|
||||||
|
|
||||||
|
private static final String AUTHOR_OPERATOR = "OPERATOR";
|
||||||
|
|
||||||
|
/** 종결 상태 — 이후 어떤 상태로도 전이할 수 없다. */
|
||||||
|
private static boolean isTerminal(IncidentState state) {
|
||||||
|
return state == IncidentState.RESOLVED || state == IncidentState.CANCELED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncidentTimelineUI> selectTimeline(Long incidentId) {
|
||||||
|
if (incidentId == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return incidentTimelineRepository.findByIncidentIdOrderByEventAtAsc(incidentId).stream()
|
||||||
|
.map(entity -> {
|
||||||
|
IncidentTimelineUI ui = new IncidentTimelineUI();
|
||||||
|
ui.setTimelineId(entity.getTimelineId());
|
||||||
|
ui.setIncidentId(entity.getIncidentId());
|
||||||
|
ui.setEventAt(entity.getEventAt());
|
||||||
|
ui.setStateAfter(entity.getStateAfter() == null ? null : entity.getStateAfter().name());
|
||||||
|
ui.setBody(entity.getBody());
|
||||||
|
ui.setAuthorType(entity.getAuthorType());
|
||||||
|
ui.setVisibleYn(entity.getVisibleYn());
|
||||||
|
return ui;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 게시. 상태 변경 여부와 무관하게 항상 새 행을 추가한다(append-only).
|
||||||
|
* 상태 변경이 있으면 장애의 STATE/PREVIOUS_STATE 를 같은 트랜잭션에서 동기화하고,
|
||||||
|
* 종결 상태(해소/취소)로 가면 종료 시각을 자동으로 채운다.
|
||||||
|
*
|
||||||
|
* @return 갱신된 상태/이전 상태/종료 시각 (화면 갱신용 — 상세를 다시 읽지 않기 위함)
|
||||||
|
*/
|
||||||
|
public PortalNoticeUI appendTimeline(Long incidentId, String body, boolean stateChange, String nextState) {
|
||||||
|
if (StringUtils.isBlank(body)) {
|
||||||
|
throw new IllegalArgumentException("타임라인 메세지를 입력하여 주십시오.");
|
||||||
|
}
|
||||||
|
DjbApistatusIncident incident = incidentRepository.findById(incidentId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("장애 정보를 찾을 수 없습니다. incidentId=" + incidentId));
|
||||||
|
if (incident.getKind() != IncidentKind.INCIDENT) {
|
||||||
|
// 점검은 STATE/타임라인을 쓰지 않는다 (ADR-F15)
|
||||||
|
throw new IllegalArgumentException("장애 유형만 타임라인을 등록할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
IncidentState after = null;
|
||||||
|
|
||||||
|
if (stateChange) {
|
||||||
|
if (StringUtils.isBlank(nextState)) {
|
||||||
|
throw new IllegalArgumentException("변경할 상태를 선택하여 주십시오.");
|
||||||
|
}
|
||||||
|
after = IncidentState.valueOf(nextState);
|
||||||
|
// 종결된 장애는 재전이 불가 — 재발은 새 장애로 등록한다 (상태 전이 설계 가드)
|
||||||
|
if (isTerminal(incident.getState())) {
|
||||||
|
throw new IllegalArgumentException("이미 종결된 장애입니다. 상태를 변경할 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (incident.getState() == after) {
|
||||||
|
throw new IllegalArgumentException("현재와 동일한 상태로는 변경할 수 없습니다.");
|
||||||
|
}
|
||||||
|
incident.setPreviousState(incident.getState());
|
||||||
|
incident.setState(after);
|
||||||
|
if (after == IncidentState.RESOLVED || after == IncidentState.CANCELED) {
|
||||||
|
incident.setEndAt(now);
|
||||||
|
}
|
||||||
|
incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTimeline(incidentId, now, after, body);
|
||||||
|
|
||||||
|
PortalNoticeUI result = new PortalNoticeUI();
|
||||||
|
result.setIncidentId(incident.getIncidentId());
|
||||||
|
result.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
|
result.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||||
|
result.setEndAt(incident.getEndAt());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateTimelineVisible(Long timelineId, String visibleYn) {
|
||||||
|
DjbApistatusIncidentTimeline timeline = incidentTimelineRepository.findById(timelineId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("타임라인 정보를 찾을 수 없습니다. timelineId=" + timelineId));
|
||||||
|
timeline.setVisibleYn("Y".equals(visibleYn) ? "Y" : "N");
|
||||||
|
incidentTimelineRepository.save(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteTimeline(Long timelineId) {
|
||||||
|
incidentTimelineRepository.deleteById(timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 컬럼(CREATED_BY/CREATED_DATE)은 EMSAuditorAware 가 채운다 — 직접 세팅하지 않는다.
|
||||||
|
*/
|
||||||
|
private void writeTimeline(Long incidentId, LocalDateTime eventAt, IncidentState stateAfter, String body) {
|
||||||
|
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
|
||||||
|
timeline.setIncidentId(incidentId);
|
||||||
|
timeline.setEventAt(eventAt != null ? eventAt : LocalDateTime.now());
|
||||||
|
timeline.setStateAfter(stateAfter);
|
||||||
|
timeline.setBody(StringUtils.abbreviate(body, 4000));
|
||||||
|
timeline.setAuthorType(AUTHOR_OPERATOR);
|
||||||
|
timeline.setVisibleYn("Y");
|
||||||
|
incidentTimelineRepository.save(timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFileUpload(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice) throws IOException {
|
private void handleFileUpload(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice) throws IOException {
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public class PortalUserManController extends BaseAnnotationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/onl/apim/portaluser/portalUserMan.view", params = "cmd=DETAIL")
|
@GetMapping(value = "/onl/apim/portaluser/portalUserMan.view", params = "cmd=DETAIL")
|
||||||
public String detailNewView() {
|
public String detailNewView(Model model) {
|
||||||
|
model.addAttribute("passwordInitSubfix", portalUserManService.getPasswordInitSubfix());
|
||||||
return "/onl/apim/portaluser/portalUserManDetail";
|
return "/onl/apim/portaluser/portalUserManDetail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserPrivacyAgreement;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserPrivacyAgreement;
|
||||||
import com.eactive.eai.common.util.SystemUtil;
|
import com.eactive.eai.common.util.SystemUtil;
|
||||||
import com.eactive.eai.rms.common.base.BaseService;
|
import com.eactive.eai.rms.common.base.BaseService;
|
||||||
|
import com.eactive.eai.rms.common.context.MonitoringContext;
|
||||||
import com.eactive.eai.rms.common.spring.LocaleMessage;
|
import com.eactive.eai.rms.common.spring.LocaleMessage;
|
||||||
import com.eactive.eai.rms.data.entity.onl.apim.approval.ApprovalService;
|
import com.eactive.eai.rms.data.entity.onl.apim.approval.ApprovalService;
|
||||||
import com.eactive.eai.rms.data.entity.onl.apim.portalinquiry.PortalInquiryService;
|
import com.eactive.eai.rms.data.entity.onl.apim.portalinquiry.PortalInquiryService;
|
||||||
@@ -61,6 +62,7 @@ public class PortalUserManService extends BaseService {
|
|||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final PortalInquiryService portalInquiryService;
|
private final PortalInquiryService portalInquiryService;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final MonitoringContext monitoringContext;
|
||||||
|
|
||||||
/** 가입자 목록 '선택 삭제'(완전삭제) 버튼 노출 여부 */
|
/** 가입자 목록 '선택 삭제'(완전삭제) 버튼 노출 여부 */
|
||||||
public boolean isHardDeleteEnabled() {
|
public boolean isHardDeleteEnabled() {
|
||||||
@@ -188,7 +190,14 @@ public class PortalUserManService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PortalUser portalUser = portalUserUIMapper.toEntity(portalUserUI);
|
PortalUser portalUser = portalUserUIMapper.toEntity(portalUserUI);
|
||||||
portalUser.setPasswordHash(kjbSafedbPasswordEncoder.encode("!" + portalUserUI.getLoginId()));
|
|
||||||
|
// 최초 아이디 생성 시 초기 패스워드(loginId + subfix)로 세팅 (비밀번호 초기화와 동일)
|
||||||
|
String subfix = getPasswordInitSubfix();
|
||||||
|
portalUser.setPasswordHash(kjbSafedbPasswordEncoder.encode(portalUserUI.getLoginId() + subfix));
|
||||||
|
|
||||||
|
// 비밀번호 변경일 초기화 (초기 비밀번호 강제 변경 유도)
|
||||||
|
portalUser.setPasswordChangeDate(null);
|
||||||
|
|
||||||
portalUser.setLoginFailureCount(0);
|
portalUser.setLoginFailureCount(0);
|
||||||
portalUser.setAccountLockYn("N");
|
portalUser.setAccountLockYn("N");
|
||||||
portalUser.setEmailAddr(portalUserUI.getLoginId());
|
portalUser.setEmailAddr(portalUserUI.getLoginId());
|
||||||
@@ -270,11 +279,20 @@ public class PortalUserManService extends BaseService {
|
|||||||
portalUserService.deleteById(id);
|
portalUserService.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 초기 패스워드 접미사(loginId + subfix). 초기화 안내 문구 노출에도 사용. */
|
||||||
|
public String getPasswordInitSubfix() {
|
||||||
|
return monitoringContext.getStringProperty(MonitoringContext.RMS_PASSWORD_INIT_SUBFIX, "@!");
|
||||||
|
}
|
||||||
|
|
||||||
public void passwordReset(String id) {
|
public void passwordReset(String id) {
|
||||||
PortalUser portalUser = portalUserService.getById(id);
|
PortalUser portalUser = portalUserService.getById(id);
|
||||||
|
|
||||||
// 패스워드를 초기 패스워드("!" + loginId)로 초기화
|
// 패스워드를 초기 패스워드(loginId + subfix)로 초기화
|
||||||
portalUser.setPasswordHash(kjbSafedbPasswordEncoder.encode("!" + portalUser.getLoginId()));
|
String subfix = getPasswordInitSubfix();
|
||||||
|
portalUser.setPasswordHash(kjbSafedbPasswordEncoder.encode(portalUser.getLoginId() + subfix));
|
||||||
|
|
||||||
|
// 비밀번호 변경일 초기화 (초기 비밀번호 강제 변경 유도)
|
||||||
|
portalUser.setPasswordChangeDate(null);
|
||||||
|
|
||||||
// 로그인 실패 횟수 초기화
|
// 로그인 실패 횟수 초기화
|
||||||
portalUser.setLoginFailureCount(0);
|
portalUser.setLoginFailureCount(0);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.net.URLDecoder;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
@@ -26,6 +27,10 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||||
|
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||||
|
import com.eactive.eai.rms.onl.transaction.apim.mapping.ApiSpecUIMapper;
|
||||||
|
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiSpecInfoUI;
|
||||||
import com.eactive.eai.agent.command.Command;
|
import com.eactive.eai.agent.command.Command;
|
||||||
import com.eactive.eai.agent.command.CommonCommand;
|
import com.eactive.eai.agent.command.CommonCommand;
|
||||||
import com.eactive.eai.common.util.DatetimeUtil;
|
import com.eactive.eai.common.util.DatetimeUtil;
|
||||||
@@ -105,6 +110,12 @@ public class LoaderController implements InterceptorSkipController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TransformDeployService transformDeployService;
|
private TransformDeployService transformDeployService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiSpecInfoService apiSpecInfoService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApiSpecUIMapper apiSpecUIMapper;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 형상관리에서 layout을 로딩한다.
|
* 형상관리에서 layout을 로딩한다.
|
||||||
@@ -1481,6 +1492,17 @@ public class LoaderController implements InterceptorSkipController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. API 스펙 정보(ptl_api_spec_info) 추가 (감사(audit) 컬럼은 제외)
|
||||||
|
Optional<ApiSpecInfo> optSpecInfo = apiSpecInfoService.findById(interfaceId);
|
||||||
|
if (optSpecInfo.isPresent()) {
|
||||||
|
ApiSpecInfoUI specInfo = apiSpecUIMapper.mapToUI(optSpecInfo.get());
|
||||||
|
specInfo.setCreatedBy(null);
|
||||||
|
specInfo.setCreatedDate(null);
|
||||||
|
specInfo.setLastModifiedBy(null);
|
||||||
|
specInfo.setLastModifiedDate(null);
|
||||||
|
interfaceDeployPack.setSpecInfo(specInfo);
|
||||||
|
}
|
||||||
|
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
objectMapper.registerModule(new JavaTimeModule());
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||||
@@ -1546,6 +1568,16 @@ public class LoaderController implements InterceptorSkipController {
|
|||||||
agentUtilService.broadcast(new CommonCommand( "com.eactive.eai.agent.transformer.ReloadTransformCommand", transformDeploy.getCnvsnname()));
|
agentUtilService.broadcast(new CommonCommand( "com.eactive.eai.agent.transformer.ReloadTransformCommand", transformDeploy.getCnvsnname()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiSpecInfoUI specInfoUI = interfaceDeployPack.getSpecInfo();
|
||||||
|
if (specInfoUI != null && StringUtils.isNotBlank(specInfoUI.getApiId())) {
|
||||||
|
ApiSpecInfo specInfo = apiSpecUIMapper.mapToEntity(specInfoUI);
|
||||||
|
if (apiSpecInfoService.findById(specInfo.getApiId()).isPresent()) {
|
||||||
|
apiSpecInfoService.updateById(specInfo.getApiId(), specInfo);
|
||||||
|
} else {
|
||||||
|
apiSpecInfoService.create(specInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Map<String,String> result = new HashMap<String,String>();
|
Map<String,String> result = new HashMap<String,String>();
|
||||||
String status = "success";
|
String status = "success";
|
||||||
result.put("status", status);
|
result.put("status", status);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.eactive.eai.rms.onl.loader.vo;
|
|||||||
import com.eactive.eai.rms.onl.loader.intf.deploy.EAIMessageDeploy;
|
import com.eactive.eai.rms.onl.loader.intf.deploy.EAIMessageDeploy;
|
||||||
import com.eactive.eai.rms.onl.loader.layout.deploy.LayoutDeploy;
|
import com.eactive.eai.rms.onl.loader.layout.deploy.LayoutDeploy;
|
||||||
import com.eactive.eai.rms.onl.loader.transform.deploy.TransformDeploy;
|
import com.eactive.eai.rms.onl.loader.transform.deploy.TransformDeploy;
|
||||||
|
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiSpecInfoUI;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -15,4 +16,6 @@ public class InterfaceDeployPack {
|
|||||||
private List<LayoutDeploy> layoutDeployList = new ArrayList<>();
|
private List<LayoutDeploy> layoutDeployList = new ArrayList<>();
|
||||||
|
|
||||||
private List<TransformDeploy> transformDeployList = new ArrayList<>();
|
private List<TransformDeploy> transformDeployList = new ArrayList<>();
|
||||||
|
|
||||||
|
private ApiSpecInfoUI specInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.eactive.eai.rms.data.entity.onl.bzwkdstcd.UserBusinessService;
|
|||||||
import com.eactive.eai.rms.data.entity.onl.layout.LayoutItemEMSRepository;
|
import com.eactive.eai.rms.data.entity.onl.layout.LayoutItemEMSRepository;
|
||||||
import com.eactive.eai.rms.data.entity.onl.layout.LayoutService;
|
import com.eactive.eai.rms.data.entity.onl.layout.LayoutService;
|
||||||
import com.eactive.eai.rms.onl.manage.rule.layoutsync.LayoutSyncVo;
|
import com.eactive.eai.rms.onl.manage.rule.layoutsync.LayoutSyncVo;
|
||||||
|
import com.eactive.eai.transformer.layout.Item;
|
||||||
|
|
||||||
@Repository("layoutDao")
|
@Repository("layoutDao")
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -122,8 +123,25 @@ public class LayoutDao extends SqlMapClientTemplateDao {
|
|||||||
entity.setLoutitempathname((String) param.get("loutItemPathName"));
|
entity.setLoutitempathname((String) param.get("loutItemPathName"));
|
||||||
entity.setLoutitemlencnt((Integer) param.get("loutItemLenCnt"));
|
entity.setLoutitemlencnt((Integer) param.get("loutItemLenCnt"));
|
||||||
entity.setLoutitemrefinfo((String) param.get("loutItemRefInfo"));
|
entity.setLoutitemrefinfo((String) param.get("loutItemRefInfo"));
|
||||||
entity.setLoutitemrefinfo2((String) param.get("loutItemRefInfo2"));
|
|
||||||
entity.setLoutitemoccurptrndstcd((String) param.get("loutItemOccurPtrnDstcd"));
|
// djb cumstom : 반복 참조 필드에 .이 포함되서 오면 .이후의 값만 저장
|
||||||
|
String loutItemRefInfo2 = (String) param.get("loutItemRefInfo2");
|
||||||
|
if (loutItemRefInfo2 != null && loutItemRefInfo2.indexOf(".") > -1) {
|
||||||
|
loutItemRefInfo2 = loutItemRefInfo2.substring(loutItemRefInfo2.lastIndexOf(".")+1);
|
||||||
|
}
|
||||||
|
entity.setLoutitemrefinfo2(loutItemRefInfo2);
|
||||||
|
|
||||||
|
// djb custom : GRIP(=GROUP)이고 반복횟수=1 일때는 반복횟수 넣지않는다. ==> GROUP으로 처리
|
||||||
|
Integer loutItemNodePtrnIDName = (Integer) param.get("loutItemNodePtrnIDName");
|
||||||
|
String loutItemOccurPtrnDstcd = (String) param.get("loutItemOccurPtrnDstcd");
|
||||||
|
if (loutItemNodePtrnIDName != null) {
|
||||||
|
if (loutItemNodePtrnIDName == Item.NODE_GROUP && "1".equals(loutItemOccurPtrnDstcd)) {
|
||||||
|
loutItemOccurPtrnDstcd = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setLoutitemoccurptrndstcd(loutItemOccurPtrnDstcd);
|
||||||
|
|
||||||
|
|
||||||
entity.setLoutitemmaxoccurnoitm((Integer) param.get("loutItemMaxOccurNoitm"));
|
entity.setLoutitemmaxoccurnoitm((Integer) param.get("loutItemMaxOccurNoitm"));
|
||||||
entity.setLoutitemminoccurnoitm((Integer) param.get("loutItemMinOccurNoitm"));
|
entity.setLoutitemminoccurnoitm((Integer) param.get("loutItemMinOccurNoitm"));
|
||||||
entity.setLoutitembascval((String) param.get("loutItemBascVal"));
|
entity.setLoutitembascval((String) param.get("loutItemBascVal"));
|
||||||
|
|||||||
+7
-4
@@ -934,9 +934,12 @@ public class LayoutSyncController extends OnlBaseAnnotationController implements
|
|||||||
|
|
||||||
service.deleteLayout(vo);
|
service.deleteLayout(vo);
|
||||||
|
|
||||||
CommonCommand commonCommand = new CommonCommand("com.eactive.eai.agent.transformer.RemoveLayoutCommand",vo.get("loutName"));
|
// CommonCommand commonCommand = new CommonCommand("com.eactive.eai.agent.transformer.RemoveLayoutCommand",vo.get("loutName"));
|
||||||
HashMap<String,String> result = AgentParserUtils.parse(commonCommand, agentUtilService,"prcssRslt","prcssRsltCmnt");
|
// HashMap<String,String> result = AgentParserUtils.parse(commonCommand, agentUtilService,"prcssRslt","prcssRsltCmnt");
|
||||||
param.putAll(result);
|
// param.putAll(result);
|
||||||
|
|
||||||
|
param.put("prcssRslt", "S");
|
||||||
|
param.put("prcssRsltCmnt", "");
|
||||||
|
|
||||||
service.addLayoutSyncLog(param);
|
service.addLayoutSyncLog(param);
|
||||||
} else {
|
} else {
|
||||||
@@ -1024,7 +1027,7 @@ public class LayoutSyncController extends OnlBaseAnnotationController implements
|
|||||||
|
|
||||||
service.insertLayout(vo, list); //FIXME : 스키마 변경 룰 적용해야 함
|
service.insertLayout(vo, list); //FIXME : 스키마 변경 룰 적용해야 함
|
||||||
|
|
||||||
//Djb custom 서버연동 제외 2026.07.22 jwh
|
//Djb custom 서버연동 제외
|
||||||
// CommonCommand commonCommand = new CommonCommand("com.eactive.eai.agent.transformer.ReloadLayoutCommand",vo.get("loutName"));
|
// CommonCommand commonCommand = new CommonCommand("com.eactive.eai.agent.transformer.ReloadLayoutCommand",vo.get("loutName"));
|
||||||
// HashMap<String,String> result = AgentParserUtils.parse(commonCommand, agentUtilService,"prcssRslt","prcssRsltCmnt");
|
// HashMap<String,String> result = AgentParserUtils.parse(commonCommand, agentUtilService,"prcssRslt","prcssRsltCmnt");
|
||||||
// param.putAll(result);
|
// param.putAll(result);
|
||||||
|
|||||||
+13
-1
@@ -25,12 +25,15 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.eactive.eai.data.entity.onl.transformer.layout.QLayoutEntity;
|
||||||
|
import com.eactive.eai.data.entity.onl.transformer.layout.QLayoutItemEntity;
|
||||||
import com.eactive.eai.rms.common.base.OnlBaseService;
|
import com.eactive.eai.rms.common.base.OnlBaseService;
|
||||||
import com.eactive.eai.rms.common.datasource.DataSourceContextHolder;
|
import com.eactive.eai.rms.common.datasource.DataSourceContextHolder;
|
||||||
import com.eactive.eai.rms.common.util.StringUtils;
|
import com.eactive.eai.rms.common.util.StringUtils;
|
||||||
import com.eactive.eai.rms.data.entity.onl.layout.LayoutService;
|
import com.eactive.eai.rms.data.entity.onl.layout.LayoutService;
|
||||||
import com.eactive.eai.rms.onl.manage.rule.layout.LayoutDao;
|
import com.eactive.eai.rms.onl.manage.rule.layout.LayoutDao;
|
||||||
import com.eactive.eai.rms.onl.manage.serviceinfo.eaimsg.EaiMsgDao;
|
import com.eactive.eai.rms.onl.manage.serviceinfo.eaimsg.EaiMsgDao;
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory;
|
||||||
|
|
||||||
@Service("layoutSyncService")
|
@Service("layoutSyncService")
|
||||||
public class LayoutSyncService extends OnlBaseService {
|
public class LayoutSyncService extends OnlBaseService {
|
||||||
@@ -75,7 +78,16 @@ public class LayoutSyncService extends OnlBaseService {
|
|||||||
|
|
||||||
public void deleteLayout(HashMap<String,Object> vo)
|
public void deleteLayout(HashMap<String,Object> vo)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
layoutService.findById(Objects.toString(vo.get("loutName"))).ifPresent(layoutService::delete);
|
//등록안된 전문유형을 가진 레이아웃 경우 에러발생하기 때문에 JPA 사용 못함
|
||||||
|
//layoutService.findById(Objects.toString(vo.get("loutName"))).ifPresent(layoutService::delete);
|
||||||
|
|
||||||
|
String loutName = Objects.toString(vo.get("loutName"));
|
||||||
|
JPAQueryFactory qf = layoutService.getJPAQueryFactory();
|
||||||
|
QLayoutItemEntity qItem = QLayoutItemEntity.layoutItemEntity;
|
||||||
|
QLayoutEntity qLayout = QLayoutEntity.layoutEntity;
|
||||||
|
|
||||||
|
qf.delete(qItem).where(qItem.id.loutname.eq(loutName)).execute(); // tseaitr08 먼저
|
||||||
|
qf.delete(qLayout).where(qLayout.loutname.eq(loutName)).execute(); // tseaitr07
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<HashMap<String,Object>> getAllLayouts() throws Exception {
|
public List<HashMap<String,Object>> getAllLayouts() throws Exception {
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ public class DjbApiSpecController extends OnlBaseAnnotationController {
|
|||||||
// 테스트베드 프록시 사용 여부(포탈 DjbTestbedGatewayProperty 와 동일 키). 마법사에 호출/인증 프록시 경유 안내용.
|
// 테스트베드 프록시 사용 여부(포탈 DjbTestbedGatewayProperty 와 동일 키). 마법사에 호출/인증 프록시 경유 안내용.
|
||||||
private static final String USE_PROXY_KEY = "djb.gateway.use-proxy";
|
private static final String USE_PROXY_KEY = "djb.gateway.use-proxy";
|
||||||
private static final String TOKEN_USE_PROXY_KEY = "djb.gateway.token-use-proxy";
|
private static final String TOKEN_USE_PROXY_KEY = "djb.gateway.token-use-proxy";
|
||||||
|
// OpenAPI 스펙 내보내기(YAML/JSON 다운로드) UI 노출 여부. 기본 false(사용안함).
|
||||||
|
private static final String EXPORT_ENABLED_KEY = "djb.openapi.export.enabled";
|
||||||
// "PortalMock" 은 포탈이 요청 origin 으로 치환하는 sentinel 이라 admin 미리보기용 서버 주소로는 부적합 → 대체값 사용.
|
// "PortalMock" 은 포탈이 요청 origin 으로 치환하는 sentinel 이라 admin 미리보기용 서버 주소로는 부적합 → 대체값 사용.
|
||||||
private static final String GW_PREVIEW_FALLBACK = "http://127.0.0.1:39310";
|
private static final String GW_PREVIEW_FALLBACK = "http://127.0.0.1:39310";
|
||||||
|
|
||||||
@@ -91,14 +93,20 @@ public class DjbApiSpecController extends OnlBaseAnnotationController {
|
|||||||
// 테스트베드 프록시(/api/call-api) 사용 여부 — mock/gw 호출·인증 프록시 경유 안내 표기용
|
// 테스트베드 프록시(/api/call-api) 사용 여부 — mock/gw 호출·인증 프록시 경유 안내 표기용
|
||||||
model.addAttribute("useProxy", resolveBoolProperty(USE_PROXY_KEY, true));
|
model.addAttribute("useProxy", resolveBoolProperty(USE_PROXY_KEY, true));
|
||||||
model.addAttribute("tokenUseProxy", resolveBoolProperty(TOKEN_USE_PROXY_KEY, true));
|
model.addAttribute("tokenUseProxy", resolveBoolProperty(TOKEN_USE_PROXY_KEY, true));
|
||||||
|
// 내보내기 UI 노출 여부(djb.openapi.export.enabled) — 미설정 시 false(사용안함)로 생성
|
||||||
|
model.addAttribute("exportEnabled", resolveBoolProperty(
|
||||||
|
EXPORT_ENABLED_KEY, false, "OpenAPI 스펙 내보내기(YAML/JSON) UI 노출 여부(true/false)"));
|
||||||
return "/onl/transaction/apim/djbApiSpecManPopup";
|
return "/onl/transaction/apim/djbApiSpecManPopup";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 포탈 그룹 프로퍼티(불리언, 레거시 Y/N 포함)를 읽어 boolean 으로 해석. 미설정/파싱불가 시 default. */
|
/** 포탈 그룹 프로퍼티(불리언, 레거시 Y/N 포함)를 읽어 boolean 으로 해석. 미설정/파싱불가 시 default. */
|
||||||
private boolean resolveBoolProperty(String key, boolean def) {
|
private boolean resolveBoolProperty(String key, boolean def) {
|
||||||
|
return resolveBoolProperty(key, def, "테스트베드 프록시(/api/call-api) 사용 여부(true/false)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean resolveBoolProperty(String key, boolean def, String description) {
|
||||||
String v = portalPropertyService.getOrCreateProperty(
|
String v = portalPropertyService.getOrCreateProperty(
|
||||||
"Portal", key, def ? "true" : "false",
|
"Portal", key, def ? "true" : "false", description);
|
||||||
"테스트베드 프록시(/api/call-api) 사용 여부(true/false)");
|
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ============================================================
|
||||||
|
# API Status 자동 탐지 초안 양식
|
||||||
|
#
|
||||||
|
# ApiStatusDetectionService 가 자동 생성하는 공지 제목/본문/타임라인 문구를 여기서 관리한다.
|
||||||
|
# 환경별로 갈리는 값이 아니므로 (/WEB-INF/properties 와 달리) 단일 파일이다.
|
||||||
|
#
|
||||||
|
# - 치환자는 {name} 형식. 정의되지 않은 키는 치환되지 않고 그대로 남는다.
|
||||||
|
# - 이 파일이 없거나 파싱에 실패하면 코드에 박힌 기본 문구로 동작한다 (초안 생성은 멈추지 않는다).
|
||||||
|
# - 게시되지 않은 GW 인터페이스는 이름·ID 를 노출하지 않고 gw-label 로 묶어 표기한다.
|
||||||
|
# ============================================================
|
||||||
|
draft:
|
||||||
|
|
||||||
|
# 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 라벨 {count}=건수
|
||||||
|
gw-label: "GW 인터페이스 {count}건"
|
||||||
|
|
||||||
|
# ---- 공지 제목 ----
|
||||||
|
# {first}=첫 게시 API 명, {rest}=나머지 건수, {keyword}=장애|응답지연, {gw}=gw-label 적용 결과
|
||||||
|
title:
|
||||||
|
single: "[자동감지] {first} API {keyword}"
|
||||||
|
multi: "[자동감지] {first} 외 {rest}종 API {keyword}"
|
||||||
|
# 게시된 API 가 하나도 없을 때 (인터페이스 명을 쓰지 않는다)
|
||||||
|
hidden-only: "[자동감지] {gw} {keyword}"
|
||||||
|
|
||||||
|
# ---- 공지 본문 (HTML) ----
|
||||||
|
body:
|
||||||
|
# {summary}=탐지 사유(에러율 임계 초과 등)
|
||||||
|
lead-publish: "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>"
|
||||||
|
lead-draft: "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>"
|
||||||
|
affected-heading: "영향 API"
|
||||||
|
# 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||||
|
filled-text: "확인 중입니다."
|
||||||
|
draft-text: "[작성 필요] {hint}"
|
||||||
|
# 관리자가 검수하며 채울 항목
|
||||||
|
sections:
|
||||||
|
- title: "발생 원인"
|
||||||
|
hint: "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"
|
||||||
|
- title: "영향 범위"
|
||||||
|
hint: "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"
|
||||||
|
- title: "조치 내용"
|
||||||
|
hint: "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"
|
||||||
|
- title: "예상 복구 시간"
|
||||||
|
hint: "예) 복구 완료 시 실제 시각으로 갱신"
|
||||||
|
|
||||||
|
# ---- 타임라인 ----
|
||||||
|
# {event}=ERROR_START 등, {summary}=탐지 사유
|
||||||
|
# {affected}=영향 API 요약 ("계좌조회, 이체 · GW 인터페이스 2건")
|
||||||
|
timeline:
|
||||||
|
detected: "{summary} 자동 감지 ({event})\n영향 API: {affected}"
|
||||||
|
api-added: "영향 API 추가 감지 ({event})\n{affected}"
|
||||||
|
redetected: "재탐지 신호 수신 ({event})"
|
||||||
|
escalated: "지연에서 장애로 격상 ({event})"
|
||||||
|
recovered-all: "전체 복구 확인 ({event})\n{affected}"
|
||||||
|
recovered-part: "일부 복구 확인 ({event})\n{affected}"
|
||||||
@@ -125,7 +125,8 @@
|
|||||||
<logger name="com.eactive.eai.rms.onl.dashboard" level="ERROR" />
|
<logger name="com.eactive.eai.rms.onl.dashboard" level="ERROR" />
|
||||||
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
|
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
|
||||||
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
|
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
|
||||||
<logger name="com.eactive.eai.rms.ext.djb.job.ApiStatusMonitorJob" level="ERROR" />
|
<logger name="com.eactive.eai.rms.ext.djb.job" level="DEBUG" />
|
||||||
|
<logger name="com.eactive.eai.rms.ext.djb.apistatus" level="DEBUG" />
|
||||||
|
|
||||||
<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="ERROR" additivity="false" />
|
<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="ERROR" additivity="false" />
|
||||||
|
|
||||||
|
|||||||
@@ -1200,6 +1200,8 @@ login.failcountover2 = You have had three password failures. <B
|
|||||||
login.grantfail1 = Failed to initialize user credential information.
|
login.grantfail1 = Failed to initialize user credential information.
|
||||||
login.grantfail2 = Logon Error:<BR>Failed to initialize user credential information.
|
login.grantfail2 = Logon Error:<BR>Failed to initialize user credential information.
|
||||||
login.loginfail1 = Login failed. Please check your login information.
|
login.loginfail1 = Login failed. Please check your login information.
|
||||||
|
login.accountlocked1 = Account locked due to {0} or more login failures. Please contact your administrator.
|
||||||
|
login.accountlocked2 = Account locked due to {0} or more login failures.<BR/> Please contact your administrator.
|
||||||
login.loginfail2 = Login failed.<BR/>Please check your login information.
|
login.loginfail2 = Login failed.<BR/>Please check your login information.
|
||||||
login.logonaccount = Logon account
|
login.logonaccount = Logon account
|
||||||
login.logout = Logout
|
login.logout = Logout
|
||||||
@@ -2396,6 +2398,7 @@ user.checkRequired2 = Please enter the user name.
|
|||||||
user.checkRequired3 = Please enter the password.
|
user.checkRequired3 = Please enter the password.
|
||||||
user.createBy = Create By
|
user.createBy = Create By
|
||||||
user.createOn = Creation Time
|
user.createOn = Creation Time
|
||||||
|
user.lastLoginYms = Last Login Time
|
||||||
user.depCode = Department Code
|
user.depCode = Department Code
|
||||||
user.depart = Department
|
user.depart = Department
|
||||||
user.eaiGrupCd = EAI group code
|
user.eaiGrupCd = EAI group code
|
||||||
|
|||||||
@@ -1346,6 +1346,8 @@ login.failcountover2 = You have had three password failures. <B
|
|||||||
login.grantfail1 = Failed to initialize user credential information.
|
login.grantfail1 = Failed to initialize user credential information.
|
||||||
login.grantfail2 = Logon Error:<BR>Failed to initialize user credential information.
|
login.grantfail2 = Logon Error:<BR>Failed to initialize user credential information.
|
||||||
login.loginfail1 = Login failed. Please check your login information.
|
login.loginfail1 = Login failed. Please check your login information.
|
||||||
|
login.accountlocked1 = Account locked due to {0} or more login failures. Please contact your administrator.
|
||||||
|
login.accountlocked2 = Account locked due to {0} or more login failures.<BR/> Please contact your administrator.
|
||||||
login.loginfail2 = Login failed.<BR/>Please check your login information.
|
login.loginfail2 = Login failed.<BR/>Please check your login information.
|
||||||
login.logonaccount = Logon account
|
login.logonaccount = Logon account
|
||||||
login.logout = Logout
|
login.logout = Logout
|
||||||
@@ -2558,6 +2560,7 @@ user.checkRequired2 = Please enter the user name.
|
|||||||
user.checkRequired3 = Please enter the password.
|
user.checkRequired3 = Please enter the password.
|
||||||
user.createBy = Create By
|
user.createBy = Create By
|
||||||
user.createOn = Creation Time
|
user.createOn = Creation Time
|
||||||
|
user.lastLoginYms = Last Login Time
|
||||||
user.depCode = Department Code
|
user.depCode = Department Code
|
||||||
user.depart = Department
|
user.depart = Department
|
||||||
user.eaiGrupCd = EAI group code
|
user.eaiGrupCd = EAI group code
|
||||||
|
|||||||
@@ -1426,6 +1426,8 @@ login.loginfail1 = \uC0AC\uC6A9\uC790ID \uB610\uB294 \uBE44
|
|||||||
login.loginfail2 = \uC0AC\uC6A9\uC790ID \uB610\uB294 \uBE44\uBC00\uBC88\uD638 \uC785\uB825 \uC2E4\uD328\uC785\uB2C8\uB2E4.<BR/> \uB85C\uADF8\uC778\uC815\uBCF4\uB97C \uD655\uC778\uD558\uC138\uC694.
|
login.loginfail2 = \uC0AC\uC6A9\uC790ID \uB610\uB294 \uBE44\uBC00\uBC88\uD638 \uC785\uB825 \uC2E4\uD328\uC785\uB2C8\uB2E4.<BR/> \uB85C\uADF8\uC778\uC815\uBCF4\uB97C \uD655\uC778\uD558\uC138\uC694.
|
||||||
login.checkstatus1 = \uC7A5\uAE30(90\uC77C)\uBBF8\uC0AC\uC6A9 \uACC4\uC815\uC785\uB2C8\uB2E4. \uB2F4\uB2F9\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
login.checkstatus1 = \uC7A5\uAE30(90\uC77C)\uBBF8\uC0AC\uC6A9 \uACC4\uC815\uC785\uB2C8\uB2E4. \uB2F4\uB2F9\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
||||||
login.checkstatus2 = \uC7A5\uAE30(90\uC77C)\uBBF8\uC0AC\uC6A9 \uACC4\uC815\uC785\uB2C8\uB2E4.<BR/> \uB2F4\uB2F9\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
login.checkstatus2 = \uC7A5\uAE30(90\uC77C)\uBBF8\uC0AC\uC6A9 \uACC4\uC815\uC785\uB2C8\uB2E4.<BR/> \uB2F4\uB2F9\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
||||||
|
login.accountlocked1 = \uB85C\uADF8\uC778 {0}\uD68C \uC774\uC0C1 \uC2E4\uD328\uB85C \uACC4\uC815\uC774 \uC7A0\uACBC\uC2B5\uB2C8\uB2E4. \uAD00\uB9AC\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
||||||
|
login.accountlocked2 = \uB85C\uADF8\uC778 {0}\uD68C \uC774\uC0C1 \uC2E4\uD328\uB85C \uACC4\uC815\uC774 \uC7A0\uACBC\uC2B5\uB2C8\uB2E4.<BR/> \uAD00\uB9AC\uC790\uC5D0\uAC8C \uBB38\uC758\uD558\uC138\uC694.
|
||||||
login.logonaccount = \uB85C\uADF8\uC628\uACC4\uC815
|
login.logonaccount = \uB85C\uADF8\uC628\uACC4\uC815
|
||||||
login.logout = \uB85C\uADF8\uC544\uC6C3
|
login.logout = \uB85C\uADF8\uC544\uC6C3
|
||||||
login.password = \uBE44\uBC00\uBC88\uD638
|
login.password = \uBE44\uBC00\uBC88\uD638
|
||||||
@@ -2659,6 +2661,7 @@ user.checkRequired4 = \uC811\uC18D \uD5C8\uC6A9 IP\uB97C \uD655\uC778\uD558\u
|
|||||||
user.checkRequired5 = \uC0AC\uC6A9\uC790 \uACC4\uC815 \uC0C1\uD0DC\uB97C \uD655\uC778\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624..
|
user.checkRequired5 = \uC0AC\uC6A9\uC790 \uACC4\uC815 \uC0C1\uD0DC\uB97C \uD655\uC778\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624..
|
||||||
user.createBy = \uB4F1\uB85D\uC778
|
user.createBy = \uB4F1\uB85D\uC778
|
||||||
user.createOn = \uB4F1\uB85D\uC77C\uC2DC
|
user.createOn = \uB4F1\uB85D\uC77C\uC2DC
|
||||||
|
user.lastLoginYms = \uCD5C\uC885\uB85C\uADF8\uC778\uC77C\uC2DC
|
||||||
user.depCode = \uBD80\uC810\uCF54\uB4DC
|
user.depCode = \uBD80\uC810\uCF54\uB4DC
|
||||||
user.depart = \uBD80\uC11C\uBA85
|
user.depart = \uBD80\uC11C\uBA85
|
||||||
user.eaiGrupCd = EAI\uADF8\uB8F9\uD68C\uC0AC\uCF54\uB4DC
|
user.eaiGrupCd = EAI\uADF8\uB8F9\uD68C\uC0AC\uCF54\uB4DC
|
||||||
|
|||||||
Reference in New Issue
Block a user