3 Commits

Author SHA1 Message Date
Rinjae a92fc85730 포탈 메뉴 관리 기능 추가
- PortalMenuCacheClient 클래스 작성
- JSP 화면 portalMenuMan.jsp 추가 및 렌더링 로직 구현
- PortalMenuManController 클래스 작성
2026-08-05 14:19:04 +09:00
Rinjae 317634b5b7 Merge remote-tracking branch 'origin/master'
eapim-admin CI / build (push) Has been cancelled
2026-08-05 10:44:38 +09:00
Rinjae 27fa79ddd9 자동 장애 탐지 초안 양식 추가
- apistatus-draft.yml 파일 및 관련 로직 추가
- SnakeYAML 의존성 추가 및 안전한 yml 파싱 구현
- 자동 탐지 공지 제목·본문·타임라인 문구 분리 및 설정 관리
2026-08-05 10:44:31 +09:00
12 changed files with 1356 additions and 36 deletions
+1
View File
@@ -17,3 +17,4 @@ PortalPropertyController_포탈 프로퍼티 관리_APIGW_UPDATE
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS
PortalInquiryManController_Q&A문의관리_APIGW_VISIBILITY
PortalMenuManController_포탈메뉴관리_APIGW_INSERT,UPDATE,DELETE,TRANSACTION_PLACEMENT,INITIALIZE,TRANSACTION_RELOAD
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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>
+6
View File
@@ -118,6 +118,12 @@ dependencies {
//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'
// 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 "org.snmp4j:snmp4j:1.10.1"
implementation "com.googlecode.json-simple:json-simple:1.1.1"
@@ -113,6 +113,10 @@ public class ApiStatusDetectionService {
@Autowired
private PortalPropertyService portalPropertyService;
/** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */
@Autowired
private ApiStatusDraftTemplate draftTemplate;
/**
* 이벤트 감지
* @param event "CONTROL_START" : 점검시작
@@ -206,7 +210,7 @@ public class ApiStatusDetectionService {
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
if (duplicated.isPresent()) {
appendTimeline(duplicated.get().getIncidentId(), null,
"재탐지 신호 수신 (" + event + ")", now);
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
return;
}
@@ -252,13 +256,15 @@ public class ApiStatusDetectionService {
}
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
appendTimeline(existing.getIncidentId(), null,
"재탐지 신호 수신 (" + event + ")", now));
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now));
return;
}
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
// 타임라인도 고객 화면에 그대로 나가므로 인터페이스 ID 대신 게시 API 명 + 건수로 적는다
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now);
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(),
@@ -308,7 +314,7 @@ public class ApiStatusDetectionService {
Long incidentId = incident.getIncidentId();
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
escalateIfNeeded(incident, event, kind, now, autoPublish);
boolean escalated = escalateIfNeeded(incident, event, kind, now, autoPublish);
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
@@ -325,14 +331,22 @@ public class ApiStatusDetectionService {
.collect(Collectors.toList());
if (added.isEmpty()) {
appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now);
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
// 격상을 남긴 턴에는 같은 시각·같은 이벤트로 "재탐지 신호 수신"이 겹쳐 찍힌다.
// 격상 줄이 더 구체적인 정보이므로 재탐지 줄은 생략한다.
if (!escalated) {
appendTimeline(incidentId, null,
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
}
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}, escalated={}",
incidentId, apiIds, escalated);
return;
}
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
Map<String, String> addedNames = resolveApiNames(added);
saveIncidentApis(incidentId, added, addedNames, now);
appendTimeline(incidentId, null,
"영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now);
draftTemplate.text(ApiStatusDraftTemplate.TL_API_ADDED,
"event", event, "affected", affectedSummary(added, addedNames)), now);
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
}
@@ -341,11 +355,13 @@ public class ApiStatusDetectionService {
*
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
*
* @return 실제로 격상하여 타임라인을 남겼으면 true
*/
private void escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
private boolean escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
LocalDateTime now, boolean autoPublish) {
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
return;
return false;
}
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
@@ -370,9 +386,10 @@ public class ApiStatusDetectionService {
incidentRepository.save(incident);
appendTimeline(incident.getIncidentId(), null,
"지연에서 장애로 격상 (" + event + ")", now);
draftTemplate.text(ApiStatusDraftTemplate.TL_ESCALATED, "event", event), now);
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
incident.getIncidentId(), incident.getNoticeId());
return true;
}
/**
@@ -459,8 +476,11 @@ public class ApiStatusDetectionService {
incident.setLastModifiedDate(now);
incidentRepository.save(incident);
String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n"
+ String.join(", ", recoveredApis);
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);
@@ -500,21 +520,80 @@ public class ApiStatusDetectionService {
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
}
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
String first = apiLabel(apiIds.get(0), apiNames);
if (apiIds.size() > 1) {
return "[자동감지] " + first + "" + (apiIds.size() - 1) + "종 API " + titleKeyword;
/**
* 영향 인터페이스를 개발자포탈 게시 여부로 가른 결과.
* 게시된 것(= 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;
}
return "[자동감지] " + first + " API " + titleKeyword;
}
/** 관리자가 검수 시 채워 넣을 항목 - {제목, 작성 힌트} */
private static final String[][] NOTICE_FILL_IN_SECTIONS = {
{"발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"},
{"영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"},
{"조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"},
{"예상 복구 시간", "예) 2026-08-03 14:30 (복구 완료 시 실제 시각으로 갱신)"}
};
/** 게시 인터페이스 판정. 조회에 실패하면 전부 비게시로 보아 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);
}
/**
* 자동 탐지 장애 공지 생성.
@@ -528,21 +607,32 @@ public class ApiStatusDetectionService {
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
StringBuilder detail = new StringBuilder();
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다.");
detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다.");
detail.append("</p>");
detail.append("<p><b>영향 API</b></p><ul>");
for (String apiId : apiIds) {
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
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 (String[] section : NOTICE_FILL_IN_SECTIONS) {
detail.append("<p><b>").append(section[0]).append("</b></p>");
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
detail.append(publish
? "<p>확인 중입니다.</p>"
: "<p>[작성 필요] " + section[1] + "</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();
@@ -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();
}
}
@@ -145,4 +145,24 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
@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);
}
@@ -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);
}
+53
View File
@@ -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}"