포탈 메뉴 관리 기능 추가
- PortalMenuCacheClient 클래스 작성 - JSP 화면 portalMenuMan.jsp 추가 및 렌더링 로직 구현 - PortalMenuManController 클래스 작성
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user