From 5585df91335d06c557a9bbf8773909099d5d2f7e Mon Sep 17 00:00:00 2001 From: Rinjae Date: Wed, 5 Aug 2026 14:19:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20menu.yml=20GNB=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?-=20=EC=A0=91=EA=B7=BC=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0/?= =?UTF-8?q?=EB=B7=B0=20=EC=A3=BC=EC=9E=85/=EB=82=B4=EB=B6=80=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + readme-docs/메뉴-관리-개발-가이드.md | 85 +++++ .../user/service/PortalUserAuthService.java | 6 +- .../portal/config/PortalConfigSecurity.java | 1 + .../PortalConfigWebDispatcherServlet.java | 13 +- .../apim/portal/config/PortalProperties.java | 4 - .../djb/menu/MenuAccessInterceptor.java | 70 ++++ .../djb/menu/MenuInternalController.java | 99 +++++ .../apim/portal/djb/menu/MenuModelAdvice.java | 94 +++++ .../apim/portal/djb/menu/MenuNode.java | 46 +++ .../apim/portal/djb/menu/MenuSeeder.java | 360 ++++++++++++++++++ .../apim/portal/djb/menu/MenuService.java | 179 +++++++++ .../apim/portal/djb/menu/MenuView.java | 108 ++++++ .../djb/menu/PortalMenuYmlProperties.java | 38 ++ .../djb/menu/PortalRolesProperties.java | 47 +++ src/main/resources/application.yml | 25 +- src/main/resources/menu.yml | 55 +++ src/main/resources/roles.yml | 47 +++ .../fragment/djbank/header_container.html | 135 +++---- .../fragment/djbank/service_sidebar.html | 69 +--- 20 files changed, 1312 insertions(+), 170 deletions(-) create mode 100644 readme-docs/메뉴-관리-개발-가이드.md create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java create mode 100644 src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java create mode 100644 src/main/resources/menu.yml create mode 100644 src/main/resources/roles.yml diff --git a/README.md b/README.md index b96b873..88c3868 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified ## 문서 - **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드 +- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동 - **프로젝트 상세 지침**: `CLAUDE.md` (한글) - **사용자 가이드**: `개발자포탈.md` (한글) - **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh` diff --git a/readme-docs/메뉴-관리-개발-가이드.md b/readme-docs/메뉴-관리-개발-가이드.md new file mode 100644 index 0000000..95aa529 --- /dev/null +++ b/readme-docs/메뉴-관리-개발-가이드.md @@ -0,0 +1,85 @@ +# 메뉴 관리 개발 가이드 + +포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며, +노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다. + +## 구성 요소 + +| 구성 | 위치 | 역할 | +|---|---|---| +| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) | +| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) | +| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` | +| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) | +| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) | +| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 | +| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) | +| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) | + +메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer), +`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**. + +## menu.yml 스키마 + +```yaml +portal-menu: + items: + - id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목) + name: "고객지원" + group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출) + section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속 + expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of + children: + - { id: support-faq, name: "FAQ", path: /faq_list } + - { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell, + expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] } +``` + +- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect). +- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스). +- 정렬은 yml 나열 순서(기본 배치 sort = index×10). + +## 시딩 규칙 (MenuSeeder) + +1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고, + **관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다. + 구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다. +2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩. + 이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨. +3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리). +4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`. + +## 캐시와 리로드 + +- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초). +- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload` + (admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행). +- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback) + + X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`). +- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`. + +## 새 메뉴 추가 절차 + +**기본 메뉴(코드 배포와 함께)** +1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로) +2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지) +3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인 +4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) — + admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장 + +**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload. +커스텀 항목은 미배치 시 삭제된다. + +## 로컬 개발 주의 + +- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요: + `JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"` + (미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError). +- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로. +- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일. + +## 역할(roles.yml) 변경 + +- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는 + admin 권한 선택 체크박스 소스 전용. +- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다. diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java index b6b18ab..e20a696 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserAuthService.java @@ -10,7 +10,7 @@ import com.eactive.apim.portal.common.exception.UserNotFoundException; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.EncryptionUtil; import com.eactive.apim.portal.common.util.SecurityUtil; -import com.eactive.apim.portal.config.PortalProperties; +import com.eactive.apim.portal.djb.menu.PortalRolesProperties; import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; @@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService { private final PortalUserRepository portalUserRepository; private final PortalUserMapper portalUserMapper; - private final PortalProperties portalProperties; + private final PortalRolesProperties portalRolesProperties; private final PasswordEncoder passwordEncoder; private final MessageHandlerService messageHandlerService; private final MessageRequestRepository messageRequestRepository; @@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService { */ public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) { RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode(); - List roles = portalProperties.getPortalSecurity().get(userRole); + List roles = portalRolesProperties.getAuthorities(userRole); PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser); authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name())); diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index b3ce74c..6402cd1 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -109,6 +109,7 @@ public class PortalConfigSecurity { .csrfTokenRepository(csrfTokenRepository) .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*")) .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**")) + .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**")) ) // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면 // 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져 diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java index 503d534..512e90d 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigWebDispatcherServlet.java @@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer { private final Environment environment; private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService; private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties; + private final com.eactive.apim.portal.djb.menu.MenuService menuService; // 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled). // prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고). @@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer { public PortalConfigWebDispatcherServlet(Environment environment, com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService, - com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) { + com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties, + com.eactive.apim.portal.djb.menu.MenuService menuService) { this.environment = environment; this.twoFactorService = twoFactorService; this.twoFactorProperties = twoFactorProperties; + this.menuService = menuService; } @@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer { .addPathPatterns("/**") .excludePathPatterns(staticExcludes) .excludePathPatterns("/auth/2fa/**"); + + // 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며 + // 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다. + registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService)) + .addPathPatterns("/**") + .excludePathPatterns(staticExcludes) + .excludePathPatterns("/internal/**", "/auth/2fa/**", + "/login", "/actionLogin.do", "/actionLogout.do", "/error"); } @Bean diff --git a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java index 148f79c..41d6cb0 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java @@ -1,13 +1,11 @@ package com.eactive.apim.portal.config; import com.eactive.apim.portal.common.pagerouter.property.PageRoute; -import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.List; -import java.util.Map; @Data @Component @@ -25,8 +23,6 @@ public class PortalProperties { private String authVirtualCode = ""; - private Map> portalSecurity; - private FileProperties file = new FileProperties(); /** diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java new file mode 100644 index 0000000..a1ab574 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java @@ -0,0 +1,70 @@ +package com.eactive.apim.portal.djb.menu; + +import com.eactive.apim.portal.menu.entity.PortalMenuItem; +import com.eactive.apim.portal.common.util.SecurityUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 메뉴 접근 권한(ACCESS_ROLES) 서버측 집행. + * + *

요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다 + * (하위 경로는 기존 안전망 @Secured / PageRoute.role 에 위임 — 사용자 결정). + * 미충족 시 익명은 로그인으로, 인증 사용자는 홈으로 리다이렉트한다. + * 메뉴에 등록되지 않은 경로는 통과시킨다. + */ +@Slf4j +@RequiredArgsConstructor +public class MenuAccessInterceptor implements HandlerInterceptor { + + private final MenuService menuService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String path = currentPath(request); + List requiredRoles = menuService.getSnapshot().getAccessRolesByPath().get(path); + if (requiredRoles == null || requiredRoles.isEmpty()) { + return true; + } + if (isAllowed(requiredRoles)) { + return true; + } + + if (!SecurityUtil.isAuthenticated()) { + response.sendRedirect(request.getContextPath() + "/login"); + } else { + log.info("메뉴 접근 권한 미충족 - path: {}, 필요 권한: {}, 사용자: {}", + path, requiredRoles, SecurityUtil.getCurrentLoginId()); + response.sendRedirect(request.getContextPath() + "/"); + } + return false; + } + + private boolean isAllowed(List requiredRoles) { + for (String role : requiredRoles) { + if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) { + if (SecurityUtil.isAuthenticated()) { + return true; + } + } else if (SecurityUtil.hasRole(role)) { + return true; + } + } + return false; + } + + private String currentPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) { + uri = uri.substring(contextPath.length()); + } + return uri; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java new file mode 100644 index 0000000..1663d58 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java @@ -0,0 +1,99 @@ +package com.eactive.apim.portal.djb.menu; + +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용. + * + *

가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록 + * (기본 loopback) + X-Forwarded-For 동반 요청 거부 + * ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델). + * CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.

+ * + *
curl -X POST http://127.0.0.1:39130/internal/menu/reload
+ */ +@Slf4j +@RestController +@RequestMapping("/internal/menu") +@RequiredArgsConstructor +public class MenuInternalController { + + static final String PROP_GROUP = "Portal"; + static final String PROP_ALLOW_IPS = "menu.internal.allow-ips"; + static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1"; + + private final MenuService menuService; + private final PortalPropertyService portalPropertyService; + + @PostMapping("/reload") + public ResponseEntity> reload(HttpServletRequest request) { + if (!isAllowed(request)) { + Map denied = new LinkedHashMap<>(); + denied.put("result", "DENIED"); + denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied); + } + + MenuService.MenuSnapshot snapshot = menuService.reload(); + log.info("메뉴 캐시 리로드 명령 수신 - from: {}", request.getRemoteAddr()); + + Map body = new LinkedHashMap<>(); + body.put("result", "OK"); + body.put("itemCount", snapshot.getItemCount()); + body.put("reloadedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + return ResponseEntity.ok(body); + } + + /** + * 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. + * (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나 + * 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.) + */ + private boolean isAllowed(HttpServletRequest request) { + String remote = canonicalize(request.getRemoteAddr()); + boolean viaProxy = request.getHeader("X-Forwarded-For") != null; + + Set allowed = Arrays.stream(resolveAllowIps().split(",")) + .map(String::trim) + .filter(ip -> !ip.isEmpty()) + .map(MenuInternalController::canonicalize) + .collect(Collectors.toSet()); + + if (viaProxy || !allowed.contains(remote)) { + log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy); + return false; + } + return true; + } + + private String resolveAllowIps() { + try { + return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS, + DEFAULT_ALLOW_IPS, "메뉴 내부 API(리로드) 허용 IP 목록(콤마 구분)"); + } catch (Exception e) { + log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e); + return DEFAULT_ALLOW_IPS; + } + } + + /** IPv6 loopback 표기 통일 */ + private static String canonicalize(String ip) { + return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java new file mode 100644 index 0000000..3ca7250 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java @@ -0,0 +1,94 @@ +package com.eactive.apim.portal.djb.menu; + +import com.eactive.apim.portal.menu.entity.PortalMenuItem; +import com.eactive.apim.portal.common.util.SecurityUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; + +/** + * 모든 뷰 요청에 role 필터가 적용된 {@code menuView} 를 주입한다 + * (breadcrumb 의 GlobalControllerAdvice 와 동일한 방식). + * + *

노출 판정: EXPOSE_ROLES 비어있음(전체) ∥ AUTHENTICATED(로그인) ∥ + * 역할 any-of. 경로 없는 그룹은 노출 자식이 하나도 없으면 제외한다. + */ +@Slf4j +@ControllerAdvice +@RequiredArgsConstructor +public class MenuModelAdvice { + + private final MenuService menuService; + + @ModelAttribute("menuView") + public MenuView menuView(HttpServletRequest request) { + try { + MenuService.MenuSnapshot snapshot = menuService.getSnapshot(); + boolean authenticated = SecurityUtil.isAuthenticated(); + + List gnb = new ArrayList<>(); + MenuNode mypage = null; + for (MenuNode root : snapshot.getRoots()) { + MenuNode filtered = filter(root, authenticated); + if (filtered == null) { + continue; + } + if (PortalMenuItem.SECTION_MYPAGE.equals(filtered.getSection())) { + if (mypage == null) { + mypage = filtered; + } + } else { + gnb.add(filtered); + } + } + return new MenuView(gnb, mypage, request.getRequestURI()); + } catch (Exception e) { + // 메뉴 조회 실패가 화면 전체를 막지 않도록 빈 메뉴로 폴백 + log.error("메뉴 뷰 구성 실패 - 빈 메뉴로 렌더링합니다.", e); + return MenuView.empty(); + } + } + + private MenuNode filter(MenuNode node, boolean authenticated) { + if (!isExposed(node, authenticated)) { + return null; + } + MenuNode copy = node.copyWithoutChildren(); + List children = new ArrayList<>(); + for (MenuNode child : node.getChildren()) { + MenuNode filteredChild = filter(child, authenticated); + if (filteredChild != null) { + children.add(filteredChild); + } + } + copy.setChildren(children); + + // 경로 없는 그룹은 노출할 자식이 없으면 통째로 제외 + if (copy.isGroup() && !copy.hasPath() && children.isEmpty()) { + return null; + } + return copy; + } + + private boolean isExposed(MenuNode node, boolean authenticated) { + List exposeRoles = node.getExposeRoles(); + if (exposeRoles.isEmpty()) { + return true; + } + for (String role : exposeRoles) { + if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) { + if (authenticated) { + return true; + } + } else if (SecurityUtil.hasRole(role)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java new file mode 100644 index 0000000..baf7bb8 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java @@ -0,0 +1,46 @@ +package com.eactive.apim.portal.djb.menu; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 렌더용 메뉴 노드. {@link MenuService} 스냅샷 트리와 + * {@link MenuModelAdvice} 의 요청별 필터 사본 양쪽에 사용한다. + */ +@Data +public class MenuNode { + + private String menuId; + private String name; + /** 이동 경로 (null = 클릭 없는 그룹) */ + private String path; + private boolean group; + /** GNB | MYPAGE */ + private String section; + private String icon; + private boolean newWindow; + private List exposeRoles = new ArrayList<>(); + private List accessRoles = new ArrayList<>(); + private List children = new ArrayList<>(); + + /** 필터 사본 생성 (children 제외 필드 복사) */ + public MenuNode copyWithoutChildren() { + MenuNode copy = new MenuNode(); + copy.menuId = menuId; + copy.name = name; + copy.path = path; + copy.group = group; + copy.section = section; + copy.icon = icon; + copy.newWindow = newWindow; + copy.exposeRoles = exposeRoles; + copy.accessRoles = accessRoles; + return copy; + } + + public boolean hasPath() { + return path != null && !path.isEmpty(); + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java new file mode 100644 index 0000000..6619afa --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java @@ -0,0 +1,360 @@ +package com.eactive.apim.portal.djb.menu; + +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.entity.PortalRoleAuthority; +import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId; +import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository; +import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository; +import com.eactive.apim.portal.menu.repository.PortalRoleAuthorityRepository; +import com.eactive.apim.portal.menu.repository.PortalRoleRepository; +import com.eactive.apim.portal.menu.service.PortalMenuDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +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.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 부팅 시 menu.yml / roles.yml → DB 적재. + * + *

    + *
  • 역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)
  • + *
  • 메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고, + * 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다
  • + *
  • 배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩
  • + *
  • yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다
  • + *
+ * + * 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로, + * 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다 + * (AuditingHandler 는 auditor 부재 시 기존 값을 보존). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MenuSeeder implements ApplicationListener { + + static final String SYSTEM_AUDITOR = "SYSTEM"; + private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$"); + + private final PortalMenuYmlProperties menuYmlProperties; + private final PortalRolesProperties rolesProperties; + private final PortalMenuItemRepository menuItemRepository; + private final PortalMenuPlacementRepository menuPlacementRepository; + private final PortalRoleRepository roleRepository; + private final PortalRoleAuthorityRepository roleAuthorityRepository; + private final PortalMenuDataService menuDataService; + private final MenuService menuService; + + @Override + @Transactional + public void onApplicationEvent(ApplicationReadyEvent event) { + try { + seedRoles(); + seedMenuItems(); + seedPlacementsIfEmpty(); + menuService.reload(); + } catch (Exception e) { + // 시딩 실패가 기동 자체를 막지 않도록 로그만 남긴다 (메뉴는 기존 DB 값으로 동작) + log.error("메뉴/역할 시딩 실패 - 기존 DB 데이터로 동작합니다.", e); + } + } + + // ─────────────── 역할 ─────────────── + + private void seedRoles() { + int sortOrder = 10; + int upserted = 0; + Set definedCodes = new HashSet<>(); + + for (PortalRolesProperties.RoleDef roleDef : rolesProperties.getRoles()) { + upserted += upsertRole(roleDef.getCode(), roleDef.getName(), PortalRole.TYPE_BASE, sortOrder) ? 1 : 0; + definedCodes.add(roleDef.getCode()); + sortOrder += 10; + } + for (Map.Entry entry : rolesProperties.getAuthorityNames().entrySet()) { + if (definedCodes.contains(entry.getKey())) { + continue; + } + upserted += upsertRole(entry.getKey(), entry.getValue(), PortalRole.TYPE_AUTHORITY, sortOrder) ? 1 : 0; + sortOrder += 10; + } + + // 역할→권한 매핑은 순수 미러 — 통째로 재구축 + roleAuthorityRepository.deleteAllInBatch(); + roleAuthorityRepository.flush(); + List mappings = rolesProperties.getRoles().stream() + .flatMap(roleDef -> roleDef.getAuthorities().stream() + .map(authority -> { + PortalRoleAuthority mapping = new PortalRoleAuthority(); + mapping.setId(new PortalRoleAuthorityId(roleDef.getCode(), authority)); + mapping.setCreatedBy(SYSTEM_AUDITOR); + return mapping; + })) + .collect(Collectors.toList()); + roleAuthorityRepository.saveAll(mappings); + + log.info("역할 시딩 완료 - PTL_ROLE upsert {}건, PTL_ROLE_AUTHORITY {}건 재구축", upserted, mappings.size()); + } + + private boolean upsertRole(String code, String name, String type, int sortOrder) { + PortalRole role = roleRepository.findById(code).orElse(null); + if (role == null) { + role = new PortalRole(); + role.setRoleCode(code); + role.setCreatedBy(SYSTEM_AUDITOR); + } else if (Objects.equals(role.getRoleName(), name) + && Objects.equals(role.getRoleType(), type) + && Objects.equals(role.getSortOrder(), sortOrder)) { + return false; + } + role.setRoleName(name); + role.setRoleType(type); + role.setSortOrder(sortOrder); + roleRepository.save(role); + return true; + } + + // ─────────────── 메뉴 항목 ─────────────── + + private void seedMenuItems() { + List flatDefs = flatten(menuYmlProperties.getItems()); + validate(flatDefs); + + int inserted = 0; + int updated = 0; + for (FlatMenuDef def : flatDefs) { + PortalMenuItem existing = menuItemRepository.findById(def.id).orElse(null); + if (existing == null) { + menuItemRepository.save(newItem(def)); + inserted++; + } else if (updateItem(existing, def)) { + menuItemRepository.save(existing); + updated++; + } + } + + // yml 에서 사라진 PORTAL 항목 경고 (자동 삭제 금지) + Set ymlIds = flatDefs.stream().map(def -> def.id).collect(Collectors.toSet()); + menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL).stream() + .map(PortalMenuItem::getMenuId) + .filter(id -> !ymlIds.contains(id)) + .forEach(id -> log.warn("menu.yml 에 없는 기본 메뉴 항목이 DB에 남아 있습니다 (수동 정리 필요): {}", id)); + + log.info("메뉴 항목 시딩 완료 - 신규 {}건, 갱신 {}건, 유지 {}건", + inserted, updated, flatDefs.size() - inserted - updated); + } + + private PortalMenuItem newItem(FlatMenuDef def) { + PortalMenuItem item = new PortalMenuItem(); + item.setMenuId(def.id); + item.setSourceType(PortalMenuItem.SOURCE_PORTAL); + item.setCreatedBy(SYSTEM_AUDITOR); + + item.setMenuName(def.name()); + item.setMenuPath(def.path()); + item.setExposeRoles(def.exposeCsv()); + item.setAccessRoles(def.accessCsv()); + + applyStructure(item, def); + applyDefaults(item, def); + return item; + } + + /** + * 기존 항목 upsert 갱신. 내용 필드는 "관리자 미수정(현재값==구 기본값) 시에만" + * 새 기본값을 따라가고, 구조 필드(group/section/icon/new-window)와 DFLT_* 는 + * 항상 yml 값으로 맞춘다. + * + * @return 변경이 있었으면 true + */ + private boolean updateItem(PortalMenuItem item, FlatMenuDef def) { + boolean[] changed = {false}; + + followDefault(item, changed, def.name(), PortalMenuItem::getDfltMenuName, + PortalMenuItem::getMenuName, PortalMenuItem::setMenuName, PortalMenuItem::setDfltMenuName); + followDefault(item, changed, def.path(), PortalMenuItem::getDfltMenuPath, + PortalMenuItem::getMenuPath, PortalMenuItem::setMenuPath, PortalMenuItem::setDfltMenuPath); + followDefault(item, changed, def.exposeCsv(), PortalMenuItem::getDfltExposeRoles, + PortalMenuItem::getExposeRoles, PortalMenuItem::setExposeRoles, PortalMenuItem::setDfltExposeRoles); + followDefault(item, changed, def.accessCsv(), PortalMenuItem::getDfltAccessRoles, + PortalMenuItem::getAccessRoles, PortalMenuItem::setAccessRoles, PortalMenuItem::setDfltAccessRoles); + + String groupYn = def.source.isGroup() ? "Y" : "N"; + String section = def.section(); + String newWindowYn = def.source.isNewWindow() ? "Y" : "N"; + if (!Objects.equals(item.getGroupYn(), groupYn) + || !Objects.equals(item.getMenuSection(), section) + || !Objects.equals(item.getIconClass(), def.source.getIcon()) + || !Objects.equals(item.getNewWindowYn(), newWindowYn)) { + applyStructure(item, def); + changed[0] = true; + } + + if (!Objects.equals(item.getDfltParentId(), def.parentId) + || !Objects.equals(item.getDfltSortOrder(), def.sortOrder)) { + item.setDfltParentId(def.parentId); + item.setDfltSortOrder(def.sortOrder); + changed[0] = true; + } + return changed[0]; + } + + /** + * 내용 필드 1개에 대한 기본값 추종 처리: yml 기본값이 바뀌었을 때 + * 현재값이 구 기본값과 같으면(관리자 미수정) 현재값도 새 기본값으로 갱신한다. + */ + private void followDefault(PortalMenuItem item, boolean[] changed, String newDefault, + Function defaultGetter, + Function currentGetter, + BiConsumer currentSetter, + BiConsumer defaultSetter) { + String oldDefault = defaultGetter.apply(item); + if (Objects.equals(oldDefault, newDefault)) { + return; + } + if (Objects.equals(currentGetter.apply(item), oldDefault)) { + currentSetter.accept(item, newDefault); + } + defaultSetter.accept(item, newDefault); + changed[0] = true; + } + + private void applyStructure(PortalMenuItem item, FlatMenuDef def) { + item.setGroupYn(def.source.isGroup() ? "Y" : "N"); + item.setMenuSection(def.section()); + item.setIconClass(def.source.getIcon()); + item.setNewWindowYn(def.source.isNewWindow() ? "Y" : "N"); + } + + private void applyDefaults(PortalMenuItem item, FlatMenuDef def) { + item.setDfltMenuName(def.name()); + item.setDfltMenuPath(def.path()); + item.setDfltExposeRoles(def.exposeCsv()); + item.setDfltAccessRoles(def.accessCsv()); + item.setDfltParentId(def.parentId); + item.setDfltSortOrder(def.sortOrder); + item.setDfltVisibleYn("Y"); + } + + // ─────────────── 배치 ─────────────── + + private void seedPlacementsIfEmpty() { + if (menuPlacementRepository.count() > 0) { + log.info("메뉴 배치 존재 - 최초 시딩 건너뜀 (관리자 배치 보존)"); + return; + } + List portalItems = + menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL); + List placements = menuDataService.buildDefaultPlacements(portalItems); + placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR)); + menuPlacementRepository.saveAll(placements); + log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size()); + } + + // ─────────────── 평탄화 / 검증 ─────────────── + + private List flatten(List roots) { + List result = new ArrayList<>(); + int sortOrder = 10; + for (PortalMenuYmlProperties.MenuItemDef root : roots) { + result.add(new FlatMenuDef(root, null, sortOrder, null)); + int childOrder = 10; + for (PortalMenuYmlProperties.MenuItemDef child : root.getChildren()) { + result.add(new FlatMenuDef(child, root.getId(), childOrder, root)); + childOrder += 10; + } + sortOrder += 10; + } + return result; + } + + private void validate(List defs) { + Set seen = new HashSet<>(); + for (FlatMenuDef def : defs) { + if (def.id == null || !MENU_ID_PATTERN.matcher(def.id).matches()) { + throw new IllegalStateException("menu.yml 항목 id 형식 오류(kebab-case 필수): " + def.id); + } + if (!seen.add(def.id)) { + throw new IllegalStateException("menu.yml 항목 id 중복: " + def.id); + } + } + } + + /** yml 트리 항목의 평탄화 뷰 (부모/기본 정렬 포함) */ + private static class FlatMenuDef { + final PortalMenuYmlProperties.MenuItemDef source; + final String parentId; + final Integer sortOrder; + final PortalMenuYmlProperties.MenuItemDef parent; + final String id; + + FlatMenuDef(PortalMenuYmlProperties.MenuItemDef source, String parentId, Integer sortOrder, + PortalMenuYmlProperties.MenuItemDef parent) { + this.source = source; + this.parentId = parentId; + this.sortOrder = sortOrder; + this.parent = parent; + this.id = source.getId(); + } + + String name() { + return source.getName(); + } + + String path() { + return trimToNull(source.getPath()); + } + + /** 자식은 섹션 미지정 시 부모 섹션 상속 */ + String section() { + String own = trimToNull(source.getSection()); + if (own != null) { + return own; + } + if (parent != null) { + String parentSection = trimToNull(parent.getSection()); + if (parentSection != null) { + return parentSection; + } + } + return PortalMenuItem.SECTION_GNB; + } + + String exposeCsv() { + return toCsv(source.getExposeRoles()); + } + + String accessCsv() { + return toCsv(source.getAccessRoles()); + } + + private static String toCsv(List roles) { + if (roles == null || roles.isEmpty()) { + return null; + } + return String.join(",", roles); + } + + 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/apim/portal/djb/menu/MenuService.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java new file mode 100644 index 0000000..3860b7b --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java @@ -0,0 +1,179 @@ +package com.eactive.apim.portal.djb.menu; + +import com.eactive.apim.portal.menu.entity.PortalMenuItem; +import com.eactive.apim.portal.menu.entity.PortalMenuPlacement; +import com.eactive.apim.portal.menu.service.PortalMenuDataService; +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 메뉴 트리 스냅샷 캐시. + * + *

매 요청 DB 조회를 피하기 위해 role 비의존 트리를 메모리에 유지한다 + * (기존 수제 캐시 관례: PageService 의 volatile 필드). TTL 은 + * PTL_PROPERTY {@code Portal / menu.cache.ttl-seconds} (기본 3600초)이며, + * eapim-admin 의 reload 명령(/internal/menu/reload)으로 즉시 갱신된다. + * 요청별 role 필터링은 {@link MenuModelAdvice} 가 수행한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MenuService { + + static final String PROP_GROUP = "Portal"; + static final String PROP_CACHE_TTL = "menu.cache.ttl-seconds"; + static final String DEFAULT_CACHE_TTL = "3600"; + /** 스냅샷이 비어있을 때의 재시도 TTL(초) — 시딩 전 기동 직후 요청 대비 */ + private static final long EMPTY_RETRY_TTL_SECONDS = 60; + + private final PortalMenuDataService menuDataService; + private final PortalPropertyService portalPropertyService; + + private volatile MenuSnapshot snapshot; + + public MenuSnapshot getSnapshot() { + MenuSnapshot current = snapshot; + if (current != null && !current.isExpired()) { + return current; + } + synchronized (this) { + current = snapshot; + if (current != null && !current.isExpired()) { + return current; + } + return reload(); + } + } + + /** + * DB 에서 트리를 재구축한다. 시더 완료 시점과 admin reload 명령이 호출한다. + */ + public synchronized MenuSnapshot reload() { + long ttlSeconds = resolveTtlSeconds(); + List items = menuDataService.loadAllItems(); + List placements = menuDataService.loadAllPlacements(); + + Map itemsById = items.stream() + .collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new)); + + // 가시(visible=Y) 배치만 렌더 트리에 포함 + Map> byParent = placements.stream() + .filter(PortalMenuPlacement::isVisible) + .filter(placement -> itemsById.containsKey(placement.getMenuId())) + .collect(Collectors.groupingBy( + placement -> placement.getParentId() == null ? "" : placement.getParentId(), + LinkedHashMap::new, Collectors.toList())); + + List roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent); + + // 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지) + Map> accessRolesByPath = new LinkedHashMap<>(); + for (PortalMenuItem item : items) { + if (item.getMenuPath() != null && item.getAccessRoles() != null + && !item.getAccessRoles().trim().isEmpty()) { + accessRolesByPath.put(normalizePath(item.getMenuPath()), splitCsv(item.getAccessRoles())); + } + } + + if (roots.isEmpty()) { + ttlSeconds = Math.min(ttlSeconds, EMPTY_RETRY_TTL_SECONDS); + } + MenuSnapshot rebuilt = new MenuSnapshot(roots, accessRolesByPath, items.size(), + System.currentTimeMillis(), ttlSeconds); + snapshot = rebuilt; + log.info("메뉴 캐시 갱신 - 항목 {}건, 최상위 {}건, TTL {}초", items.size(), roots.size(), ttlSeconds); + return rebuilt; + } + + private List buildNodes(List placements, + Map itemsById, + Map> byParent) { + List nodes = new ArrayList<>(); + for (PortalMenuPlacement placement : placements) { + PortalMenuItem item = itemsById.get(placement.getMenuId()); + MenuNode node = toNode(item); + node.setChildren(buildNodes( + byParent.getOrDefault(item.getMenuId(), Collections.emptyList()), itemsById, byParent)); + nodes.add(node); + } + return nodes; + } + + private MenuNode toNode(PortalMenuItem item) { + MenuNode node = new MenuNode(); + node.setMenuId(item.getMenuId()); + node.setName(item.getMenuName()); + node.setPath(normalizePath(item.getMenuPath())); + node.setGroup(item.isGroup()); + node.setSection(item.getMenuSection()); + node.setIcon(item.getIconClass()); + node.setNewWindow("Y".equals(item.getNewWindowYn())); + node.setExposeRoles(splitCsv(item.getExposeRoles())); + node.setAccessRoles(splitCsv(item.getAccessRoles())); + return node; + } + + private long resolveTtlSeconds() { + try { + String value = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_CACHE_TTL, + DEFAULT_CACHE_TTL, "포탈 메뉴 캐시 TTL(초)"); + return Long.parseLong(value.trim()); + } catch (Exception e) { + log.warn("메뉴 캐시 TTL 조회 실패 - 기본값 {}초 사용", DEFAULT_CACHE_TTL, e); + return Long.parseLong(DEFAULT_CACHE_TTL); + } + } + + private static List splitCsv(String csv) { + if (csv == null || csv.trim().isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(token -> !token.isEmpty()) + .collect(Collectors.toList()); + } + + private static String normalizePath(String path) { + if (path == null || path.trim().isEmpty()) { + return null; + } + String normalized = path.trim(); + int queryIndex = normalized.indexOf('?'); + return queryIndex >= 0 ? normalized.substring(0, queryIndex) : normalized; + } + + /** role 비의존 메뉴 스냅샷 (불변 취급) */ + @Getter + public static class MenuSnapshot { + private final List roots; + private final Map> accessRolesByPath; + private final int itemCount; + private final long loadedAt; + private final long ttlSeconds; + + MenuSnapshot(List roots, Map> accessRolesByPath, + int itemCount, long loadedAt, long ttlSeconds) { + this.roots = roots; + this.accessRolesByPath = accessRolesByPath; + this.itemCount = itemCount; + this.loadedAt = loadedAt; + this.ttlSeconds = ttlSeconds; + } + + boolean isExpired() { + return System.currentTimeMillis() - loadedAt > ttlSeconds * 1000L; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java b/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java new file mode 100644 index 0000000..c50a133 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java @@ -0,0 +1,108 @@ +package com.eactive.apim.portal.djb.menu; + +import lombok.Getter; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 요청별(role 필터 적용) 메뉴 뷰 — 템플릿 모델 {@code menuView}. + * + *

    + *
  • {@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)
  • + *
  • {@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)
  • + *
  • {@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색
  • + *
+ */ +@Getter +public class MenuView { + + /** + * service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지. + * 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵. + */ + private static final Map LEGACY_ACTIVE_KEYS = new HashMap<>(); + + static { + LEGACY_ACTIVE_KEYS.put("intro", "service-intro"); + LEGACY_ACTIVE_KEYS.put("guide", "service-guide"); + LEGACY_ACTIVE_KEYS.put("oauth2", "service-oauth2-guide"); + LEGACY_ACTIVE_KEYS.put("webhookGuide", "service-webhook-dev-guide"); + LEGACY_ACTIVE_KEYS.put("notice", "support-notice"); + LEGACY_ACTIVE_KEYS.put("faq", "support-faq"); + LEGACY_ACTIVE_KEYS.put("qna", "support-qna"); + LEGACY_ACTIVE_KEYS.put("feedback", "support-partnership"); + LEGACY_ACTIVE_KEYS.put("users", "my-page-users"); + LEGACY_ACTIVE_KEYS.put("apiKey", "my-page-clients"); + LEGACY_ACTIVE_KEYS.put("webhook", "my-page-webhook"); + LEGACY_ACTIVE_KEYS.put("statistics", "my-page-statistics"); + LEGACY_ACTIVE_KEYS.put("profile", "my-page-profile"); + LEGACY_ACTIVE_KEYS.put("password", "my-page-password"); + } + + private final List gnb; + private final MenuNode mypage; + private final String currentPath; + + public MenuView(List gnb, MenuNode mypage, String currentPath) { + this.gnb = gnb; + this.mypage = mypage; + this.currentPath = currentPath; + } + + /** + * 사이드바용 활성 그룹. legacy activeMenu 키 → 메뉴 ID 우선, + * 실패 시 현재 경로(정확/prefix) 매칭으로 폴백. + */ + public MenuNode activeGroup(String activeMenu) { + String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu); + MenuNode byId = targetId == null ? null : findRootContaining(node -> targetId.equals(node.getMenuId())); + if (byId != null) { + return byId; + } + return findRootContaining(this::matchesCurrentPath); + } + + /** + * 사이드바 항목 활성 여부: legacy 키 매칭 우선, 경로 매칭 폴백. + */ + public boolean isActive(MenuNode node, String activeMenu) { + String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu); + if (targetId != null) { + return targetId.equals(node.getMenuId()); + } + return matchesCurrentPath(node); + } + + private MenuNode findRootContaining(java.util.function.Predicate predicate) { + for (MenuNode root : allRoots()) { + if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) { + return root; + } + } + return null; + } + + private List allRoots() { + if (mypage == null) { + return gnb; + } + java.util.ArrayList roots = new java.util.ArrayList<>(gnb); + roots.add(mypage); + return roots; + } + + private boolean matchesCurrentPath(MenuNode node) { + if (!node.hasPath() || currentPath == null) { + return false; + } + return currentPath.equals(node.getPath()) + || currentPath.startsWith(node.getPath() + "/"); + } + + public static MenuView empty() { + return new MenuView(Collections.emptyList(), null, null); + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java b/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java new file mode 100644 index 0000000..a08a63f --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java @@ -0,0 +1,38 @@ +package com.eactive.apim.portal.djb.menu; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * menu.yml 바인딩 (spring.config.import 로 로드). + * 트리(children 중첩) 형태 그대로 바인딩하며, 평탄화는 {@link MenuSeeder} 가 수행한다. + */ +@Data +@Component +@ConfigurationProperties(prefix = "portal-menu") +public class PortalMenuYmlProperties { + + private List items = new ArrayList<>(); + + @Data + public static class MenuItemDef { + /** kebab-case 자연키 (^[a-z0-9-]+$) */ + private String id; + private String name; + private String path; + /** true = 클릭 없는 상위 그룹 */ + private boolean group; + /** GNB(기본) | MYPAGE */ + private String section; + /** FontAwesome 아이콘 클래스 (마이페이지 드롭다운) */ + private String icon; + private boolean newWindow; + private List exposeRoles = new ArrayList<>(); + private List accessRoles = new ArrayList<>(); + private List children = new ArrayList<>(); + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java b/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java new file mode 100644 index 0000000..f2196ba --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java @@ -0,0 +1,47 @@ +package com.eactive.apim.portal.djb.menu; + +import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * roles.yml 바인딩 (spring.config.import 로 로드). + * 기존 application.yml 의 {@code portal.portal_security} 를 대체한다. + * 로그인 시 권한 확장(PortalUserAuthService)이 직접 사용하며, + * DB 미러(PTL_ROLE / PTL_ROLE_AUTHORITY)는 {@link MenuSeeder} 가 적재한다. + */ +@Data +@Component +@ConfigurationProperties(prefix = "portal-roles") +public class PortalRolesProperties { + + private List roles = new ArrayList<>(); + + /** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */ + private Map authorityNames = new LinkedHashMap<>(); + + @Data + public static class RoleDef { + private String code; + private String name; + private List authorities = new ArrayList<>(); + } + + /** + * 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록. + */ + public List getAuthorities(RoleCode roleCode) { + return roles.stream() + .filter(role -> role.getCode().equals(roleCode.name())) + .findFirst() + .map(RoleDef::getAuthorities) + .orElse(Collections.emptyList()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 62850d9..99312fc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,10 @@ server: spring: + config: + import: + - classpath:menu.yml + - classpath:roles.yml data: web: pageable: @@ -218,26 +222,7 @@ portal: view-name: apps/apis/static/tokenApiSpec bean: apiHandler - portal_security: - ROLE_USER: - - ROLE_INQUIRY - - ROLE_ACCOUNT - ROLE_CORP_USER: - - ROLE_API_KEY_REQUEST - - ROLE_API_KEY_REQUEST_VIEW - - ROLE_INQUIRY - - ROLE_APP - - ROLE_ACCOUNT - ROLE_CORP_MANAGER: - - ROLE_API_KEY_REQUEST - - ROLE_API_KEY_REQUEST_VIEW - - ROLE_WEBHOOK - - ROLE_INQUIRY - - ROLE_APP - - ROLE_ACCOUNT - - ROLE_CORP_API - - ROLE_DASHBOARD - - ROLE_USER_MANAGER + # portal_security 는 roles.yml (portal-roles) 로 이동 page: home: name: "Home" diff --git a/src/main/resources/menu.yml b/src/main/resources/menu.yml new file mode 100644 index 0000000..0adb99e --- /dev/null +++ b/src/main/resources/menu.yml @@ -0,0 +1,55 @@ +# ------------------------------------------------------------------------------ +# 포탈 GNB 메뉴 정의 (menu.yml) +# +# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면 +# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다. +# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다. +# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행. +# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지. +# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작. +# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운) +# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자, +# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of) +# ------------------------------------------------------------------------------ +portal-menu: + items: + - id: service + name: "서비스 소개" + group: true + children: + - { id: service-intro, name: "API 포탈 소개", path: /service/intro } + - { id: service-guide, name: "회원가입 안내", path: /service/guide } + - { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide } + - { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide } + + - { id: open-api, name: "오픈 API", path: /apis } + + - id: support + name: "고객지원" + group: true + children: + - { id: support-notice, name: "공지사항", path: /portalnotice } + - { id: support-faq, name: "FAQ", path: /faq_list } + - { id: support-qna, name: "Q&A", path: /inquiry } + - { id: support-partnership, name: "피드백/개선요청", path: /partnership } + + - { id: api-status, name: "API Status", path: /apistatus } + + - id: my-page + name: "마이페이지" + group: true + section: MYPAGE + expose-roles: [AUTHENTICATED] + children: + - { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users, + expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] } + - { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key, + expose-roles: [ROLE_APP], access-roles: [ROLE_APP] } + - { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell, + expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] } + - { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar, + expose-roles: [ROLE_APP], access-roles: [ROLE_APP] } + - { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle, + expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] } + - { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock, + expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] } diff --git a/src/main/resources/roles.yml b/src/main/resources/roles.yml new file mode 100644 index 0000000..c4061da --- /dev/null +++ b/src/main/resources/roles.yml @@ -0,0 +1,47 @@ +# ------------------------------------------------------------------------------ +# 포탈 역할 정의 (roles.yml) +# +# - 기존 application.yml 의 portal.portal_security 를 이동한 파일. +# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다. +# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며, +# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다. +# ------------------------------------------------------------------------------ +portal-roles: + roles: + - code: ROLE_USER + name: "개인사용자" + authorities: + - ROLE_INQUIRY + - ROLE_ACCOUNT + - code: ROLE_CORP_USER + name: "법인사용자" + authorities: + - ROLE_API_KEY_REQUEST + - ROLE_API_KEY_REQUEST_VIEW + - ROLE_INQUIRY + - ROLE_APP + - ROLE_ACCOUNT + - code: ROLE_CORP_MANAGER + name: "법인관리자" + authorities: + - ROLE_API_KEY_REQUEST + - ROLE_API_KEY_REQUEST_VIEW + - ROLE_WEBHOOK + - ROLE_INQUIRY + - ROLE_APP + - ROLE_ACCOUNT + - ROLE_CORP_API + - ROLE_DASHBOARD + - ROLE_USER_MANAGER + + # PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용 + authority-names: + ROLE_INQUIRY: "문의 작성" + ROLE_ACCOUNT: "계정 관리" + ROLE_API_KEY_REQUEST: "인증키 신청" + ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회" + ROLE_APP: "앱/API 신청 관리" + ROLE_WEBHOOK: "웹훅 관리" + ROLE_CORP_API: "법인 API 조회" + ROLE_DASHBOARD: "대시보드" + ROLE_USER_MANAGER: "개발자 관리" diff --git a/src/main/resources/templates/views/fragment/djbank/header_container.html b/src/main/resources/templates/views/fragment/djbank/header_container.html index 392e8ae..c0994da 100644 --- a/src/main/resources/templates/views/fragment/djbank/header_container.html +++ b/src/main/resources/templates/views/fragment/djbank/header_container.html @@ -27,28 +27,22 @@ + @@ -76,20 +70,18 @@ 로그아웃 -
- + + @@ -184,72 +176,43 @@
- + +