포탈 메뉴 관리 기능 추가

- PortalMenuCacheClient 클래스 작성
- JSP 화면 portalMenuMan.jsp 추가 및 렌더링 로직 구현
- PortalMenuManController 클래스 작성
This commit is contained in:
Rinjae
2026-08-05 14:19:04 +09:00
parent 317634b5b7
commit a92fc85730
7 changed files with 948 additions and 0 deletions
@@ -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);
}