Compare commits
16 Commits
f36b1a6478
..
design
| Author | SHA1 | Date | |
|---|---|---|---|
| 63d4d7268c | |||
| c42a278c8e | |||
| 83352e3669 | |||
| 317b8d781d | |||
| 206e72edde | |||
| 34dce308dc | |||
| b3fc5c06bf | |||
| eb74a99a5a | |||
| 38e5a7f13c | |||
| 3b7c2e5a8f | |||
| 47099ec485 | |||
| 6dbf6af3de | |||
| 5585df9133 | |||
| 2c63fa1557 | |||
| d71af34950 | |||
| fbc145d145 |
@@ -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 화면에 표기된다.
|
||||||
@@ -4,11 +4,23 @@ public interface AuthNumberService {
|
|||||||
|
|
||||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
|
||||||
|
*/
|
||||||
|
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||||
*/
|
*/
|
||||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
|
||||||
|
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
|
||||||
|
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
|
||||||
|
*/
|
||||||
|
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
|
||||||
|
|
||||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
|
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
|
||||||
|
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||||
|
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
|
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
|
||||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||||
|
|
||||||
validateResendTime(recipientKey);
|
validateResendTime(recipientKey);
|
||||||
|
|
||||||
String authNumber = generator.generateAuthNumber();
|
String authNumber = generator.generateAuthNumber();
|
||||||
|
|
||||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
|
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
|
||||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||||
|
|
||||||
storage.saveAuthNumber(recipientKey, authNumber,
|
storage.saveAuthNumber(recipientKey, authNumber,
|
||||||
@@ -99,9 +111,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) {
|
private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
MessageRecipient recipient = new MessageRecipient();
|
||||||
recipient.setUserId(recipientKey);
|
recipient.setUserId(recipientKey);
|
||||||
|
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
|
||||||
|
if (username != null && !username.trim().isEmpty()) {
|
||||||
|
recipient.setUsername(username);
|
||||||
|
}
|
||||||
if ("SMS".equalsIgnoreCase(msgType)) {
|
if ("SMS".equalsIgnoreCase(msgType)) {
|
||||||
recipient.setPhone(recipientKey);
|
recipient.setPhone(recipientKey);
|
||||||
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ public class PortalNoticeDTO {
|
|||||||
private String state;
|
private String state;
|
||||||
private String previousState;
|
private String previousState;
|
||||||
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
|
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
|
||||||
private List<TimelineEntryDTO> timeline = Collections.emptyList();
|
private List<TimelineEntryDTO> timeline = Collections.emptyList();
|
||||||
|
|
||||||
|
|||||||
+7
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
|
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
|
||||||
|
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
|
||||||
|
*/
|
||||||
|
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -5,7 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
|||||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
||||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
|
||||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
|
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||||
@@ -17,8 +19,10 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
private final DjbApistatusIncidentRepository incidentRepository;
|
private final DjbApistatusIncidentRepository incidentRepository;
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
private final ApiStatusAssembler apiStatusAssembler;
|
private final ApiStatusAssembler apiStatusAssembler;
|
||||||
|
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<PortalNoticeDTO> getLatestNotices() {
|
public List<PortalNoticeDTO> getLatestNotices() {
|
||||||
@@ -85,11 +90,22 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||||
|
|
||||||
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
|
||||||
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
|
||||||
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
|
||||||
.collect(Collectors.toList());
|
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
|
||||||
|
int hiddenCount = 0;
|
||||||
|
for (DjbApistatusIncidentApi api :
|
||||||
|
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
|
||||||
|
String publishedName = visibleNames.get(api.getApiId());
|
||||||
|
if (publishedName == null) {
|
||||||
|
hiddenCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
|
||||||
|
}
|
||||||
dto.setAffectedApis(apis);
|
dto.setAffectedApis(apis);
|
||||||
|
dto.setHiddenApiCount(hiddenCount);
|
||||||
|
|
||||||
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
|
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
|
||||||
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
|
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
|
|||||||
private final AuthNoticeProperties authNoticeProperties;
|
private final AuthNoticeProperties authNoticeProperties;
|
||||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입·아이디/비밀번호 찾기 등 로그인 이전 흐름은 수신자 이름을 알 수 없으므로
|
||||||
|
* 메시지 템플릿 %USER_NAME% 자리에 넣을 기본값.
|
||||||
|
*/
|
||||||
|
private static final String GUEST_USER_NAME = "guest";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증 요청
|
* 인증 요청
|
||||||
@@ -43,7 +49,7 @@ public class AuthFacadeImpl implements AuthFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
|
||||||
response.setValid(true);
|
response.setValid(true);
|
||||||
response.setMessage("인증번호를 발송하였습니다.");
|
response.setMessage("인증번호를 발송하였습니다.");
|
||||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.eactive.apim.portal.config.PortalProperties;
|
|||||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||||
|
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||||
|
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AuthNoticeProperties authNoticeProperties;
|
private AuthNoticeProperties authNoticeProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RelatedSiteService relatedSiteService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private Environment environment;
|
private Environment environment;
|
||||||
|
|
||||||
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
|
|||||||
return portalPropertyService.getOrCreateProperty(
|
return portalPropertyService.getOrCreateProperty(
|
||||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("relatedSitesLabel")
|
||||||
|
public String relatedSitesLabel() {
|
||||||
|
return relatedSiteService.getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
|
||||||
|
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("relatedSites")
|
||||||
|
public List<RelatedSite> relatedSites() {
|
||||||
|
return relatedSiteService.getSites();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-21
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.migration;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -20,6 +21,8 @@ import java.util.Arrays;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||||
@@ -29,15 +32,19 @@ import java.util.Map;
|
|||||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||||
*
|
*
|
||||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
||||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
||||||
|
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
||||||
|
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||||
|
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
||||||
|
* 작업 완료 후 이 클래스는 제거한다.</p>
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* # 미리보기(변경 안 함)
|
* # 미리보기(변경 안 함)
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||||
* # 실제 실행 (PII 컬럼)
|
* # 실제 실행 (PII 컬럼)
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@@ -46,13 +53,14 @@ import java.util.Map;
|
|||||||
@RequestMapping("/internal/migration")
|
@RequestMapping("/internal/migration")
|
||||||
public class LegacyEncryptionMigrationController {
|
public class LegacyEncryptionMigrationController {
|
||||||
|
|
||||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
||||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
||||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
||||||
|
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||||
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
|
|||||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
||||||
|
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
||||||
);
|
);
|
||||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||||
|
|
||||||
|
static final String PROP_GROUP = "Portal";
|
||||||
|
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
||||||
|
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||||
|
|
||||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
||||||
|
PortalPropertyService portalPropertyService) {
|
||||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||||
|
this.portalPropertyService = portalPropertyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/encrypt-legacy")
|
@PostMapping("/encrypt-legacy")
|
||||||
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
|
|||||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||||
assertLocalOnly(request);
|
assertAllowedIp(request);
|
||||||
assertNotBypass();
|
assertNotBypass();
|
||||||
|
|
||||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||||
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
|
|||||||
|
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
int totalChanged = 0;
|
int totalChanged = 0;
|
||||||
|
int totalSkipped = 0;
|
||||||
for (TargetTable target : targets) {
|
for (TargetTable target : targets) {
|
||||||
for (String column : target.columns) {
|
for (String column : target.columns) {
|
||||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||||
results.add(r);
|
results.add(r);
|
||||||
totalChanged += (int) r.get("changed");
|
totalChanged += (int) r.get("changed");
|
||||||
|
totalSkipped += (int) r.get("skipped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||||
|
response.put("damoMode", resolveDamoMode());
|
||||||
response.put("includeAudit", includeAudit);
|
response.put("includeAudit", includeAudit);
|
||||||
response.put("totalChanged", totalChanged);
|
response.put("totalChanged", totalChanged);
|
||||||
|
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
||||||
|
response.put("totalSkipped", totalSkipped);
|
||||||
response.put("results", results);
|
response.put("results", results);
|
||||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
||||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveDamoMode() {
|
||||||
|
if (converter.isBypassMode()) {
|
||||||
|
return "BYPASS";
|
||||||
|
}
|
||||||
|
return converter.isFakeMode() ? "FAKE" : "REAL";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||||
*/
|
*/
|
||||||
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
|
|||||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||||
r.put("distinct", 0);
|
r.put("distinct", 0);
|
||||||
r.put("changed", 0);
|
r.put("changed", 0);
|
||||||
|
r.put("skipped", 0);
|
||||||
r.put("error", e.getMessage());
|
r.put("error", e.getMessage());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
|
int skipped = 0;
|
||||||
for (String value : values) {
|
for (String value : values) {
|
||||||
String normalized;
|
String normalized;
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
|
|||||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
||||||
|
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
||||||
|
skipped++;
|
||||||
|
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
||||||
|
table, column, value.length());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (normalized != null && !normalized.equals(value)) {
|
if (normalized != null && !normalized.equals(value)) {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
|
|||||||
|
|
||||||
r.put("distinct", values.size());
|
r.put("distinct", values.size());
|
||||||
r.put("changed", changed);
|
r.put("changed", changed);
|
||||||
|
r.put("skipped", skipped);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,21 +207,45 @@ public class LegacyEncryptionMigrationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
||||||
|
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
||||||
|
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
||||||
|
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
||||||
*/
|
*/
|
||||||
private void assertLocalOnly(HttpServletRequest request) {
|
private void assertAllowedIp(HttpServletRequest request) {
|
||||||
String remote = request.getRemoteAddr();
|
String remote = canonicalize(request.getRemoteAddr());
|
||||||
boolean localAddr = "127.0.0.1".equals(remote)
|
|
||||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
|
||||||
|| "::1".equals(remote);
|
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
if (!localAddr || viaProxy) {
|
|
||||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
||||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
.map(String::trim)
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
.filter(ip -> !ip.isEmpty())
|
||||||
|
.map(LegacyEncryptionMigrationController::canonicalize)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
if (viaProxy || !allowed.contains(remote)) {
|
||||||
|
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
||||||
|
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
||||||
|
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
private static final class TargetTable {
|
private static final class TargetTable {
|
||||||
final String table;
|
final String table;
|
||||||
final List<String> columns;
|
final List<String> columns;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class ActiveIncidentDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String kind;
|
private String kind;
|
||||||
private String state;
|
private String state;
|
||||||
private String stateLabel;
|
private String stateLabel;
|
||||||
@@ -24,5 +28,7 @@ public class ActiveIncidentDTO {
|
|||||||
private LocalDateTime startedAt;
|
private LocalDateTime startedAt;
|
||||||
private long elapsedMinutes;
|
private long elapsedMinutes;
|
||||||
private List<AffectedApiDTO> apis = new ArrayList<>();
|
private List<AffectedApiDTO> apis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class MaintenanceCardDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String title;
|
private String title;
|
||||||
private String summary;
|
private String summary;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -23,6 +27,8 @@ public class MaintenanceCardDTO {
|
|||||||
private LocalDateTime endAt;
|
private LocalDateTime endAt;
|
||||||
private Long durationMinutes;
|
private Long durationMinutes;
|
||||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime registeredAt;
|
private LocalDateTime registeredAt;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public class PastIssueCardDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String kind;
|
private String kind;
|
||||||
private String state;
|
private String state;
|
||||||
private String stateLabel;
|
private String stateLabel;
|
||||||
@@ -29,5 +33,7 @@ public class PastIssueCardDTO {
|
|||||||
private LocalDate dateGroup;
|
private LocalDate dateGroup;
|
||||||
private Long durationMinutes;
|
private Long durationMinutes;
|
||||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
private List<TimelineEntryDTO> timeline = new ArrayList<>();
|
private List<TimelineEntryDTO> timeline = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -11,6 +11,9 @@ import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusInciden
|
|||||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
|
||||||
|
import com.eactive.apim.portal.apps.community.notice.repository.PortalNoticeRepository;
|
||||||
|
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -37,6 +40,42 @@ public class ApiStatusAssembler {
|
|||||||
|
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
private final DjbApistatusIncidentTimelineRepository timelineRepository;
|
private final DjbApistatusIncidentTimelineRepository timelineRepository;
|
||||||
|
private final PortalNoticeRepository portalNoticeRepository;
|
||||||
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영향 인터페이스를 "개발자포탈에 게시된 API" 와 그 외 GW 인터페이스로 가른 결과.
|
||||||
|
* 게시된 것만 개별 노출하고 나머지는 건수로만 알린다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public static class VisibleApis {
|
||||||
|
private final List<AffectedApiDTO> visible;
|
||||||
|
private final int hiddenCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈 영향 인터페이스에서 현재 사용자에게 공개된 API 만 남긴다.
|
||||||
|
*
|
||||||
|
* <p>이름은 이슈에 캐시된 EAI 서비스명이 아니라 포털 노출명(PTL_API_SPEC_INFO)을 쓴다 -
|
||||||
|
* 같은 API 가 화면마다 다른 이름으로 보이지 않게 한다.</p>
|
||||||
|
*/
|
||||||
|
private VisibleApis splitByVisibility(List<AffectedApiDTO> apis, Map<String, String> visibleNames) {
|
||||||
|
if (apis == null || apis.isEmpty()) {
|
||||||
|
return new VisibleApis(Collections.emptyList(), 0);
|
||||||
|
}
|
||||||
|
List<AffectedApiDTO> visible = new ArrayList<>();
|
||||||
|
int hidden = 0;
|
||||||
|
for (AffectedApiDTO api : apis) {
|
||||||
|
String publishedName = visibleNames.get(api.getApiId());
|
||||||
|
if (publishedName == null) {
|
||||||
|
hidden++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visible.add(new AffectedApiDTO(api.getApiId(), publishedName, api.getRecoveredAt()));
|
||||||
|
}
|
||||||
|
return new VisibleApis(visible, hidden);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
|
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
|
||||||
if (incidentIds == null || incidentIds.isEmpty()) {
|
if (incidentIds == null || incidentIds.isEmpty()) {
|
||||||
@@ -69,6 +108,30 @@ public class ApiStatusAssembler {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈에 연결된 공지. 게시 중(USE_YN='Y')인 것만 담는다.
|
||||||
|
*
|
||||||
|
* <p>조회 자체의 공개 조건({@code ApiStatusIncidentQueryRepository.VISIBLE})과 같은 기준이라
|
||||||
|
* 여기서 빠지는 건은 공지가 삭제된 고아 행뿐이다.</p>
|
||||||
|
*/
|
||||||
|
public Map<String, PortalNotice> loadNotices(Collection<String> noticeIds) {
|
||||||
|
if (noticeIds == null || noticeIds.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
List<String> ids = noticeIds.stream()
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
Map<String, PortalNotice> result = new HashMap<>();
|
||||||
|
for (PortalNotice notice : portalNoticeRepository.findByIdInAndUseYn(ids, "Y")) {
|
||||||
|
result.put(notice.getId(), notice);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
|
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
|
||||||
if (incidents.isEmpty()) {
|
if (incidents.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
@@ -76,6 +139,10 @@ public class ApiStatusAssembler {
|
|||||||
List<Long> ids = incidentIds(incidents);
|
List<Long> ids = incidentIds(incidents);
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
|
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
|
||||||
|
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||||
|
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||||
|
.map(DjbApistatusIncident::getNoticeId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
List<ActiveIncidentDTO> result = new ArrayList<>();
|
List<ActiveIncidentDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
@@ -89,7 +156,16 @@ public class ApiStatusAssembler {
|
|||||||
dto.setSummary(incident.getSummary());
|
dto.setSummary(incident.getSummary());
|
||||||
dto.setStartedAt(incident.getStartedAt());
|
dto.setStartedAt(incident.getStartedAt());
|
||||||
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
|
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
|
||||||
dto.setApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
VisibleApis affected = splitByVisibility(
|
||||||
|
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||||
|
dto.setApis(affected.getVisible());
|
||||||
|
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||||
|
|
||||||
|
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||||
|
if (notice != null) {
|
||||||
|
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||||
|
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||||
|
}
|
||||||
|
|
||||||
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
|
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
|
||||||
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
|
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
|
||||||
@@ -104,19 +180,33 @@ public class ApiStatusAssembler {
|
|||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
|
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
|
||||||
|
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||||
|
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||||
|
.map(DjbApistatusIncident::getNoticeId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
List<MaintenanceCardDTO> result = new ArrayList<>();
|
List<MaintenanceCardDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
MaintenanceCardDTO dto = new MaintenanceCardDTO();
|
MaintenanceCardDTO dto = new MaintenanceCardDTO();
|
||||||
dto.setIncidentId(incident.getIncidentId());
|
dto.setIncidentId(incident.getIncidentId());
|
||||||
dto.setNoticeId(incident.getNoticeId());
|
dto.setNoticeId(incident.getNoticeId());
|
||||||
|
|
||||||
|
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||||
|
if (notice != null) {
|
||||||
|
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||||
|
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||||
|
}
|
||||||
|
|
||||||
dto.setTitle(incident.getTitle());
|
dto.setTitle(incident.getTitle());
|
||||||
dto.setSummary(incident.getSummary());
|
dto.setSummary(incident.getSummary());
|
||||||
dto.setStartedAt(incident.getStartedAt());
|
dto.setStartedAt(incident.getStartedAt());
|
||||||
dto.setEndAt(incident.getEndAt());
|
dto.setEndAt(incident.getEndAt());
|
||||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||||
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
VisibleApis affected = splitByVisibility(
|
||||||
|
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||||
|
dto.setImpactedApis(affected.getVisible());
|
||||||
|
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||||
dto.setRegisteredAt(incident.getCreatedDate());
|
dto.setRegisteredAt(incident.getCreatedDate());
|
||||||
dto.setLastModifiedAt(incident.getLastModifiedDate());
|
dto.setLastModifiedAt(incident.getLastModifiedDate());
|
||||||
result.add(dto);
|
result.add(dto);
|
||||||
@@ -133,18 +223,29 @@ public class ApiStatusAssembler {
|
|||||||
}
|
}
|
||||||
List<Long> ids = incidentIds(incidents);
|
List<Long> ids = incidentIds(incidents);
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||||
|
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||||
|
|
||||||
List<Long> incidentKindIds = incidents.stream()
|
List<Long> incidentKindIds = incidents.stream()
|
||||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||||
.map(DjbApistatusIncident::getIncidentId)
|
.map(DjbApistatusIncident::getIncidentId)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
|
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
|
||||||
|
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
|
||||||
|
.map(DjbApistatusIncident::getNoticeId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
List<PastIssueCardDTO> result = new ArrayList<>();
|
List<PastIssueCardDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
PastIssueCardDTO dto = new PastIssueCardDTO();
|
PastIssueCardDTO dto = new PastIssueCardDTO();
|
||||||
dto.setIncidentId(incident.getIncidentId());
|
dto.setIncidentId(incident.getIncidentId());
|
||||||
dto.setNoticeId(incident.getNoticeId());
|
dto.setNoticeId(incident.getNoticeId());
|
||||||
|
|
||||||
|
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
|
||||||
|
if (notice != null) {
|
||||||
|
dto.setNoticeSubject(notice.getNoticeSubject());
|
||||||
|
dto.setNoticeDetail(notice.getNoticeDetail());
|
||||||
|
}
|
||||||
|
|
||||||
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
|
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
|
||||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
||||||
@@ -155,7 +256,10 @@ public class ApiStatusAssembler {
|
|||||||
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
|
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
|
||||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||||
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
VisibleApis affected = splitByVisibility(
|
||||||
|
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||||
|
dto.setImpactedApis(affected.getVisible());
|
||||||
|
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||||
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
||||||
result.add(dto);
|
result.add(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -18,7 +18,9 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +82,23 @@ public class ApiStatusCatalogService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자에게 공개된 API 의 {API ID → 노출명} 맵.
|
||||||
|
*
|
||||||
|
* <p>GW 인터페이스는 전부 이슈 데이터(DJB_APISTATUS_INCIDENT_API)에 들어오지만
|
||||||
|
* 개발자포탈에 게시되는 것은 그 일부(= API)뿐이다. 화면은 이 맵에 있는 것만
|
||||||
|
* 개별 이름으로 노출하고 나머지는 건수로 묶는다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@link #getSelectableApis()} 와 같은 경로라 역할·소속에 따른 공개 범위가 그대로 반영된다.</p>
|
||||||
|
*/
|
||||||
|
public Map<String, String> getVisibleApiNames() {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
for (ApiOptionDTO api : getSelectableApis()) {
|
||||||
|
names.put(api.getApiId(), api.getApiName());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
|
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.eactive.apim.portal.djb.footer;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터 "관련 사이트" 셀렉트에 노출되는 사이트 한 건.
|
||||||
|
*
|
||||||
|
* <p>{@link RelatedSiteService} 가 PortalProperty 문자열을 파싱해 만든다.</p>
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RelatedSite {
|
||||||
|
|
||||||
|
/** 셀렉트에 표시되는 이름 (예: 신한은행) */
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
/** 이동 대상 URL (http/https 절대주소 또는 `/` 로 시작하는 사이트 상대주소) */
|
||||||
|
private final String url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.eactive.apim.portal.djb.footer;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터 "관련 사이트" 셀렉트의 라벨과 목록을 DB(PortalProperty)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
|
||||||
|
* 의 자동 생성(self-seed)이 동작하도록 한다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code footer.related-sites.label} - 셀렉트 첫 항목(플레이스홀더) 문구</li>
|
||||||
|
* <li>{@code footer.related-sites} - 사이트 목록. 한 줄에 하나씩 {@code 이름=URL}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>목록 표기 규칙</h3>
|
||||||
|
* <pre>
|
||||||
|
* # 주석 (# 으로 시작하는 줄은 무시)
|
||||||
|
* 신한은행=http://www.shinhan.com
|
||||||
|
* 제주은행 = https://www.jejubank.co.kr ← = 앞뒤 공백 허용
|
||||||
|
* </pre>
|
||||||
|
* <ul>
|
||||||
|
* <li>빈 줄 · {@code #} 로 시작하는 줄은 건너뛴다.</li>
|
||||||
|
* <li>줄 순서가 곧 화면 노출 순서다.</li>
|
||||||
|
* <li>URL 에 {@code =} 가 들어가도 <b>첫 번째</b> {@code =} 만 구분자로 쓰므로 안전하다.</li>
|
||||||
|
* <li>{@code =} 가 없거나 이름/URL 이 비었거나 허용되지 않은 스킴이면 그 줄만 버리고 WARN 로그를
|
||||||
|
* 남긴다. (한 줄이 잘못돼도 나머지 사이트는 정상 노출)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>허용 스킴을 {@code http/https} 와 사이트 상대경로로 제한하는 이유는, 이 값이 관리자 화면에서
|
||||||
|
* 편집되어 그대로 앵커/스크립트 이동 대상이 되기 때문이다({@code javascript:} 등 차단).</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RelatedSiteService {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
|
||||||
|
static final String NAME_LABEL = "footer.related-sites.label";
|
||||||
|
static final String NAME_SITES = "footer.related-sites";
|
||||||
|
|
||||||
|
static final String DESC_LABEL = "푸터 관련 사이트 셀렉트 라벨(첫 항목 문구)";
|
||||||
|
static final String DESC_SITES = "푸터 관련 사이트 목록. 한 줄에 하나씩 '이름=URL' (빈 줄·# 주석 무시)";
|
||||||
|
|
||||||
|
static final String DEFAULT_LABEL = "DJBank 관련 사이트";
|
||||||
|
|
||||||
|
/** 기본값: 신한금융그룹 Family site (shinhangroup.com 하단 목록 기준) */
|
||||||
|
static final String DEFAULT_SITES = String.join("\n",
|
||||||
|
"신한은행=http://www.shinhan.com",
|
||||||
|
"신한카드=http://www.shinhancard.com",
|
||||||
|
"신한투자증권=http://www.shinhansec.com",
|
||||||
|
"신한라이프=http://www.shinhanlife.co.kr",
|
||||||
|
"신한캐피탈=http://www.shcap.co.kr",
|
||||||
|
"신한자산운용=https://www.shinhanfund.com",
|
||||||
|
"제주은행=https://www.jejubank.co.kr",
|
||||||
|
"신한저축은행=http://www.shinhansavings.co.kr",
|
||||||
|
"신한자산신탁=http://www.shinhantrust.kr",
|
||||||
|
"신한DS=http://www.shinhansys.co.kr",
|
||||||
|
"신한펀드파트너스=https://www.shinhanfundpartners.com",
|
||||||
|
"신한리츠운용=http://shinhanrem.com",
|
||||||
|
"신한벤처투자=http://www.shinhanvc.com",
|
||||||
|
"신한EZ손해보험=http://www.shinhanez.co.kr",
|
||||||
|
"신한장학재단=http://www.shsf.or.kr",
|
||||||
|
"신한금융희망재단=http://www.shinhanfoundation.or.kr");
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 셀렉트 첫 항목에 노출할 라벨 */
|
||||||
|
@Transactional
|
||||||
|
public String getLabel() {
|
||||||
|
String label = portalPropertyService.getOrCreateProperty(GROUP, NAME_LABEL, DEFAULT_LABEL, DESC_LABEL);
|
||||||
|
return (label == null || label.trim().isEmpty()) ? DEFAULT_LABEL : label.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 셀렉트에 노출할 사이트 목록. 파싱 결과가 없으면 빈 리스트(푸터에서 셀렉트 미노출) */
|
||||||
|
@Transactional
|
||||||
|
public List<RelatedSite> getSites() {
|
||||||
|
return parse(portalPropertyService.getOrCreateProperty(GROUP, NAME_SITES, DEFAULT_SITES, DESC_SITES));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code 이름=URL} 줄 목록을 파싱한다. 잘못된 줄은 건너뛴다.
|
||||||
|
*/
|
||||||
|
static List<RelatedSite> parse(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RelatedSite> sites = new ArrayList<>();
|
||||||
|
for (String rawLine : raw.split("\\r?\\n")) {
|
||||||
|
String line = rawLine.trim();
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sep = line.indexOf('=');
|
||||||
|
if (sep <= 0) {
|
||||||
|
log.warn("관련 사이트 설정 형식 오류(= 구분자 없음) - 해당 줄 무시: {}", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = line.substring(0, sep).trim();
|
||||||
|
String url = line.substring(sep + 1).trim();
|
||||||
|
if (name.isEmpty() || url.isEmpty()) {
|
||||||
|
log.warn("관련 사이트 설정 형식 오류(이름 또는 URL 없음) - 해당 줄 무시: {}", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isAllowedUrl(url)) {
|
||||||
|
log.warn("관련 사이트 설정 URL 스킴 불허(http/https 또는 / 로 시작해야 함) - 해당 줄 무시: {}", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sites.add(new RelatedSite(name, url));
|
||||||
|
}
|
||||||
|
return sites;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAllowedUrl(String url) {
|
||||||
|
String lower = url.toLowerCase();
|
||||||
|
return lower.startsWith("http://") || lower.startsWith("https://") || url.startsWith("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,91 @@
|
|||||||
|
package com.eactive.apim.portal.djb.menu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||||
|
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.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||||
|
*
|
||||||
|
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
||||||
|
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
||||||
|
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
||||||
|
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||||
|
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
||||||
|
* 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";
|
||||||
|
static final String PROP_ALLOW_IPS_DESCRIPTION =
|
||||||
|
"메뉴 내부 API(리로드) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
|
||||||
|
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
|
||||||
|
|
||||||
|
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 = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||||
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
|
|
||||||
|
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), 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, PROP_ALLOW_IPS_DESCRIPTION);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||||
|
return DEFAULT_ALLOW_IPS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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] }
|
||||||
@@ -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: "개발자 관리"
|
||||||
@@ -762,7 +762,7 @@ hr {
|
|||||||
.global-header .container .header-content {
|
.global-header .container .header-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.global-header {
|
.global-header {
|
||||||
height: clamp(44px, 11.73vw, 60px);
|
height: clamp(44px, 11.73vw, 60px);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
@@ -795,9 +795,26 @@ hr {
|
|||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.header-left .logo {
|
.header-left .logo {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.header-left .logo img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-left .logo .mobile-logo-link {
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-left .logo .mobile-logo-text {
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
@@ -899,7 +916,7 @@ hr {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.desktop-header {
|
.desktop-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -911,7 +928,7 @@ hr {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1508,7 @@ hr {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--primary-blue);
|
color: var(--primary-blue);
|
||||||
@@ -1517,7 +1535,7 @@ hr {
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.global-header {
|
.global-header {
|
||||||
height: clamp(44px, 11.73vw, 60px);
|
height: clamp(44px, 11.73vw, 60px);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
@@ -1639,7 +1657,7 @@ hr {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.header-user-info {
|
.header-user-info {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -5411,6 +5429,7 @@ select.form-control {
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.login-btn:hover {
|
.login-btn:hover {
|
||||||
background-color: var(--accent-light);
|
background-color: var(--accent-light);
|
||||||
@@ -5423,6 +5442,7 @@ select.form-control {
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
@@ -8840,8 +8860,9 @@ button.djb-comment-submit:disabled {
|
|||||||
}
|
}
|
||||||
.api-showcase .api-cards-container {
|
.api-showcase .api-cards-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 29px;
|
gap: 29px;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.api-showcase .api-cards-container {
|
.api-showcase .api-cards-container {
|
||||||
@@ -8858,6 +8879,9 @@ button.djb-comment-submit:disabled {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.api-showcase .api-card {
|
.api-showcase .api-card {
|
||||||
|
flex: 0 0 calc((100% - 87px) / 4);
|
||||||
|
max-width: calc((100% - 87px) / 4);
|
||||||
|
box-sizing: border-box;
|
||||||
height: 288px;
|
height: 288px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -8883,12 +8907,16 @@ button.djb-comment-submit:disabled {
|
|||||||
}
|
}
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.api-showcase .api-card {
|
.api-showcase .api-card {
|
||||||
|
flex: 0 0 calc(50% - 9px);
|
||||||
|
max-width: calc(50% - 9px);
|
||||||
width: calc(50% - 9px);
|
width: calc(50% - 9px);
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.api-showcase .api-card {
|
.api-showcase .api-card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
@@ -12727,7 +12755,7 @@ body.index-page-body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
border-bottom: 1px solid #000;
|
border-bottom: 1px solid #818181;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
@@ -18988,6 +19016,12 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.affected-api-badge.is-gw {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-affected-toggle {
|
.btn-affected-toggle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -20874,7 +20908,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
background: none;
|
background: none;
|
||||||
padding: 0 0 16px 0;
|
padding: 0 0 16px 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid #212529;
|
border-bottom: 1px solid #818181;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
.org-section-header--agreement h3 {
|
.org-section-header--agreement h3 {
|
||||||
@@ -25971,7 +26005,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1.5px solid #4a4a4a;
|
border-bottom: 1.5px solid #818181;
|
||||||
}
|
}
|
||||||
.step1-wrap .s1-form-card .webhook-card-head .head-title-group {
|
.step1-wrap .s1-form-card .webhook-card-head .head-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -26619,6 +26653,80 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: var(--as-muted);
|
color: var(--as-muted);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.api-status .as-api-pills .as-api-pill.is-gw {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--as-border-strong);
|
||||||
|
color: var(--as-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.api-status .as-card-split {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
.api-status .as-card-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.api-status .as-card-split > .as-alert-notice {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-card .as-card-split > .as-alert-timeline {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.api-status .as-maint-card .as-card-split > .as-maint-body {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius);
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-head strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--as-text);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--as-pill);
|
||||||
|
background: var(--as-info-bg);
|
||||||
|
color: var(--as-info);
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-body {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--as-text-2);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-body img, .api-status .as-alert-notice .as-alert-notice-body table {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.api-status .as-alert-notice .as-alert-notice-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-info);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
.api-status .as-alert-card {
|
.api-status .as-alert-card {
|
||||||
border-left: 4px solid var(--as-err);
|
border-left: 4px solid var(--as-err);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@
|
|||||||
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
||||||
const base = page.getAttribute('data-base') || '/apistatus';
|
const base = page.getAttribute('data-base') || '/apistatus';
|
||||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||||
|
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||||
|
|
||||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||||
const linkableApiIds = new Set();
|
const linkableApiIds = new Set();
|
||||||
@@ -174,21 +175,61 @@
|
|||||||
|
|
||||||
// ---------------- 이슈 카드 ----------------
|
// ---------------- 이슈 카드 ----------------
|
||||||
/**
|
/**
|
||||||
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
|
* 영향 API 태그.
|
||||||
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
|
*
|
||||||
|
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||||
|
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||||
*/
|
*/
|
||||||
function apiPillsHtml(apis) {
|
function apiPillsHtml(apis, hiddenCount) {
|
||||||
if (!apis || !apis.length) return '';
|
const hidden = hiddenCount || 0;
|
||||||
const pills = apis.map(function (api) {
|
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||||
|
|
||||||
|
const pills = (apis || []).map(function (api) {
|
||||||
const text = escapeHtml(api.apiName || api.apiId);
|
const text = escapeHtml(api.apiName || api.apiId);
|
||||||
if (!linkableApiIds.has(api.apiId)) {
|
if (!linkableApiIds.has(api.apiId)) {
|
||||||
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
|
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||||
+ text + '</span>';
|
|
||||||
}
|
}
|
||||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||||
+ text + '</a>';
|
+ text + '</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + '</div>';
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
|
||||||
|
const hiddenPill = hidden > 0
|
||||||
|
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||||
|
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||||
|
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||||
|
*/
|
||||||
|
function noticeHtml(issue) {
|
||||||
|
if (!issue.noticeDetail) return '';
|
||||||
|
const link = issue.noticeId
|
||||||
|
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||||
|
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
|
||||||
|
: '';
|
||||||
|
return '<div class="as-alert-notice">'
|
||||||
|
+ '<div class="as-alert-notice-head">'
|
||||||
|
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||||
|
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
|
||||||
|
+ link
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||||
|
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||||
|
*/
|
||||||
|
function splitHtml(left, right) {
|
||||||
|
if (!left) return right;
|
||||||
|
if (!right) return left;
|
||||||
|
return '<div class="as-card-split">' + left + right + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueCardHtml(issue) {
|
function issueCardHtml(issue) {
|
||||||
@@ -222,8 +263,8 @@
|
|||||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||||
+ inner
|
+ splitHtml(inner, noticeHtml(issue))
|
||||||
+ apiPillsHtml(issue.impactedApis)
|
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
|
||||||
+ '</article>';
|
+ '</article>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
const authenticated = page.classList.contains('is-authenticated');
|
const authenticated = page.classList.contains('is-authenticated');
|
||||||
const base = page.getAttribute('data-base') || '/apistatus';
|
const base = page.getAttribute('data-base') || '/apistatus';
|
||||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||||
|
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||||
|
|
||||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||||
const linkableApiIds = new Set();
|
const linkableApiIds = new Set();
|
||||||
@@ -87,21 +88,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
|
* 영향 API 태그.
|
||||||
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
|
*
|
||||||
|
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||||
|
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||||
*/
|
*/
|
||||||
function apiPillsHtml(apis, label) {
|
function apiPillsHtml(apis, label, hiddenCount) {
|
||||||
if (!apis || !apis.length) return '';
|
const hidden = hiddenCount || 0;
|
||||||
const pills = apis.map(function (api) {
|
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||||
|
|
||||||
|
const pills = (apis || []).map(function (api) {
|
||||||
const text = escapeHtml(api.apiName || api.apiId);
|
const text = escapeHtml(api.apiName || api.apiId);
|
||||||
if (!linkableApiIds.has(api.apiId)) {
|
if (!linkableApiIds.has(api.apiId)) {
|
||||||
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
|
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||||
+ text + '</span>';
|
|
||||||
}
|
}
|
||||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||||
+ text + '</a>';
|
+ text + '</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + '</div>';
|
|
||||||
|
const hiddenPill = hidden > 0
|
||||||
|
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||||
|
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||||
|
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||||
|
*/
|
||||||
|
function noticeHtml(incident) {
|
||||||
|
if (!incident.noticeDetail) return '';
|
||||||
|
const link = incident.noticeId
|
||||||
|
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||||
|
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
|
||||||
|
: '';
|
||||||
|
return '<div class="as-alert-notice">'
|
||||||
|
+ '<div class="as-alert-notice-head">'
|
||||||
|
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||||
|
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
|
||||||
|
+ link
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||||
|
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||||
|
*/
|
||||||
|
function splitHtml(left, right) {
|
||||||
|
if (!left) return right;
|
||||||
|
if (!right) return left;
|
||||||
|
return '<div class="as-card-split">' + left + right + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- ❶ 진행 중 장애 ----------------
|
// ---------------- ❶ 진행 중 장애 ----------------
|
||||||
@@ -139,8 +179,9 @@
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
|
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
|
||||||
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
|
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
|
||||||
+ apiPillsHtml(incident.apis, '영향 API:')
|
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
|
||||||
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
|
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
|
||||||
|
noticeHtml(incident))
|
||||||
+ '</section>';
|
+ '</section>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -252,8 +293,9 @@
|
|||||||
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
|
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
|
||||||
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
|
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
|
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
|
||||||
+ apiPillsHtml(card.impactedApis, '영향 API:')
|
noticeHtml(card))
|
||||||
|
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
|
||||||
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
|
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
|
||||||
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
|
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -300,8 +342,8 @@
|
|||||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||||
+ inner
|
+ splitHtml(inner, noticeHtml(issue))
|
||||||
+ apiPillsHtml(issue.impactedApis, '영향 API:')
|
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
|
||||||
+ '</article>'
|
+ '</article>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--accent-light);
|
background-color: var(--accent-light);
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
height: clamp(44px, 11.73vw, 60px);
|
height: clamp(44px, 11.73vw, 60px);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -128,9 +128,31 @@
|
|||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
|
||||||
|
img { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.mobile-logo-link {
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logo-text {
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
// "API / Portal" 로 접히지 않게
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +273,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +284,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -981,6 +1003,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary-blue);
|
color: var(--primary-blue);
|
||||||
@@ -1095,7 +1119,7 @@
|
|||||||
// Mobile Responsive Design
|
// Mobile Responsive Design
|
||||||
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
|
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
|
||||||
// ===========================
|
// ===========================
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.global-header {
|
.global-header {
|
||||||
// Figma: 44px 높이, 반투명 배경
|
// Figma: 44px 높이, 반투명 배경
|
||||||
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
|
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
|
||||||
@@ -1246,7 +1270,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mobile responsive
|
// Mobile responsive
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.header-user-info {
|
.header-user-info {
|
||||||
display: none; // Hide on mobile, use drawer instead
|
display: none; // Hide on mobile, use drawer instead
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,83 @@
|
|||||||
color: var(--as-muted);
|
color: var(--as-muted);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||||
|
.as-api-pill.is-gw {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--as-border-strong);
|
||||||
|
color: var(--as-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임라인 | 공지 2열. 데스크탑(961px~)에서만 갈라지고 그 아래는 세로로 쌓인다.
|
||||||
|
// minmax(0, 1fr) 은 공지 본문의 긴 URL·표가 칸을 밀어내지 못하게 하는 grid 관용구.
|
||||||
|
.as-card-split {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 감싼 두 블록의 자체 세로 margin 은 grid gap 으로 대체한다.
|
||||||
|
// 카드 컨텍스트를 함께 써서 원 규칙(.as-alert-card .as-alert-timeline 등)보다 특이도를 높인다.
|
||||||
|
.as-card-split > .as-alert-notice { margin-top: 0; }
|
||||||
|
.as-alert-card .as-card-split > .as-alert-timeline { margin-top: 0; }
|
||||||
|
.as-maint-card .as-card-split > .as-maint-body { margin-top: 0; }
|
||||||
|
|
||||||
|
// 이슈에 연결된 공지 본문 (진행 중 장애 카드 · 점검 카드 공용).
|
||||||
|
// 본문 길이가 제각각이라 높이를 제한하고 안쪽만 스크롤시킨다.
|
||||||
|
.as-alert-notice {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius);
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
|
||||||
|
.as-alert-notice-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
strong { font-size: 14px; color: var(--as-text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--as-pill);
|
||||||
|
background: var(--as-info-bg);
|
||||||
|
color: var(--as-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-body {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--as-text-2);
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
img, table { max-width: 100%; }
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-info);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 진행 중 장애 카드 ----------------
|
// ---------------- 진행 중 장애 카드 ----------------
|
||||||
|
|||||||
@@ -859,8 +859,9 @@
|
|||||||
|
|
||||||
.api-cards-container {
|
.api-cards-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 29px;
|
gap: 29px;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
|
||||||
//@include respond-to('lg') {
|
//@include respond-to('lg') {
|
||||||
// gap: 22px;
|
// gap: 22px;
|
||||||
@@ -881,6 +882,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-card {
|
.api-card {
|
||||||
|
// 1행 4열 고정 (gap 29px * 3 = 87px)
|
||||||
|
flex: 0 0 calc((100% - 87px) / 4);
|
||||||
|
max-width: calc((100% - 87px) / 4);
|
||||||
|
box-sizing: border-box;
|
||||||
height: 288px;
|
height: 288px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -909,12 +914,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
|
flex: 0 0 calc(50% - 9px);
|
||||||
|
max-width: calc(50% - 9px);
|
||||||
width: calc(50% - 9px);
|
width: calc(50% - 9px);
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 144px width, auto height, padding 18px 17px
|
// Figma 모바일: 144px width, auto height, padding 18px 17px
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
border-bottom: 1px solid #000;
|
border-bottom: 1px solid #818181;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,14 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||||
|
&.is-gw {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-affected-toggle {
|
.btn-affected-toggle {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
padding: 0 0 $spacing-md 0;
|
padding: 0 0 $spacing-md 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid #212529;
|
border-bottom: 1px solid #818181;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1.5px solid #4a4a4a;
|
border-bottom: 1.5px solid #818181;
|
||||||
|
|
||||||
.head-title-group {
|
.head-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -78,29 +78,27 @@
|
|||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
<tr
|
||||||
|
th:if="${(portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()) or portalNotice.hiddenApiCount > 0}">
|
||||||
<th>영향 API</th>
|
<th>영향 API</th>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
<div class="affected-apis-wrapper">
|
<div class="affected-apis-wrapper">
|
||||||
<!-- First 3 APIs (Always visible) -->
|
<!-- 개발자포탈에 게시된 API 만 개별 노출한다 (인터페이스 ID 는 표시하지 않음) -->
|
||||||
<div class="affected-apis-list">
|
<div class="affected-apis-list">
|
||||||
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
||||||
th:if="${iterStat.index < 3}">
|
th:if="${iterStat.index < 3}" th:text="${api.apiName}">API 명</span>
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
|
||||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
|
||||||
th:text="| - ${api.apiName}|"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Rest of APIs (Hidden by default) -->
|
<!-- Rest of APIs (Hidden by default) -->
|
||||||
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
||||||
<span class="affected-api-badge extra-api"
|
<span class="affected-api-badge extra-api"
|
||||||
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
||||||
style="display: none;">
|
style="display: none;" th:text="${api.apiName}">API 명</span>
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
|
||||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
|
||||||
th:text="| - ${api.apiName}|"></span>
|
|
||||||
</span>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 게시되지 않은 GW 인터페이스는 건수로만 묶어서 표기 -->
|
||||||
|
<span class="affected-api-badge is-gw" th:if="${portalNotice.hiddenApiCount > 0}"
|
||||||
|
th:text="|GW 인터페이스 ${portalNotice.hiddenApiCount}건|"
|
||||||
|
title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">GW 인터페이스 0건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toggle Button -->
|
<!-- Toggle Button -->
|
||||||
|
|||||||
@@ -226,8 +226,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-cards-container">
|
<div class="api-cards-container">
|
||||||
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
|
<!-- main.service.list 에 등록된 서비스 전체 표시 (1행 4열, 초과분은 다음 행으로) -->
|
||||||
<div class="api-card" th:each="service, iterStat : ${services}" th:if="${iterStat.index < 4}"
|
<div class="api-card" th:each="service : ${services}"
|
||||||
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
||||||
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
||||||
<p class="card-description">
|
<p class="card-description">
|
||||||
|
|||||||
@@ -6,34 +6,7 @@
|
|||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="service-intro">
|
<section class="service-intro">
|
||||||
<h1 class="service-intro__title">DJBank 개발자포탈 소개</h1>
|
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
|
||||||
|
|
||||||
<p class="service-intro__lead">기업의 업무 흐름 속으로, 금융이 흘러 들어갑니다.</p>
|
|
||||||
|
|
||||||
<p class="service-intro__desc">
|
|
||||||
제주은행의 디지털 기업금융 특화 브랜드 'DJ Bank'의 금융 서비스를<br>
|
|
||||||
핀테크 기업·개발자·ERP/SaaS 파트너가 손쉽게 연결해 활용할 수 있도록 지원하는<br>
|
|
||||||
통합 Open API 포털입니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="service-intro__spacer" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<h2 class="service-intro__section-title">구축 배경</h2>
|
|
||||||
<div class="service-intro__section-body">
|
|
||||||
<p>기존 기업금융은 서류 부담, 시간 지연, 대면중심 절차의 한계가 있었습니다. 또한 폐쇄적인 금융시스템 안에서 핀테크·SaaS 기업이 금융 기능을 자사 서비스에 결합하기 어려웠습니다.</p>
|
|
||||||
<p>DJ Bank는 ERP 데이터와 AI를 결합한 자율형 금융 플랫폼을 지향하며, 이 비전을 실현하기 위해 개방형 API 생태계를 구축했습니다. 기업이 본업의 흐름을 끊지 않고 금융을 이용할 수 있도록, 핵심 금융 자원을 파트너와 개발자에게 개방합니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service-intro__spacer" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<h2 class="service-intro__section-title">제공 API 카테고리</h2>
|
|
||||||
<ul class="service-intro__list">
|
|
||||||
<li>계좌 · 수신 - 법인 계좌개설·조회, 법인 파킹통장 연계</li>
|
|
||||||
<li>여신 · 대출 - ERP 연계 매출채권 담보대출, AX 솔루션자금</li>
|
|
||||||
<li>신용 · 평가 - 대안신용평가 전략모형 기반 기업 신용 조회</li>
|
|
||||||
<li>이체 · 결제 - 펌뱅킹, 실시간이체, 결제 API</li>
|
|
||||||
<li>인증 · 보안 - OAuth 2.0, 전자서명, 마이데이터 인증</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
</th:block>
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -114,7 +114,6 @@
|
|||||||
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
||||||
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
|
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
|
||||||
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
||||||
<span class="eventtype-code" th:text="${et.code}">CODE</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-status" id="apiStatusPage"
|
<div class="api-status" id="apiStatusPage"
|
||||||
th:attr="data-window-days=${windowDays},data-base=@{/apistatus},data-api-detail-base=@{/apis/detail}"
|
th:attr="data-window-days=${windowDays},data-base=@{/apistatus},data-api-detail-base=@{/apis/detail},data-notice-detail-base=@{/portalnotice/detail}"
|
||||||
th:classappend="${authenticated} ? 'is-authenticated' : ''">
|
th:classappend="${authenticated} ? 'is-authenticated' : ''">
|
||||||
|
|
||||||
<!-- ❶ 진행 중 장애 -->
|
<!-- ❶ 진행 중 장애 -->
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
th:attr="data-window-days=${windowDays},
|
th:attr="data-window-days=${windowDays},
|
||||||
data-base=@{/apistatus},
|
data-base=@{/apistatus},
|
||||||
data-api-detail-base=@{/apis/detail},
|
data-api-detail-base=@{/apis/detail},
|
||||||
|
data-notice-detail-base=@{/portalnotice/detail},
|
||||||
data-today=${today},
|
data-today=${today},
|
||||||
data-min-date=${minDate},
|
data-min-date=${minDate},
|
||||||
data-selected-date=${selectedDate} ?: '',
|
data-selected-date=${selectedDate} ?: '',
|
||||||
|
|||||||
@@ -24,18 +24,31 @@
|
|||||||
<p class="footer-copyright">Copyright © 2026 JEJU Bank. All Rights Reserved.</p>
|
<p class="footer-copyright">Copyright © 2026 JEJU Bank. All Rights Reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
<div class="footer-related-sites">
|
<!-- 관련 사이트: PortalProperty(Portal/footer.related-sites[.label])로 제어. 목록이 비면 미노출 -->
|
||||||
<select class="related-sites-select">
|
<div class="footer-related-sites" th:if="${!#lists.isEmpty(relatedSites)}">
|
||||||
<option>DJBank 관련 사이트</option>
|
<select class="related-sites-select" data-related-sites th:aria-label="${relatedSitesLabel}">
|
||||||
<option>DJBank 홈페이지</option>
|
<option value="" th:text="${relatedSitesLabel}">DJBank 관련 사이트</option>
|
||||||
<option>DJBank 인터넷뱅킹</option>
|
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
|
||||||
<option>DJBank 모바일뱅킹</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script th:inline="none">
|
||||||
|
// 관련 사이트 셀렉트: 선택 시 새 창으로 이동 후 라벨(첫 항목)로 되돌린다.
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
var el = e.target;
|
||||||
|
if (!el || !el.hasAttribute || !el.hasAttribute('data-related-sites')) return;
|
||||||
|
var url = el.value;
|
||||||
|
el.selectedIndex = 0;
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -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()}">
|
||||||
|
<a th:href="${menu.hasPath()} ? @{${menu.path}} : '#'" class="nav-link" th:text="${menu.name}">메뉴</a>
|
||||||
<ul class="sub-menu">
|
<ul class="sub-menu">
|
||||||
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
<li th:each="child : ${menu.children}">
|
||||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
|
||||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
th:text="${child.name}">하위 메뉴</a>
|
||||||
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
|
||||||
</ul>
|
|
||||||
</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&A</a></li>
|
|
||||||
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
|
|
||||||
</ul>
|
</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 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' : ''">
|
||||||
|
<th:block th:if="${!menu.children.isEmpty()}">
|
||||||
<button class="drawer-menu-btn" type="button">
|
<button class="drawer-menu-btn" type="button">
|
||||||
<span>서비스 소개</span>
|
<span th:text="${menu.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="@{/service/intro}">API 포탈 소개</a></li>
|
<li th:each="child : ${menu.children}">
|
||||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
<a th:href="@{${child.path}}" th:target="${child.newWindow} ? '_blank' : null"
|
||||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
th:text="${child.name}">하위 메뉴</a>
|
||||||
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
</li>
|
||||||
</ul>
|
</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"
|
||||||
|
th:text="${child.name}">하위 메뉴</a>
|
||||||
</li>
|
</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&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,27 +20,12 @@
|
|||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user