From a92fc85730b1b9f55b58017685d6ac85770ff4a3 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Wed, 5 Aug 2026 14:19:04 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=AC=ED=83=88=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20PortalMenuCacheClient=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20-=20JSP=20=ED=99=94=EB=A9=B4=20portalMenuM?= =?UTF-8?q?an.jsp=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20-=20Porta?= =?UTF-8?q?lMenuManController=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebContent/WEB-INF/auditpoints.dat | 1 + .../jsp/onl/apim/portalmenu/portalMenuMan.jsp | 550 ++++++++++++++++++ .../portalmenu/PortalMenuCacheClient.java | 75 +++ .../portalmenu/PortalMenuManController.java | 89 +++ .../apim/portalmenu/PortalMenuManService.java | 169 ++++++ .../rms/onl/apim/portalmenu/PortalMenuUI.java | 34 ++ .../apim/portalmenu/PortalMenuUIMapper.java | 30 + 7 files changed, 948 insertions(+) create mode 100644 WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java diff --git a/WebContent/WEB-INF/auditpoints.dat b/WebContent/WEB-INF/auditpoints.dat index 9ba8e9e..8eab19e 100644 --- a/WebContent/WEB-INF/auditpoints.dat +++ b/WebContent/WEB-INF/auditpoints.dat @@ -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 diff --git a/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp b/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp new file mode 100644 index 0000000..b1cc2cc --- /dev/null +++ b/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp @@ -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"); +%> + + + + + + + + + + + + + +
+
+ +
+
+ +
포탈 메뉴 관리
+ +
+ + + + + +
+ +
+ 드래그로 위치를 조절한 뒤 저장 버튼으로 일괄 반영합니다. + 기본 항목([기본] 배지)은 [-] 시 미배치로만 이동하고, 커스텀 항목은 미배치 시 삭제됩니다. + 저장 후 캐시 Reload 를 실행해야 포탈에 즉시 반영됩니다(미실행 시 TTL 경과 후 반영). +
+ + + + + + +
+
+ + diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java new file mode 100644 index 0000000..e36132c --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java @@ -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 내부 호출). + * + *

호출 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 으로 감싸 화면에 사유를 표시한다 — 예외를 전파하지 않는다.

+ */ +@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 reload() { + Map result = new LinkedHashMap<>(); + String url = resolveUrl(); + try { + @SuppressWarnings("unchecked") + ResponseEntity response = restTemplate.postForEntity(url, null, Map.class); + Map 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; + } + } +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java new file mode 100644 index 0000000..d9371b0 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java @@ -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; + +/** + * 포탈메뉴관리 (파트너포탈 > 포탈관리 > 메뉴 관리). + * + *

cmd 규약: TRANSACTION_ 계열과 INSERT/UPDATE/DELETE/INITIALIZE 는 + * AuthorizeInterceptor 가 W 권한을 자동 요구한다.

+ */ +@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> listAll() { + List items = portalMenuManService.selectAll(); + List roles = portalMenuManService.selectRoles(); + Map 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 insert(PortalMenuUI portalMenuUI) { + portalMenuManService.insert(portalMenuUI); + return ResponseEntity.ok().build(); + } + + @PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=UPDATE") + public ResponseEntity update(PortalMenuUI portalMenuUI) { + portalMenuManService.update(portalMenuUI); + return ResponseEntity.ok().build(); + } + + /** 기본 항목은 미배치 전환, 커스텀 항목은 하드 삭제 */ + @PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=DELETE") + @ResponseBody + public ResponseEntity> delete(String menuId) { + boolean removed = portalMenuManService.delete(menuId); + Map 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 savePlacements(@RequestBody List placements) { + portalMenuManService.savePlacements(placements); + return ResponseEntity.ok().build(); + } + + /** 메뉴 초기화 — eapim-portal 시딩 기본값으로 복원 */ + @PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=INITIALIZE") + public ResponseEntity initialize() { + portalMenuManService.initialize(); + return ResponseEntity.ok().build(); + } + + /** 포탈 메뉴 캐시 리로드 (TRANSACTION_ prefix → W 권한 요구) */ + @PostMapping(value = "/onl/apim/portalmenu/portalMenuMan.json", params = "cmd=TRANSACTION_RELOAD") + @ResponseBody + public ResponseEntity> reloadPortalCache() { + return ResponseEntity.ok(portalMenuCacheClient.reload()); + } +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java new file mode 100644 index 0000000..1a28b2e --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java @@ -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; + +/** + * 포탈메뉴관리 오케스트레이션. + * + *

가드: + *

    + *
  • PORTAL(기본) 항목 — 삭제 시 미배치 전환만, 식별자/구조 필드(group/section) 수정 금지
  • + *
  • ADMIN(커스텀) 항목 — 삭제 시 하드 삭제
  • + *
+ */ +@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 selectAll() { + Map 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 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 placements) { + Set knownIds = portalMenuDataService.loadAllItems().stream() + .map(PortalMenuItem::getMenuId) + .collect(Collectors.toSet()); + Set seen = new HashSet<>(); + + List 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(); + } +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java new file mode 100644 index 0000000..7f72b61 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java @@ -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; +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java new file mode 100644 index 0000000..de05615 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java @@ -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); +}