메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현

This commit is contained in:
Rinjae
2026-08-05 14:19:33 +09:00
parent 2c63fa1557
commit 5585df9133
20 changed files with 1312 additions and 170 deletions
+1
View File
@@ -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 설치 가이드 - **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글) - **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글) - **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh` - **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
@@ -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 화면에 표기된다.
@@ -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.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil; import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil; 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.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final PortalUserRepository portalUserRepository; private final PortalUserRepository portalUserRepository;
private final PortalUserMapper portalUserMapper; private final PortalUserMapper portalUserMapper;
private final PortalProperties portalProperties; private final PortalRolesProperties portalRolesProperties;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService; private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository; private final MessageRequestRepository messageRequestRepository;
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
*/ */
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) { public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode(); RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole); List<String> roles = portalRolesProperties.getAuthorities(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser); PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name())); authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
.csrfTokenRepository(csrfTokenRepository) .csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*")) .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**")) .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
) )
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면 // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져 // 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
private final Environment environment; private final Environment environment;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService; 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.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled). // 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고). // prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public PortalConfigWebDispatcherServlet(Environment environment, public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService, 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.environment = environment;
this.twoFactorService = twoFactorService; this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties; this.twoFactorProperties = twoFactorProperties;
this.menuService = menuService;
} }
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns(staticExcludes) .excludePathPatterns(staticExcludes)
.excludePathPatterns("/auth/2fa/**"); .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 @Bean
@@ -1,13 +1,11 @@
package com.eactive.apim.portal.config; package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.pagerouter.property.PageRoute; import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
@Component @Component
@@ -25,8 +23,6 @@ public class PortalProperties {
private String authVirtualCode = ""; private String authVirtualCode = "";
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties(); private FileProperties file = new FileProperties();
/** /**
@@ -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) 서버측 집행.
*
* <p>요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다
* (하위 경로는 기존 안전망 @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<String> 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<String> 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;
}
}
@@ -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 명령 수신용.
*
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
*
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
*/
@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<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) {
Map<String, Object> 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<String, Object> 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<String> 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;
}
}
@@ -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 와 동일한 방식).
*
* <p>노출 판정: 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<MenuNode> 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<MenuNode> 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<String> 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;
}
}
@@ -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<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuNode> 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();
}
}
@@ -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 적재.
*
* <ul>
* <li>역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)</li>
* <li>메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고,
* 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다</li>
* <li>배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩</li>
* <li>yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다</li>
* </ul>
*
* 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로,
* 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다
* (AuditingHandler 는 auditor 부재 시 기존 값을 보존).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MenuSeeder implements ApplicationListener<ApplicationReadyEvent> {
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<String> 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<String, String> 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<PortalRoleAuthority> 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<FlatMenuDef> 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<String> 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<PortalMenuItem, String> defaultGetter,
Function<PortalMenuItem, String> currentGetter,
BiConsumer<PortalMenuItem, String> currentSetter,
BiConsumer<PortalMenuItem, String> 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<PortalMenuItem> portalItems =
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL);
List<PortalMenuPlacement> placements = menuDataService.buildDefaultPlacements(portalItems);
placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR));
menuPlacementRepository.saveAll(placements);
log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size());
}
// ─────────────── 평탄화 / 검증 ───────────────
private List<FlatMenuDef> flatten(List<PortalMenuYmlProperties.MenuItemDef> roots) {
List<FlatMenuDef> 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<FlatMenuDef> defs) {
Set<String> 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<String> 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();
}
}
}
@@ -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;
/**
* 메뉴 트리 스냅샷 캐시.
*
* <p>매 요청 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<PortalMenuItem> items = menuDataService.loadAllItems();
List<PortalMenuPlacement> placements = menuDataService.loadAllPlacements();
Map<String, PortalMenuItem> itemsById = items.stream()
.collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new));
// 가시(visible=Y) 배치만 렌더 트리에 포함
Map<String, List<PortalMenuPlacement>> 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<MenuNode> roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent);
// 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지)
Map<String, List<String>> 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<MenuNode> buildNodes(List<PortalMenuPlacement> placements,
Map<String, PortalMenuItem> itemsById,
Map<String, List<PortalMenuPlacement>> byParent) {
List<MenuNode> 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<String> 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<MenuNode> roots;
private final Map<String, List<String>> accessRolesByPath;
private final int itemCount;
private final long loadedAt;
private final long ttlSeconds;
MenuSnapshot(List<MenuNode> roots, Map<String, List<String>> 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;
}
}
}
@@ -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}.
*
* <ul>
* <li>{@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)</li>
* <li>{@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)</li>
* <li>{@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색</li>
* </ul>
*/
@Getter
public class MenuView {
/**
* service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지.
* 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵.
*/
private static final Map<String, String> 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<MenuNode> gnb;
private final MenuNode mypage;
private final String currentPath;
public MenuView(List<MenuNode> 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<MenuNode> predicate) {
for (MenuNode root : allRoots()) {
if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) {
return root;
}
}
return null;
}
private List<MenuNode> allRoots() {
if (mypage == null) {
return gnb;
}
java.util.ArrayList<MenuNode> 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);
}
}
@@ -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<MenuItemDef> 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<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuItemDef> children = new ArrayList<>();
}
}
@@ -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<RoleDef> roles = new ArrayList<>();
/** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */
private Map<String, String> authorityNames = new LinkedHashMap<>();
@Data
public static class RoleDef {
private String code;
private String name;
private List<String> authorities = new ArrayList<>();
}
/**
* 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록.
*/
public List<String> getAuthorities(RoleCode roleCode) {
return roles.stream()
.filter(role -> role.getCode().equals(roleCode.name()))
.findFirst()
.map(RoleDef::getAuthorities)
.orElse(Collections.emptyList());
}
}
+5 -20
View File
@@ -23,6 +23,10 @@ server:
spring: spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
data: data:
web: web:
pageable: pageable:
@@ -218,26 +222,7 @@ portal:
view-name: apps/apis/static/tokenApiSpec view-name: apps/apis/static/tokenApiSpec
bean: apiHandler bean: apiHandler
portal_security: # portal_security 는 roles.yml (portal-roles) 로 이동
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
page: page:
home: home:
name: "Home" name: "Home"
+55
View File
@@ -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] }
+47
View File
@@ -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: "개발자 관리"
@@ -27,28 +27,22 @@
</div> </div>
</div> </div>
<!-- 상단 GNB: DB 메뉴(menuView) 기반 렌더 — .nav-menu > li / .sub-menu 구조는 하단 JS 의존 -->
<nav class="header-center"> <nav class="header-center">
<ul class="nav-menu"> <ul class="nav-menu">
<li> <li th:each="menu : ${menuView.gnb}">
<a href="#" class="nav-link">서비스 소개</a> <th:block th:if="${!menu.children.isEmpty()}">
<ul class="sub-menu"> <a th:href="${menu.hasPath()} ? @{${menu.path}} : '#'" class="nav-link" th:text="${menu.name}">메뉴</a>
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li> <ul class="sub-menu">
<li><a th:href="@{/service/guide}">회원가입 안내</a></li> <li th:each="child : ${menu.children}">
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li> <a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li> th:text="${child.name}">하위 메뉴</a>
</ul> </li>
</ul>
</th:block>
<a th:if="${menu.children.isEmpty()}" th:href="${menu.hasPath()} ? @{${menu.path}} : '#'"
th:target="${menu.newWindow} ? '_blank' : null" class="nav-link" th:text="${menu.name}">메뉴</a>
</li> </li>
<li><a href="/apis" class="nav-link">오픈 API</a></li>
<li>
<a href="#playground" class="nav-link">고객지원</a>
<ul class="sub-menu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
<li><a th:href="@{/apistatus}" class="nav-link">API Status</a></li>
</ul> </ul>
</nav> </nav>
@@ -76,20 +70,18 @@
<span class="divider"></span> <span class="divider"></span>
<a th:href="@{/actionLogout.do}" class="header-link">로그아웃</a> <a th:href="@{/actionLogout.do}" class="header-link">로그아웃</a>
<span class="divider"></span> <span class="divider"></span>
<div class="mypage-dropdown"> <!-- 마이페이지 드롭다운: DB 메뉴(menuView.mypage) 기반, 노출 권한은 서버 필터 적용 -->
<button class="header-link mypage-toggle" type="button">마이페이지</button> <div class="mypage-dropdown" th:if="${menuView.mypage != null}">
<button class="header-link mypage-toggle" type="button"
th:text="${menuView.mypage.name}">마이페이지</button>
<div class="mypage-dropdown-menu"> <div class="mypage-dropdown-menu">
<ul class="mypage-menu-list"> <ul class="mypage-menu-list">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"> <li th:each="child : ${menuView.mypage.children}">
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a> <a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null">
<i th:if="${child.icon != null}" th:class="'fas ' + ${child.icon}"></i>
<th:block th:text="${child.name}">메뉴</th:block>
</a>
</li> </li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/clients}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/password/change}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -184,72 +176,43 @@
</div> </div>
</div> </div>
<!-- Navigation Menu (Accordion Style) --> <!-- Navigation Menu (Accordion Style): DB 메뉴(menuView) 기반 렌더 -->
<!-- .drawer-menu-item / .drawer-menu-btn / .drawer-submenu 구조는 하단 JS 의존 -->
<nav class="drawer-nav"> <nav class="drawer-nav">
<ul class="drawer-menu-list"> <ul class="drawer-menu-list">
<!-- 서비스 소개 --> <li th:each="menu : ${menuView.gnb}" class="drawer-menu-item"
<li class="drawer-menu-item has-submenu"> th:classappend="${!menu.children.isEmpty()} ? 'has-submenu' : ''">
<button class="drawer-menu-btn" type="button"> <th:block th:if="${!menu.children.isEmpty()}">
<span>서비스 소개</span> <button class="drawer-menu-btn" type="button">
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg"> <span th:text="${menu.name}">메뉴</span>
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
</svg> <path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</button> </svg>
<ul class="drawer-submenu"> </button>
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li> <ul class="drawer-submenu">
<li><a th:href="@{/service/guide}">회원가입 안내</a></li> <li th:each="child : ${menu.children}">
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li> <a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li> th:text="${child.name}">하위 메뉴</a>
</ul> </li>
</ul>
</th:block>
<a th:if="${menu.children.isEmpty()}" th:href="${menu.hasPath()} ? @{${menu.path}} : '#'"
th:target="${menu.newWindow} ? '_blank' : null" class="drawer-menu-btn" th:text="${menu.name}">메뉴</a>
</li> </li>
<!-- API --> <!-- 마이페이지 (노출 권한 서버 필터 — 미노출 시 null) -->
<li class="drawer-menu-item has-submenu"> <li class="drawer-menu-item has-submenu" th:if="${menuView.mypage != null}">
<button class="drawer-menu-btn" type="button"> <button class="drawer-menu-btn" type="button">
<span>API</span> <span th:text="${menuView.mypage.name}">마이페이지</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
<ul class="drawer-submenu"> <ul class="drawer-submenu">
<li><a th:href="@{/apis}">API 마켓</a></li> <li th:each="child : ${menuView.mypage.children}">
</ul> <a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
</li> th:text="${child.name}">하위 메뉴</a>
<!-- 고객지원 --> </li>
<li class="drawer-menu-item has-submenu">
<button class="drawer-menu-btn" type="button">
<span>고객지원</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul>
</li>
<!-- API Status -->
<li class="drawer-menu-item">
<a th:href="@{/apistatus}" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
<button class="drawer-menu-btn" type="button">
<span>마이페이지</span>
<svg class="accordion-icon" width="14" height="7" viewBox="0 0 14 7" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 6L13 1" stroke="#212529" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/password/change}">비밀번호 변경</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -2,41 +2,13 @@
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<body> <body>
<!-- Service Left Sidebar Fragment --> <!-- Service Left Sidebar Fragment: DB 메뉴(menuView) 기반 렌더.
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)"> activeMenu 파라미터는 기존 호출부 호환용 레거시 키(MenuView.LEGACY_ACTIVE_KEYS 브리지). -->
<nav class="service-nav"> <aside class="service-sidebar" th:fragment="sidebar(activeMenu)"
<!-- 서비스 소개 그룹 (Service) --> th:with="grp=${menuView.activeGroup(activeMenu)}">
<th:block <nav class="service-nav" th:if="${grp != null}">
th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}"> <!-- 마이페이지 그룹 프로필 -->
<a th:href="@{/service/intro}" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''" <th:block th:if="${grp.section == 'MYPAGE'}">
class="service-nav__item">API 포탈 소개</a>
<a th:href="@{/service/guide}" th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">회원가입 안내</a>
<a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block>
<!-- 고객지원 그룹 (Customer Support) -->
<th:block
th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
<a th:href="@{/portalnotice}" th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
class="service-nav__item">공지사항</a>
<a th:href="@{/faq_list}" th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
class="service-nav__item">FAQ</a>
<a th:href="@{/inquiry}" th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
class="service-nav__item">Q&A</a>
<a th:href="@{/partnership}" th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
class="service-nav__item">피드백/개선요청</a>
</th:block>
<!-- 마이페이지 그룹 (My Page) -->
<th:block
th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section -->
<div class="service-sidebar__profile"> <div class="service-sidebar__profile">
<div class="avatar"> <div class="avatar">
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
@@ -48,29 +20,14 @@
</div> </div>
<div class="service-sidebar__divider"></div> <div class="service-sidebar__divider"></div>
<a th:href="@{/users}" th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">API 신청 관리</a>
<a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
<a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">이용통계</a>
<a th:href="@{/mypage}" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/password/change}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block> </th:block>
<a th:each="child : ${grp.children}" th:href="@{${child.path}}"
th:target="${child.newWindow} ? '_blank' : null"
th:classappend="${menuView.isActive(child, activeMenu)} ? 'service-nav__item--active' : ''"
class="service-nav__item" th:text="${child.name}">메뉴</a>
</nav> </nav>
</aside> </aside>
</body> </body>
</html> </html>