56 Commits

Author SHA1 Message Date
Rinjae 0b6ee10439 Jenkinsfile.sonar: SonarQube 설정 이름 보정 및 스캐너 탐색 폴백
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- SONAR_ENV_NAME 을 실제 등록명 SonarQube-Community 로 수정
- 스캐너 툴 이름 미지정 시 후보 이름 탐색 후 PATH 의 sonar-scanner 폴백
- 스캐너를 못 찾으면 원인이 드러나는 메시지와 함께 실패

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:35:12 +09:00
Rinjae 1a95f015dc Jenkinsfile.sonar: 컴파일 전 생성 소스 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
clean 이 build/generated 잠금으로 실패하는 사례가 있어 rm -rf 를 선행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:32:15 +09:00
Rinjae 92589530d1 Merge branch 'master' into feats/ci-test
# Conflicts:
#	Jenkinsfile.deploy
#	Jenkinsfile.test-build
#	src/test/java/com/eactive/apim/portal/apps/user/AccountControllerTest.java
2026-08-13 15:30:31 +09:00
Rinjae be40973aa3 SonarQube 정적 보안 분석 파이프라인 추가
- Jenkinsfile.sonar: 분석 전용 파이프라인 (컴파일 JDK8 / 스캐너 JDK17 분리,
  Quality Gate 실패는 UNSTABLE 처리, 토큰은 환경변수로만 전달)
- sonar-project.properties: 분석 대상/제외 규칙, Java 8 소스 설정
- ci/sonar-classpath.gradle: build.gradle 무수정으로 sonar.java.libraries 덤프
  (타입 해석용 컴파일 클래스패스가 없으면 보안 룰 다수가 동작하지 않음)
- .gitignore: .scannerwork/ 제외

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:29:21 +09:00
Rinjae 1e21b85de1 - 소수 자릿수 처리 개선 - DECIMAL/BIGDECIMAL 정수/숫자 구분
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 샘플 생성 로직 리팩토링 - 길이 및 소수 자릿수 기준 상세화
- 공통 스키마 처리 함수 추가 - 코드 중복 제거 및 가독성 향상
2026-08-13 14:42:44 +09:00
Rinjae 7b8e0575c5 Actuator 인터셉터 엔드포인트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `/actuator/mappings`에 누락된 Interceptor 목록 읽기용 엔드포인트 구현
- 내부 사용자 이메일 도메인 동적 주입 로직 UserTypeConfigurer 클래스에 추가
- 비동기 스타일 개선 및 CSS 메인 스타일 일부 수정
2026-08-13 13:36:09 +09:00
Rinjae 7c0cd371f0 클라이언트 표현 수정 및 내부 사용자 이메일 도메인 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "앱" -> "클라이언트"로 명칭 일괄 수정 (HTML/DTO/JS)
- 내부 사용자 이메일 도메인 동적 설정 로직 및 기본값 추가
2026-08-13 12:50:50 +09:00
Rinjae eb2cce8002 Actuator/SBA 로컬 제한 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml 주석 및 build.gradle 로컬 전용 라이브러리 처리
- 배포 산출물에서 로컬 전용 리소스/라이브러리 제외
2026-08-12 11:48:37 +09:00
Rinjae 634c97c7ca 첨부파일 업로드 제약 조건 추가 및 APIM 서비스 파일 컨텍스트 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 첨부파일 업로드 시 파일 크기/형식 조건 동적 적용
- FileTypeContext.clear 호출 추가, 내부 사용자 컨텍스트 정리
2026-08-12 09:41:59 +09:00
Rinjae e32acfa215 UserLog 엔티티 필드 추가 및 관련 API User-Agent 처리 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- UserLog에 userAgent(500자)와 duplicate(Boolean) 컬럼 추가
- 로그인/실패/로그아웃 로깅 시 User-Agent와 중복 세션 여부 기록
- TwoFactorService/Controller Request 객체 전달 및 처리 반영
2026-08-11 19:10:39 +09:00
Rinjae 8356fc3029 - Swagger spec 서버 주소 GW 기준 치환 통합 - UI/다운로드 동일 기준
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- ApiTesterFilter: gw 호출 시 base-url 재조립 처리 추가
- admin 스펙 저장 시 실주소 고정 제거 - 재치환 유연성 확보
2026-08-11 19:00:18 +09:00
Rinjae d6186de009 DEFAULT spec 서버 치환 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GW/sentinel 치환 로직 삭제 및 serverRewriter 호출 제거
- mock path 분리/servers 덮어쓰기 로직 및 공통 유틸 추가
2026-08-11 16:56:17 +09:00
Rinjae 2a9980ab16 - API Tester 프록시 응답 헤더 전달 로직 개선 - BLOCKED 헤더 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- API ID 기반 명세 조회 기능 추가 - GW/Mock path 매칭 문제 해결
- APISender에 ApiResponse 클래스 도입 - 응답 상태코드/본문/헤더 반환
2026-08-11 16:38:46 +09:00
Rinjae 919b4a0b06 이메일 인증 템플릿 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 제주은행/DJ Bank 이메일 템플릿 신규 추가
- Base64 임베디드 이미지 및 링크형 리소스 분리
2026-08-11 15:03:37 +09:00
Rinjae b0ecb26a9d 변경/해지 신청 개선 및 사전검사 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 변경/해지 상태 표시 및 취소 기능 추가
- 해지 신청 사전검사 및 경고 메시지 처리 구현
- 관리자 승인 이전 허용되는 취소 정책 지원
2026-08-11 14:15:33 +09:00
Rinjae 0ad5bffa4f DJB 커스텀 소스 납품 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Windows, Linux/macOS 지원 배치/셸 스크립트 구현
- 소스 필터 규칙 및 수집 파일/모듈 매니페스트 작성
2026-08-11 11:47:31 +09:00
Rinjae 766341540d 개발가이드 Base URL 동적 처리, Mock 응답 인증 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GuideProperty 이용 개발가이드 GW URL 동적 주입
- sample/mock 응답 유형의 인증 절차 생략 로직 추가
- Webhook 이벤트 코드명/JSON 값 수정
2026-08-11 11:46:49 +09:00
Rinjae 63d4d7268c Merge branch 'master' into design
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-07 10:19:52 +09:00
Rinjae c42a278c8e 수신자 이름 지정 인증번호 발송 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- %USER_NAME% 지원 위한 createMessageRecipient 수정
- GUEST_USER_NAME 상수 및 관련 기본값 처리 로직 추가
2026-08-07 09:20:39 +09:00
hong 83352e3669 fix : css 수정 2026-08-06 13:21:41 +09:00
hong 317b8d781d Merge branch 'design' of http://172.30.1.50:3000/djb-eapim/eapim-portal into design 2026-08-06 13:20:27 +09:00
hong 206e72edde fix : 디자인 수정 2026-08-06 13:20:21 +09:00
Rinjae 34dce308dc 메뉴 내부 API 허용 IP 검증 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- IpAddressMatcher로 CIDR/와일드카드 지원
- 허용 IP 목록 설명 필드 추가
- RemoteAddr 정규화 로직 제거
2026-08-06 09:59:34 +09:00
Rinjae b3fc5c06bf webhook 수정 화면에서 이벤트 코드 출력 제거.
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-06 09:24:48 +09:00
Rinjae eb74a99a5a - PersonalDataEncryptConverter: isFakeMode 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- LegacyEncryptionMigration: 허용 IP 검증 로직 추가 - 스킵 내역 로그 강화
- 암호화 대상 컬럼 추가 및 DamoMode 구분 지원
2026-08-05 17:47:37 +09:00
Rinjae 38e5a7f13c API 카드 레이아웃 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 1행 4열 고정 및 반응형 flex-wrap 적용
- 카드 간격/정렬 방식 및 max-width 설정 추가
2026-08-05 15:42:48 +09:00
Rinjae 3b7c2e5a8f 푸터 "관련 사이트" 조회 및 렌더링 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- RelatedSite/RelatedSiteService 생성 및 PortalProperty 연계
- GlobalControllerAdvice에서 셀렉트 라벨/목록 주입
- footer.html 템플릿 및 관련 JS 수정
2026-08-05 15:23:59 +09:00
Rinjae 47099ec485 Merge branch 'feats/menu-control'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-05 14:21:22 +09:00
Rinjae 6dbf6af3de 서비스 소개 페이지 텍스트 간소화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 'DJBank API Portal 소개'로 제목 및 내용 정리
2026-08-05 14:19:58 +09:00
Rinjae 5585df9133 메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현 2026-08-05 14:19:33 +09:00
Rinjae 2c63fa1557 개발자포탈 미게시 GW 인터페이스 표시 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- hiddenApiCount 필드로 미게시 인터페이스 집계 노출
- API Pills 및 관련 CSS/JS 수정
- 영향 인터페이스 노출 로직 개선 및 VisibleApis 클래스 적용
2026-08-05 10:44:50 +09:00
Rinjae d71af34950 공지 연계 기능 추가 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 중, 과거 장애/점검 카드에 공지 제목 및 본문 추가.
- 공지 본문 2열 레이아웃 및 세로 스크롤 스타일 적용.
- CSS 반응형 수정(768px → 1024px).
2026-08-05 09:33:22 +09:00
Rinjae fbc145d145 로고 워드마크 정렬 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 로고 텍스트 및 링크 baseline 맞춤.
- CSS 수정 및 스타일 정렬 개선.
2026-08-04 19:27:54 +09:00
Rinjae f36b1a6478 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-08-04 19:23:45 +09:00
Rinjae 8f03209f8a UI 문구 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 공지 상세 화면 "끝" → "종료"로 수정
- Webhook 가이드 "장애 시작" → "장애 발생"으로 변경
2026-08-04 16:20:11 +09:00
Rinjae b45a7a162a - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
Rinjae 07fbe1ba54 장애·지연 구분 및 집계 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 지연 유형 로직 및 UI/스타일 업데이트
- JPQL 조회와 공개 조건 로직 전면 개정
2026-08-03 16:57:57 +09:00
Rinjae 4916bdf4af API 상태 지연 필터 및 UI/로직 개선:
- 장애·지연 구분 및 집계 로직 추가 대응
- 지연 유형 반영 색상/태그/레이블/UI 경로 업데이트
2026-08-03 15:33:21 +09:00
Rinjae 35621b5174 Merge branch 'master' into feats/api-status 2026-07-31 17:35:11 +09:00
Rinjae b97b0fcf7b "SBOM 생성 및 변환 추가 - CycloneDX -> Excel(xlsx) 지원 - Jenkins 단계에 SBOM 빌드 및 아티팩트 보관 연결"
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-07-31 17:31:45 +09:00
Rinjae 6cb98d36bb API Status 개선 및 조회 기간 설정 추가 2026-07-31 16:22:43 +09:00
Rinjae fd229fca43 API Status 페이지 기능 및 UI 업데이트:
- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가
- 지연 필터 및 상대 시간 표기 기능 구현
- 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
2026-07-31 14:42:09 +09:00
Rinjae 5e8d08f1af API Status 페이지 포탈 미게시 API 안내 추가:
- 포탈 미게시 API 필터 안내 배너 HTML 및 JS 추가
- 조회 시 API 명 표시 및 API 옵션 외 필터 유지 로직 변경
2026-07-31 14:10:39 +09:00
Rinjae e7945233fe Merged from master 2026-07-31 13:41:25 +09:00
Rinjae 815e2bdd04 API Status 페이지 UI 및 주요 기능 추가:
- API Status 페이지 SCSS 스타일 및 반응형 대응 추가
- 진행 중 장애 DTO 및 영향 API DTO 구현
- 진행 중 장애 및 점검 사항 관련 JS 렌더링 로직 추가
2026-07-31 13:38:37 +09:00
Rinjae 84e873a08b Merge branch 'design' into feats/api-status 2026-07-30 17:42:29 +09:00
Rinjae e3b5a44270 Merge branch 'master' into feats/api-status 2026-07-30 13:44:50 +09:00
Rinjae 2a9694cd85 API 스펙 관리 문구 및 로직 개선
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:24:05 +09:00
rinjae 67ec6d2103 Jenkinsfile.deploy 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:12:57 +09:00
rinjae b024f8e86f Jenkinsfile.test-build 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 11:09:38 +09:00
rinjae fd16300553 Jenkinsfile.test-build 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 10:51:00 +09:00
Rinjae 815c329e3c CI - 단위테스트 안정화 및 e2e 분리 워크플로 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- AccountControllerTest 현 컨트롤러 API 기준 재작성
- Selenium 4종·SignupFileTest @Tag("e2e") 부여
- BaseWebTest headless 옵션, e2e 워크플로 신설

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 20:21:01 +09:00
Rinjae 7fdd70dbc7 ci - checkout step 이름·주석 정리
- 자신 모듈은 this branch, 의존은 master 명시
- step 이름과 주석으로 의도 가시화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 19:50:58 +09:00
Rinjae 3dd9edc849 eapim-portal CI 테스트 워크플로 추가 2026-06-12 19:40:20 +09:00
Rinjae 6cff96f40a CI - systemd 유닛 동기화 및 리로드 단계 제거 2026-06-12 18:45:55 +09:00
Rinjae a97609d533 Gitea Generic Package 업로드 워크플로 추가
- WAR 파일 Gitea Generic Package 업로드 단계 추가
- 패키지 버전 및 업로드 실패 시 오류 처리 로직 포함
2026-06-12 18:43:18 +09:00
147 changed files with 10993 additions and 495 deletions
+3
View File
@@ -83,6 +83,9 @@ file_upload/
# Ignore Gradle build output directory
build
# SonarScanner 작업 디렉터리
.scannerwork/
build_knbank.sh
+15
View File
@@ -80,6 +80,21 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+267
View File
@@ -0,0 +1,267 @@
// SonarQube 정적 보안 분석 전용 파이프라인.
//
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다)
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록
// (SONAR_SCANNER_TOOL 이 비어 있으면 흔한 이름을 탐색하고, 없으면 PATH 의 sonar-scanner 사용)
// 3) Quality Gate 대기를 쓰려면 SonarQube 서버에
// Administration > Configuration > Webhooks 에 <JENKINS_URL>/sonarqube-webhook/ 등록
// 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다.
//
// 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다.
// (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError)
pipeline {
agent { label 'djb-vm' }
triggers {
pollSCM('H/30 * * * *')
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
}
parameters {
booleanParam(
name: 'RUN_TESTS',
defaultValue: false,
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
)
}
environment {
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
// Manage Jenkins > System > SonarQube servers 의 Name
SONAR_ENV_NAME = 'SonarQube-Community'
// Manage Jenkins > Tools > SonarQube Scanner 에 등록한 이름.
// 비워두면 아래 후보 이름들을 순서대로 탐색하고, 그래도 없으면 PATH 의 sonar-scanner 를 쓴다.
SONAR_SCANNER_TOOL = ''
// --- 툴체인 ---
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
// 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다.
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
echo "--- build JDK ---"
java -version
gradle --version
echo "--- scanner JDK ---"
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것."
exit 1
fi
"$JAVA_HOME_SCANNER/bin/java" -version
'''
}
}
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
// 생성 소스 디렉터리는 별도로 먼저 지운다.
stage('Compile') {
steps {
sh '''
set -eu
rm -rf build/generated build/classes
gradle clean classes testClasses --no-daemon
'''
}
}
stage('Test') {
when { expression { return params.RUN_TESTS } }
steps {
sh 'gradle test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
}
}
}
// sonar.java.libraries 용 클래스패스 덤프.
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
stage('Export analysis classpath') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
}
}
}
stage('SonarQube analysis') {
steps {
script {
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
def candidates = env.SONAR_SCANNER_TOOL?.trim()
? [env.SONAR_SCANNER_TOOL.trim()]
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
def scannerHome = null
for (name in candidates) {
try {
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
break
} catch (ignored) {
// 등록되지 않은 이름은 건너뛴다
}
}
if (scannerHome == null) {
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
}
withSonarQubeEnv(env.SONAR_ENV_NAME) {
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
sh '''
set -eu
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
export JAVA_HOME="$JAVA_HOME_SCANNER"
export PATH="$JAVA_HOME/bin:$PATH"
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
# 플러그인 버전에 따라 SONAR_AUTH_TOKEN 만 주입되는 경우가 있다.
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
fi
LIBS=""
if [ -f build/sonar/java-libraries.txt ]; then
LIBS=$(cat build/sonar/java-libraries.txt)
else
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
fi
TEST_LIBS=""
if [ -f build/sonar/java-test-libraries.txt ]; then
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
fi
JUNIT_ARG=""
if [ -d build/test-results/test ]; then
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
fi
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
elif command -v sonar-scanner >/dev/null 2>&1; then
SCANNER="$(command -v sonar-scanner)"
else
echo "sonar-scanner 실행 파일을 찾지 못했다."
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
exit 1
fi
echo "scanner: $SCANNER"
"$SCANNER" \
-Dsonar.projectVersion="${BUILD_NUMBER}" \
-Dsonar.java.libraries="$LIBS" \
-Dsonar.java.test.libraries="$TEST_LIBS" \
$JUNIT_ARG
'''
}
}
}
}
}
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
stage('Quality Gate') {
steps {
script {
def qg = null
try {
timeout(time: 15, unit: 'MINUTES') {
qg = waitForQualityGate abortPipeline: false
}
} catch (err) {
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
return
}
if (qg == null) {
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
} else if (qg.status != 'OK') {
unstable("Quality Gate ${qg.status}")
} else {
echo 'Quality Gate OK'
}
}
}
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
script {
def report = '.scannerwork/report-task.txt'
if (fileExists(report)) {
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
if (url) {
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
}
}
}
}
}
}
+15
View File
@@ -101,5 +101,20 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+1
View File
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
+61 -1
View File
@@ -24,6 +24,24 @@ allprojects {
}
}
// 로컬 전용 라이브러리 — WebLogic 배포 산출물(war/bootWar)에서 제외한다.
// 정보보호 점검에서 Actuator/모니터링 라이브러리가 배포본에 실려 있으면 불필요하게 탐지되므로
// 로컬(bootRun/IDE)에서만 classpath 에 오르게 한다. 제외 로직은 아래 war/bootWar 블록.
//
// ※ Spring Boot 의 developmentOnly 를 쓰지 않는 이유
// developmentOnly 는 bootJar/bootWar 에서만 제외된다. 표준 war task 는 runtimeClasspath 를
// 그대로 쓰므로 산출물에 실린다 (실측: devtools 가 eapim-portal.war 에 포함되어 있었음).
// ※ 별도 configuration(localOnly)을 쓰지 않는 이유
// runtimeClasspath 에서 빠지면 IntelliJ 가 모듈 classpath 를 구성할 때도 빠져
// IDE 로 기동할 때 Actuator/SBA 가 동작하지 않는다.
def localOnlyLibPrefixes = [
'spring-boot-devtools',
'spring-boot-starter-actuator',
'spring-boot-actuator', // spring-boot-actuator, -autoconfigure 모두 매칭
'micrometer-', // actuator 전용(runtimeClasspath 상 다른 출처 없음 — 확인함)
'spring-boot-admin-',
]
dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.28"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
@@ -45,6 +63,23 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
implementation('org.springframework.boot:spring-boot-starter-web')
implementation('org.springframework.boot:spring-boot-starter-validation')
// ↓ 로컬 전용. war/bootWar 산출물에서는 localOnlyLibPrefixes 로 제외된다.
// runtimeOnly 인 이유: compileClasspath 에서 빠지므로 자바 코드가 이 API 를 참조하면
// 컴파일 단계에서 막힌다. implementation 이면 참조가 컴파일에 통과해버리고,
// 배포본(actuator/SBA 제외됨)에서 NoClassDefFoundError 로 터진다.
// IntelliJ / bootRun 은 runtimeClasspath 기준이라 로컬 기동에는 정상 포함된다.
// 설정(application-local*.yml)으로만 사용한다.
runtimeOnly('org.springframework.boot:spring-boot-starter-actuator')
// Spring Boot Admin client. 2.7.16 = Spring Boot 2.7.x 대응 마지막 계열(Java 8 호환).
runtimeOnly('de.codecentric:spring-boot-admin-starter-client:2.7.16')
// 위 runtimeOnly 원칙의 유일한 예외. InterceptorsEndpointConfig 가 actuator API
// (@Endpoint / InfoContributor)를 참조해야 하므로 compileClasspath 에만 올린다.
// compileOnly 는 runtimeClasspath 에 포함되지 않으므로 war/bootWar 산출물에는 영향이 없다.
// 해당 클래스는 @ConditionalOnClass 로 actuator 부재 시 로드되지 않으니
// 배포본(actuator 제외)에서 NoClassDefFoundError 가 나지 않는다.
compileOnly('org.springframework.boot:spring-boot-actuator')
implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
@@ -129,7 +164,16 @@ bootRun {
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
args = ["--spring.profiles.active=dev"]
// 로컬 기동 기본 프로파일.
// dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가
// 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는
// 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰).
// 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev
// ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어
// findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.)
def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString()
args = ["--spring.profiles.active=" + bootRunProfile]
}
@@ -181,9 +225,21 @@ test {
enabled = true
}
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
// 정보보호 점검에 불필요하게 걸린다. processResources 는 건드리지 않는다
// (bootRun 이 build/resources/main 을 그대로 쓰므로 로컬 기동이 깨진다).
def localOnlyResources = ['**/application-local*.yml']
// 배포 산출물에서 로컬 전용 라이브러리를 걸러낸다. localOnlyLibPrefixes 는 파일 상단 정의.
def excludeLocalOnlyLibs = { org.gradle.api.file.FileCollection cp ->
cp.filter { f -> !localOnlyLibPrefixes.any { p -> f.name.startsWith(p) } }
}
bootWar {
archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication'
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
}
war {
@@ -192,6 +248,8 @@ war {
from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' }
from('src/main/resources/weblogic.xml') { into 'WEB-INF' }
rootSpec.exclude(localOnlyResources)
classpath = excludeLocalOnlyLibs(classpath)
}
task printSourceSets {
@@ -204,3 +262,5 @@ task printSourceSets {
}
}
}
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
+41
View File
@@ -0,0 +1,41 @@
// SonarScanner 의 sonar.java.libraries / sonar.java.test.libraries 로 넘길
// 컴파일 클래스패스를 파일로 덤프한다.
//
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
// gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon
//
// 출력(콤마 구분, 절대경로):
// build/sonar/java-libraries.txt main 컴파일 클래스패스
// build/sonar/java-test-libraries.txt main + test 컴파일 클래스패스
//
// 이 파일이 없으면 Sonar Java 분석기가 타입을 해석하지 못해
// 보안 룰(SQL Injection, XSS 등) 상당수가 침묵한다. 선택 사항이 아니다.
rootProject { project ->
project.plugins.withId('java') {
project.tasks.register('exportSonarClasspath') {
group = 'verification'
description = 'SonarScanner 용 컴파일 클래스패스를 build/sonar 에 덤프한다'
doLast {
def outDir = new File(project.layout.buildDirectory.get().asFile, 'sonar')
outDir.mkdirs()
def toLine = { files ->
files.findAll { it.exists() }
.collect { it.absolutePath }
.unique()
.join(',')
}
def mainCp = project.sourceSets.main.compileClasspath.files
def testCp = project.sourceSets.test.compileClasspath.files
new File(outDir, 'java-libraries.txt').text = toLine(mainCp)
new File(outDir, 'java-test-libraries.txt').text = toLine(mainCp + testCp)
logger.lifecycle("sonar classpath dumped: ${outDir}")
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>DJBank API Portal 이메일 인증</title>
<!--[if mso]>
<style type="text/css">
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#eef0f3;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<!-- 미리보기 텍스트 (받은편지함 목록에 노출, 본문에서는 숨김) -->
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#eef0f3;">DJBank 개발자포탈 이메일 인증번호 안내입니다.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef0f3;">
<tr>
<td align="center" style="padding:30px 12px;">
<!-- ============ 카드 (흰 배경 · 테두리 #dfdfdf · 라운드 20) ============ -->
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #dfdfdf;border-radius:20px;">
<!-- ===== Header : DJ Bank 로고 + API Portal ===== -->
<tr>
<td style="padding:26px 27px 0 27px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="20" style="width:20px;font-size:0;line-height:0;">&nbsp;</td>
<td valign="middle" style="line-height:0;">
<img src="images/logo-djbank.png" width="144" height="40" alt="DJ Bank" style="display:block;width:144px;height:40px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
<td width="16" style="width:16px;font-size:0;line-height:0;">&nbsp;</td>
<td valign="middle" style="line-height:0;">
<img src="images/logo-apiportal.png" width="124" height="21" alt="API Portal" style="display:block;width:124px;height:21px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
</tr>
</table>
</td>
</tr>
<!-- ===== 본문 : 상단 파란선(#2A69DE) + 그림자 ===== -->
<tr>
<td style="padding:7px 27px 0 27px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:3px solid #2a69de;box-shadow:0px 4px 12px 0px rgba(85,95,108,0.25);">
<tr>
<td style="padding:26px 29px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<!-- 제목 (subject) : ##변수## 치환 토큰 -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:15px;line-height:25px;color:#000000;font-weight:600;padding-bottom:20px;">##subject:이메일인증##</td>
</tr>
<!-- 인증번호 박스 (#ECF1FF · 라운드 10) -->
<tr>
<td style="padding-bottom:20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#ecf1ff;border-radius:10px;">
<tr>
<td align="center" bgcolor="#ecf1ff" style="background-color:#ecf1ff;border-radius:10px;padding:16px 20px;font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:14px;line-height:24px;color:#000000;font-weight:700;text-align:center;letter-spacing:2px;"><!-- 동적값: 인증번호 -->123456</td>
</tr>
</table>
</td>
</tr>
<!-- 안내 문구 -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#000000;padding-bottom:20px;">
안녕하세요, <!-- 동적값: 회원명 -->김철수 님.<br>
DJBank 개발자포탈 회원가입 시 입력하신 이메일을 인증해 주세요.<br>
아래 인증번호를 회원가입 화면에 입력하시면 인증이 완료됩니다.
</td>
</tr>
<!-- 경고 문구 (#FF2727) -->
<tr>
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#ff2727;font-weight:600;">※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다.</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- ===== Footer : 제주은행 로고 + 안내 (#F1F1ED) ===== -->
<tr>
<td style="padding:7px 27px 26px 27px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f1f1ed" style="background-color:#f1f1ed;border-radius:4px;">
<tr>
<td style="padding:12px 14px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="middle" width="86" style="width:86px;padding-right:16px;line-height:0;">
<img src="images/logo-jejubank.png" width="86" height="24" alt="제주은행" style="display:block;width:86px;height:24px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
</td>
<td valign="middle" style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;color:#0d0e11;">
<p style="margin:0;font-size:10px;line-height:20px;color:#0d0e11;">본 메일은 발신 전용으로 회신되지 않습니다.</p>
<p style="margin:0 0 9px 0;font-size:10px;line-height:20px;color:#0d0e11;">관련 문의사항은 고객센터 (????-????) 또는 홈페이지를 이용하시기 바랍니다.</p>
<p style="margin:0 0 9px 0;font-size:10px;line-height:16px;color:#0d0e11;">제주 제주시 1100로 3351 (노형동)<span style="color:#c2c2bc;">&nbsp;|&nbsp;</span>고객센터 : ????-?????<span style="color:#c2c2bc;">&nbsp;|&nbsp;</span>사업자 등록번호 : 616-81-00615</p>
<p style="margin:0;font-size:10px;line-height:16px;color:#454545;font-weight:500;">Copyright JEJUBANK. ALL Rights Reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- ============ /카드 ============ -->
</td>
</tr>
</table>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+326
View File
@@ -0,0 +1,326 @@
/*
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
*
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
*
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
* classpath 나 WAR 산출물에는 포함되지 않는다.
*
* 시트: 요약 / WAR 기준
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
*/
buildscript {
repositories {
maven {
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
allowInsecureProtocol = true
}
mavenCentral()
}
dependencies {
classpath 'org.apache.poi:poi-ooxml:3.17'
}
}
import groovy.json.JsonSlurper
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
ext.SBOM_CELL_LIMIT = 32000
task sbomXlsx {
group = 'sbom'
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
if (!project.hasProperty('sbomJson')) {
dependsOn 'cyclonedxBom'
}
doLast {
File src = resolveBomJson(project)
File out = project.hasProperty('sbomOut')
? project.file(project.property('sbomOut'))
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
out.parentFile.mkdirs()
def bom = new JsonSlurper().parse(src, 'UTF-8')
def deploy = collectDeployJars(project)
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
def wb = new XSSFWorkbook()
def st = createStyles(wb)
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
writeWarSheet(wb, st, warRows)
out.withOutputStream { os -> wb.write(os) }
wb.close()
int unmatched = warRows.count { it.matched == 'N' }
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
}
}
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
String sbomFileName(Project p) {
def warTask = p.tasks.findByName('war')
if (warTask != null) {
String archive = warTask.archiveFileName.get()
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
}
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
return "${p.name}${ver}-sbom.xlsx"
}
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
File resolveBomJson(Project p) {
if (p.hasProperty('sbomJson')) {
File f = p.file(p.property('sbomJson'))
if (!f.exists()) {
throw new GradleException("bom.json 없음: ${f.absolutePath}")
}
return f
}
def candidates = [
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
new File(p.buildDir, 'reports/bom.json'),
]
File found = candidates.find { it.exists() }
if (found == null) {
throw new GradleException(
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
}
return found
}
/**
* 실제 배포물에 packaging 되는 jar 목록.
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
*
* @return [label: 기준 설명, jars: 행 목록]
*/
Map collectDeployJars(Project p) {
def cfg = p.configurations.findByName('runtimeClasspath')
if (cfg == null) {
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
return [label: '(없음)', jars: []]
}
def warTask = p.tasks.findByName('war')
def files
String label
if (warTask != null) {
files = warTask.classpath.files
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
} else {
files = cfg.files
label = 'runtimeClasspath (war 태스크 없음)'
}
def coordByFile = [:]
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
def id = a.moduleVersion.id
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
}
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
def c = coordByFile[f]
[
file : f.name,
group : c?.group ?: '',
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
version: c?.version ?: '',
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
]
}.sort { it.file }
return [label: label, jars: jars]
}
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
Map indexComponents(bom) {
String rootRef = bom.metadata?.component?.'bom-ref'
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
def index = [:]
bom.components?.each { c ->
def hashes = [:]
c.hashes?.each { h -> hashes[h.alg] = h.content }
def licenses = (c.licenses ?: []).collect { l ->
l.license?.id ?: l.license?.name ?: l.expression ?: ''
}.findAll { it }
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
licenses : licenses.join('; '),
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
purl : c.purl ?: '',
sha256 : hashes['SHA-256'] ?: '',
sha1 : hashes['SHA-1'] ?: '',
description: c.description ?: '',
]
}
return index
}
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
List joinWarRows(List warJars, Map index) {
def result = []
warJars.eachWithIndex { j, i ->
def m = j.coord ? index[j.coord] : null
result << [
no : i + 1,
file : j.file,
group : j.group,
name : j.name,
version : j.version,
direct : m?.direct ?: '',
licenses : m?.licenses ?: '',
licenseList: m?.licenseList ?: ['(미상)'],
purl : m?.purl ?: '',
sha256 : m?.sha256 ?: '',
sha1 : m?.sha1 ?: '',
matched : (m != null) ? 'Y' : 'N',
description: m?.description ?: '',
]
}
return result
}
Map createStyles(wb) {
def headFont = wb.createFont()
headFont.setBold(true)
headFont.setColor(IndexedColors.WHITE.getIndex())
def head = wb.createCellStyle()
head.setFont(headFont)
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
head.setAlignment(HorizontalAlignment.CENTER)
head.setVerticalAlignment(VerticalAlignment.CENTER)
head.setBorderBottom(BorderStyle.THIN)
def body = wb.createCellStyle()
body.setVerticalAlignment(VerticalAlignment.TOP)
def wrap = wb.createCellStyle()
wrap.setVerticalAlignment(VerticalAlignment.TOP)
wrap.setWrapText(true)
def labelFont = wb.createFont()
labelFont.setBold(true)
def label = wb.createCellStyle()
label.setFont(labelFont)
return [head: head, body: body, wrap: wrap, label: label]
}
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
def row = sheet.createRow(0)
row.setHeightInPoints(20f)
headers.eachWithIndex { h, i ->
def cell = row.createCell(i)
cell.setCellValue(h)
cell.setCellStyle(style)
sheet.setColumnWidth(i, widths[i] * 256)
}
sheet.createFreezePane(0, 1)
}
def cellOf(row, int idx, value, style) {
def cell = row.createCell(idx)
String s = (value == null) ? '' : value.toString()
if (s.length() > SBOM_CELL_LIMIT) {
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
}
cell.setCellValue(s)
cell.setCellStyle(style)
return cell
}
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
def sheet = wb.createSheet('요약')
def comp = bom.metadata?.component ?: [:]
def tool = bom.metadata?.tools?.components?.getAt(0)
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
def items = [
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
['serialNumber', bom.serialNumber ?: ''],
['생성 시각', bom.metadata?.timestamp ?: ''],
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
['원본 파일', src.absolutePath],
['배포 수록 jar', warRows.size()],
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
['라이선스 종류', licenseKinds.size()],
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
]
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
items.eachWithIndex { item, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, item[0], st.label)
cellOf(row, 1, item[1], st.body)
}
// 라이선스 분포 (요약 하단)
def byLicense = [:].withDefault { 0 }
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
int base = items.size() + 2
def hdr = sheet.createRow(base)
cellOf(hdr, 0, '라이선스', st.head)
cellOf(hdr, 1, 'jar 수', st.head)
sorted.eachWithIndex { e, i ->
def row = sheet.createRow(base + 1 + i)
cellOf(row, 0, e.key, st.body)
cellOf(row, 1, e.value, st.body)
}
}
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
def writeWarSheet(wb, st, List warRows) {
def sheet = wb.createSheet('WAR 기준')
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
writeHeader(sheet, st.head, headers, widths)
warRows.eachWithIndex { r, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, r.no, st.body)
cellOf(row, 1, r.file, st.body)
cellOf(row, 2, r.group, st.body)
cellOf(row, 3, r.name, st.body)
cellOf(row, 4, r.version, st.body)
cellOf(row, 5, r.direct, st.body)
cellOf(row, 6, r.licenses, st.body)
cellOf(row, 7, r.purl, st.body)
cellOf(row, 8, r.sha256, st.body)
cellOf(row, 9, r.sha1, st.body)
cellOf(row, 10, r.matched, st.body)
cellOf(row, 11, r.description, st.wrap)
}
if (!warRows.isEmpty()) {
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
}
}
@@ -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 화면에 표기된다.
+112
View File
@@ -0,0 +1,112 @@
# DJB 커스텀 소스 납품본 수집
발주사 납품용으로, 기준 시점 이후 **신규로 추가된 커스텀 소스 파일**만 모아
디렉터리 구조를 유지한 채 한곳에 복사하고 `MANIFEST.csv` / `SUMMARY.md` / zip 을 만든다.
## 파일
| 파일 | 용도 |
|---|---|
| `export-custom.sh` | macOS / Linux 실행 스크립트 |
| `export-custom.ps1` | Windows PowerShell 실행 스크립트 (실제 로직) |
| `export-custom.bat` | Windows cmd 래퍼 — 인자를 `.ps1` 로 그대로 전달 |
| `modules.conf` | 대상 모듈 / 소스 경로 / 수집 확장자 |
| `export-filter.rules` | 포함·제외 규칙 (`.gitignore` 유사 문법) |
| `filelist.txt` | 마지막으로 뽑은 수집 대상 목록 (검토용, 재생성 가능) |
| `filelist-no-djb-marker.txt` | 그중 `djb` / `custom` 마커가 **없는** 파일 — 업그레이드 유입분 검토 대상 |
## 수집 방식
1. 모듈별로 **컷오프 날짜 직전의 마지막 커밋**을 base 로 잡는다.
`git rev-list -1 --before=<CUTOFF> HEAD`
컷오프 이전 커밋이 아예 없으면 저장소 전체를 신규로 본다.
2. `git diff --name-only --diff-filter=A -M <base> HEAD -- <소스경로>`
**신규 추가(A)** 파일만 뽑는다. `-M` 이라 단순 rename 은 신규로 잡히지 않는다.
3. `modules.conf` 의 확장자 allowlist 로 거른다. (확장자 없는 파일은 제외)
4. `export-filter.rules` 로 포함·제외를 최종 결정한다.
5. 남은 파일을 `<out>/src/<모듈>/<원래 경로>` 로 복사한다.
## 사용법
```bash
# macOS / Linux
./export-custom.sh --dry-run > filelist.txt # 목록만 뽑기 (로그는 stderr)
./export-custom.sh --show-excluded # 규칙에 걸려 빠진 목록 + 걸린 규칙
./export-custom.sh # 실제 수집
./export-custom.sh --zip # 수집 + zip
./export-custom.sh -c 2026-05-01 -m eapim-portal
```
```powershell
# Windows
.\export-custom.ps1 -DryRun > filelist.txt
.\export-custom.ps1 -ShowExcluded
.\export-custom.ps1 -Zip
.\export-custom.ps1 -Cutoff 2026-05-01 -Module eapim-portal,eapim-admin
# cmd 에서
export-custom.bat -DryRun > filelist.txt
```
### 주요 옵션
| sh | ps1 | 설명 |
|---|---|---|
| `-c, --cutoff` | `-Cutoff` | 기준 날짜 (기본 `2026-05-01`) |
| `-r, --root` | `-Root` | 저장소 루트. 미지정 시 스크립트 위치 기준 자동 탐지 |
| `-o, --out` | `-Out` | 출력 디렉터리 (기본 `<root>/build/djb-custom-export`) |
| `-m, --module` | `-Module` | 특정 모듈만 |
| `--zip` | `-Zip` | zip 생성 |
| `--include-untracked` | `-IncludeUntracked` | 미커밋 신규 파일도 포함 |
| `--dry-run` | `-DryRun` | 복사 없이 목록만 |
| `--show-excluded` | `-ShowExcluded` | 규칙에 걸려 빠진 목록 |
## `export-filter.rules` 문법
`.gitignore` 와 비슷하되 더 단순하다.
- 한 줄에 패턴 하나. `#` 주석, 빈 줄 무시
- 기본은 **모두 포함**. 패턴에 걸리면 제외
- `!` 로 시작하면 예외(다시 포함)
- **뒤에 오는 규칙이 앞 규칙을 덮는다** — 순서가 중요하다
- 패턴에 `/` 가 있으면 `<모듈>/<경로>` 전체와 매칭, 없으면 파일명만 매칭
- `/` 로 끝나면 그 디렉터리 하위 전체
- `*``/` 도 포함해 매칭한다 (단순 glob, `**` 없음)
매칭 대상 경로 예:
```
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
```
규칙 파일은 세 구역으로 나뉜다.
1. **업그레이드 유입분 제외**`filelist-no-djb-marker.txt` 를 검토하며 채운다
2. **`!*djb*` 예외** — djb 마커가 붙은 파일은 커스텀이므로 되살린다
3. **무조건 제외** — 빌드 산출물·서드파티. 2)보다 뒤에 있어 최종 승리한다
## 신규지만 커스텀이 아닌 파일 걸러내기
신규 파일이라도 제품 업그레이드로 딸려 온 것일 수 있다. 판별 순서:
```bash
# 1) 전체 목록
./export-custom.sh --dry-run > filelist.txt
# 2) djb / custom 마커가 없는 것 = 검토 대상
grep -vi djb filelist.txt | grep -v '/custom/' > filelist-no-djb-marker.txt
# 3) 검토 후 업그레이드 유입분을 export-filter.rules 의 1) 구역에 추가
# 4) 반영 확인
./export-custom.sh --show-excluded
```
## 주의
- 컷오프 이전 커밋이 없으면 base 가 **빈 트리**가 되어 저장소 전체가 신규로 잡힌다.
`SUMMARY.md` 의 base 컬럼에 `(컷오프 이전 커밋 없음 …)` 으로 표시된다.
- 모듈에 컷오프 직전 커밋이 드문드문 있으면 base 가 컷오프보다 훨씬 이전으로 잡힐 수 있다.
실행 로그의 `base:` 날짜를 항상 확인할 것.
- 추가된 뒤 삭제·이동된 파일은 현재 트리에 없으므로 건너뛰고, 건수를 `SUMMARY.md` 에 남긴다.
@@ -0,0 +1,29 @@
@echo off
REM ---------------------------------------------------------------------------
REM DJB 커스텀 소스 납품본 수집 (Windows cmd 래퍼)
REM
REM 실제 로직은 export-custom.ps1 에 있다. 이 배치는 PowerShell 을 띄우고
REM 전달받은 인자를 그대로 넘기기만 한다.
REM
REM 사용 예:
REM export-custom.bat
REM export-custom.bat -Cutoff 2026-05-01 -Zip
REM export-custom.bat -DryRun > filelist.txt
REM export-custom.bat -ShowExcluded
REM export-custom.bat -Module eapim-portal,eapim-admin
REM ---------------------------------------------------------------------------
setlocal
REM 한글 경로/출력이 깨지지 않도록 코드페이지를 UTF-8 로 전환
chcp 65001 >nul
where powershell >nul 2>nul
if errorlevel 1 (
echo [오류] PowerShell 을 찾을 수 없습니다. 1>&2
exit /b 1
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0export-custom.ps1" %*
set RC=%ERRORLEVEL%
endlocal & exit /b %RC%
+278
View File
@@ -0,0 +1,278 @@
<#
.SYNOPSIS
DJB 커스텀 소스 납품본 수집 스크립트 (Windows / PowerShell)
.DESCRIPTION
기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
.EXAMPLE
.\export-custom.ps1
.\export-custom.ps1 -Cutoff 2026-05-01 -Zip
.\export-custom.ps1 -DryRun
.\export-custom.ps1 -Module eapim-portal,eapim-admin
#>
[CmdletBinding()]
param(
[string] $Cutoff = '2026-05-01',
[string] $Root,
[string] $Out,
[string[]] $Module,
[switch] $Zip,
[switch] $IncludeUntracked,
[switch] $DryRun,
[switch] $ShowExcluded
)
if ($ShowExcluded) { $DryRun = $true }
$ErrorActionPreference = 'Stop'
# git 출력의 한글이 깨지지 않도록 콘솔 인코딩을 UTF-8로 고정한다.
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
$OutputEncoding = [Text.Encoding]::UTF8
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ConfFile = Join-Path $ScriptDir 'modules.conf'
$RulesFile = Join-Path $ScriptDir 'export-filter.rules'
# 저장소 루트: 스크립트는 <root>\eapim-portal\script\djb-custom-export\ 에 있다.
if (-not $Root) { $Root = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path }
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
throw "저장소 루트를 찾을 수 없다: $Root"
}
if (-not $Out) { $Out = Join-Path $Root 'build\djb-custom-export' }
if (-not (Test-Path -LiteralPath $ConfFile)) { throw "설정 파일 없음: $ConfFile" }
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
$EmptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
# ---- 설정 로드 ------------------------------------------------------------
$Modules = @()
$AllowExt = @()
foreach ($line in (Get-Content -LiteralPath $ConfFile -Encoding UTF8)) {
$t = $line.Trim()
if ($t -eq '' -or $t.StartsWith('#')) { continue }
$idx = $t.IndexOf('|')
if ($idx -lt 0) { continue }
$key = $t.Substring(0, $idx)
$rest = $t.Substring($idx + 1)
switch ($key) {
'MODULE' {
$i2 = $rest.IndexOf('|')
if ($i2 -lt 0) { continue }
$Modules += [pscustomobject]@{
Name = $rest.Substring(0, $i2)
Paths = $rest.Substring($i2 + 1).Split(' ') | Where-Object { $_ -ne '' }
}
}
'EXT' { $AllowExt = $rest.Split(',') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -ne '' } }
}
}
# ---- 포함/제외 규칙 로드 (.gitignore 유사) --------------------------------
$Rules = @()
if (Test-Path -LiteralPath $RulesFile) {
foreach ($line in (Get-Content -LiteralPath $RulesFile -Encoding UTF8)) {
$t = $line.Trim()
if ($t -eq '' -or $t.StartsWith('#')) { continue }
$Rules += $t
}
}
# 마지막으로 매칭된 규칙이 이긴다. 기본 포함, '!' 는 예외(다시 포함).
# 반환: @{ Keep = $true/$false; Rule = '<제외시킨 규칙>' }
function Test-RuleKeep([string]$RelPath) {
$leaf = Split-Path $RelPath -Leaf
$keep = $true
$matched = ''
foreach ($raw in $Rules) {
$rule = $raw
$neg = $false
if ($rule.StartsWith('!')) { $neg = $true; $rule = $rule.Substring(1) }
if ($rule.EndsWith('/')) { $rule = $rule + '*' } # 디렉터리 규칙
$target = if ($rule.Contains('/')) { $RelPath } else { $leaf }
if ($target -like $rule) {
if ($neg) { $keep = $true; $matched = '' }
else { $keep = $false; $matched = $rule }
}
}
return @{ Keep = $keep; Rule = $matched }
}
function Test-AllowedExt([string]$RelPath) {
$leaf = Split-Path $RelPath -Leaf
if ($leaf -notmatch '\.') { return $false } # 확장자 없는 파일 제외
$ext = $leaf.Substring($leaf.LastIndexOf('.') + 1).ToLower()
return $AllowExt -contains $ext
}
function Invoke-Git([string]$Dir, [string[]]$GitArgs) {
$all = @('-C', $Dir, '-c', 'core.quotepath=false') + $GitArgs
$res = & git @all 2>$null
if ($LASTEXITCODE -ne 0) { return @() }
return @($res)
}
# ---- 준비 -----------------------------------------------------------------
$Manifest = Join-Path $Out 'MANIFEST.csv'
$Summary = Join-Path $Out 'SUMMARY.md'
if (-not $DryRun) {
if (Test-Path -LiteralPath $Out) { Remove-Item -LiteralPath $Out -Recurse -Force }
New-Item -ItemType Directory -Path (Join-Path $Out 'src') -Force | Out-Null
# Excel 한글용 UTF-8 BOM
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[IO.File]::WriteAllText($Manifest, "module,path,ext,added_commit,added_date,author`r`n", $utf8Bom)
}
Write-Host "저장소 루트 : $Root"
Write-Host "기준 날짜 : $Cutoff (이 날짜 직전 마지막 커밋이 base)"
Write-Host "출력 경로 : $Out"
if ($DryRun) { Write-Host '모드 : DRY-RUN (복사 안 함)' }
Write-Host ''
$Rows = New-Object System.Collections.Generic.List[string]
$SummaryRs = New-Object System.Collections.Generic.List[object]
$Total = 0
$Missing = 0
$Excluded = 0
# ---- 모듈 순회 ------------------------------------------------------------
foreach ($m in $Modules) {
if ($Module -and ($Module -notcontains $m.Name)) { continue }
$modDir = Join-Path $Root $m.Name
if (-not (Test-Path -LiteralPath (Join-Path $modDir '.git'))) {
Write-Host "[건너뜀] $($m.Name) — git 저장소 아님 ($modDir)"
continue
}
# base 커밋 결정
$base = (Invoke-Git $modDir @('rev-list', '-1', "--before=$Cutoff", 'HEAD') | Select-Object -First 1)
if ([string]::IsNullOrWhiteSpace($base)) {
$base = $EmptyTree
$baseDesc = '(컷오프 이전 커밋 없음 → 전체를 신규로 간주)'
} else {
$d = (Invoke-Git $modDir @('log', '-1', '--format=%h %ad %s', '--date=short', $base) | Select-Object -First 1)
$baseDesc = if ($d.Length -gt 80) { $d.Substring(0, 80) } else { $d }
}
# 실제 존재하는 소스 경로만 pathspec 으로 사용
$spec = @()
foreach ($p in $m.Paths) {
if (Test-Path -LiteralPath (Join-Path $modDir ($p -replace '/', '\')) -PathType Container) { $spec += $p }
}
if ($spec.Count -eq 0) {
Write-Host "[건너뜀] $($m.Name) — 설정된 소스 경로가 존재하지 않음"
continue
}
# 신규(Added) 파일만. -M 으로 rename 은 신규에서 제외한다.
$files = @(Invoke-Git $modDir (@('diff', '--name-only', '--diff-filter=A', '-M', $base, 'HEAD', '--') + $spec))
if ($IncludeUntracked) {
$files += @(Invoke-Git $modDir (@('ls-files', '--others', '--exclude-standard', '--') + $spec))
}
$files = $files | Where-Object { $_ -ne '' } | Sort-Object -Unique
$count = 0
$miss = 0
foreach ($f in $files) {
if (-not (Test-AllowedExt $f)) { continue }
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
$rk = Test-RuleKeep "$($m.Name)/$f"
if (-not $rk.Keep) {
$Excluded++
if ($ShowExcluded) { Write-Output "$($m.Name)/$f`t# $($rk.Rule)" }
continue
}
if ($ShowExcluded) { continue }
$src = Join-Path $modDir ($f -replace '/', '\')
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
Write-Warning " [없음] $($m.Name)/$f"
$miss++
continue
}
$count++
# 목록은 Write-Output(성공 스트림) 으로 — 리다이렉트하면 목록만 파일로 떨어진다.
if ($DryRun) { Write-Output "$($m.Name)/$f"; continue }
$dst = Join-Path (Join-Path $Out 'src') (Join-Path $m.Name ($f -replace '/', '\'))
$dstDir = Split-Path -Parent $dst
if (-not (Test-Path -LiteralPath $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
Copy-Item -LiteralPath $src -Destination $dst -Force
$meta = (Invoke-Git $modDir @('log', '-1', '--diff-filter=A', '--format=%h|%ad|%an', '--date=short', '--', $f) | Select-Object -First 1)
$cHash = ''; $cDate = ''; $cAuth = ''
if ($meta) {
$parts = $meta.Split('|')
if ($parts.Count -ge 3) { $cHash = $parts[0]; $cDate = $parts[1]; $cAuth = $parts[2] }
}
$leaf = Split-Path $f -Leaf
$ext = if ($leaf -match '\.') { $leaf.Substring($leaf.LastIndexOf('.') + 1) } else { '' }
$esc = { param($s) '"' + ($s -replace '"', '""') + '"' }
$Rows.Add((@(
(& $esc $m.Name), (& $esc $f), (& $esc $ext),
(& $esc $cHash), (& $esc $cDate), (& $esc $cAuth)
) -join ','))
}
$Total += $count
$Missing += $miss
$SummaryRs.Add([pscustomobject]@{ Module = $m.Name; Count = $count; Base = $baseDesc })
Write-Host ("[수집] {0,-22} {1,5} 개 base: {2}" -f $m.Name, $count, $baseDesc)
}
# ---- 출력 ------------------------------------------------------------------
if (-not $DryRun) {
if ($Rows.Count -gt 0) {
[IO.File]::AppendAllText($Manifest, (($Rows -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
}
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('# DJB 커스텀 소스 납품본')
[void]$sb.AppendLine('')
[void]$sb.AppendLine("- 생성 일시: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
[void]$sb.AppendLine("- 기준 날짜: ``$Cutoff`` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)")
[void]$sb.AppendLine('- 수집 규칙: `git diff --diff-filter=A -M <base> HEAD` / 확장자 allowlist / `export-filter.rules` 필터')
[void]$sb.AppendLine("- 대상 확장자: ``$($AllowExt -join ',')``")
[void]$sb.AppendLine("- 규칙 파일: ``$(Split-Path $RulesFile -Leaf)`` ($($Rules.Count) 개 규칙, 제외 $Excluded 건)")
[void]$sb.AppendLine('')
[void]$sb.AppendLine('## 모듈별 수집 결과')
[void]$sb.AppendLine('')
[void]$sb.AppendLine('| 모듈 | 파일 수 | base 커밋 |')
[void]$sb.AppendLine('|---|---:|---|')
foreach ($r in $SummaryRs) { [void]$sb.AppendLine("| $($r.Module) | $($r.Count) | $($r.Base) |") }
[void]$sb.AppendLine('')
[void]$sb.AppendLine("**합계: $Total 개**")
if ($Missing -gt 0) { [void]$sb.AppendLine("> 추가 후 삭제/이동되어 현재 트리에 없는 파일 $Missing 개는 제외됨.") }
[void]$sb.AppendLine('')
[void]$sb.AppendLine('## 파일 목록')
[void]$sb.AppendLine('')
[void]$sb.AppendLine('`MANIFEST.csv` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자).')
[IO.File]::WriteAllText($Summary, $sb.ToString(), (New-Object System.Text.UTF8Encoding($true)))
Write-Host ''
Write-Host "MANIFEST : $Manifest"
Write-Host "SUMMARY : $Summary"
if ($Zip) {
$zipName = "djb-custom-export-$(Get-Date -Format 'yyyyMMdd').zip"
$zipPath = Join-Path (Split-Path -Parent $Out) $zipName
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
Compress-Archive -Path (Join-Path $Out '*') -DestinationPath $zipPath
Write-Host "ZIP : $zipPath"
}
}
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env bash
#
# DJB 커스텀 소스 납품본 수집 스크립트 (macOS / Linux)
#
# 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
# 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
# MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
#
# 사용법:
# ./export-custom.sh # 기본값으로 실행
# ./export-custom.sh -c 2026-05-01 --zip
# ./export-custom.sh --dry-run
# ./export-custom.sh -m eapim-portal -m eapim-admin
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF_FILE="$SCRIPT_DIR/modules.conf"
RULES_FILE="$SCRIPT_DIR/export-filter.rules"
# ---- 기본값 ---------------------------------------------------------------
CUTOFF="2026-05-01"
REPO_ROOT=""
OUT_DIR=""
MAKE_ZIP=0
DRY_RUN=0
INCLUDE_UNTRACKED=0
ONLY_MODULES=""
SHOW_EXCLUDED=0
usage() {
cat <<'EOF'
DJB 커스텀 소스 납품본 수집
옵션:
-c, --cutoff <YYYY-MM-DD> 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다.
(기본: 2026-05-01)
-r, --root <DIR> 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지.
-o, --out <DIR> 출력 디렉터리. (기본: <root>/build/djb-custom-export)
-m, --module <NAME> 특정 모듈만 수집. 여러 번 지정 가능.
--zip 수집 후 zip 아카이브 생성.
--include-untracked git에 아직 커밋되지 않은 신규 파일도 포함.
--dry-run 복사하지 않고 대상 목록만 stdout 으로 출력(로그는 stderr).
--show-excluded --dry-run 과 함께. 규칙에 걸려 제외된 목록을 대신 출력.
-h, --help 도움말.
포함/제외 규칙은 export-filter.rules (.gitignore 유사 문법) 에서 관리한다.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-c|--cutoff) CUTOFF="$2"; shift 2 ;;
-r|--root) REPO_ROOT="$2"; shift 2 ;;
-o|--out) OUT_DIR="$2"; shift 2 ;;
-m|--module) ONLY_MODULES="$ONLY_MODULES $2"; shift 2 ;;
--zip) MAKE_ZIP=1; shift ;;
--include-untracked) INCLUDE_UNTRACKED=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--show-excluded) SHOW_EXCLUDED=1; DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;;
esac
done
# ---- 저장소 루트 탐지 -----------------------------------------------------
# 스크립트는 <root>/eapim-portal/script/djb-custom-export/ 에 있다.
if [ -z "$REPO_ROOT" ]; then
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
fi
if [ ! -d "$REPO_ROOT" ]; then
echo "저장소 루트를 찾을 수 없다: $REPO_ROOT" >&2
exit 1
fi
[ -z "$OUT_DIR" ] && OUT_DIR="$REPO_ROOT/build/djb-custom-export"
if [ ! -f "$CONF_FILE" ]; then
echo "설정 파일 없음: $CONF_FILE" >&2
exit 1
fi
# ---- 설정 로드 ------------------------------------------------------------
ALLOW_EXT=""
EXCLUDE_PAT=""
MODULE_LINES=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|'#'*) continue ;;
esac
key="${line%%|*}"
rest="${line#*|}"
case "$key" in
MODULE) MODULE_LINES="$MODULE_LINES
$rest" ;;
EXT) ALLOW_EXT="$rest" ;;
EXCLUDE) EXCLUDE_PAT="$rest" ;;
esac
done < "$CONF_FILE"
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
is_allowed_ext() {
_f="$1"
_base="${_f##*/}"
case "$_base" in
*.*) _ext="${_base##*.}" ;;
*) return 1 ;; # 확장자 없는 파일 제외
esac
# 소문자 변환 (bash 3.2 호환)
_ext="$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')"
case ",$ALLOW_EXT," in
*",$_ext,"*) return 0 ;;
*) return 1 ;;
esac
}
# ---- 포함/제외 규칙 (.gitignore 유사) --------------------------------------
RULES=()
if [ -f "$RULES_FILE" ]; then
while IFS= read -r r || [ -n "$r" ]; do
# 앞뒤 공백 제거
r="${r#"${r%%[![:space:]]*}"}"
r="${r%"${r##*[![:space:]]}"}"
case "$r" in
''|'#'*) continue ;;
esac
RULES[${#RULES[@]}]="$r"
done < "$RULES_FILE"
fi
# 경로 하나에 규칙을 순서대로 적용한다. 마지막으로 매칭된 규칙이 이긴다.
# 기본은 포함(0), '!' 규칙은 다시 포함, 일반 규칙은 제외(1).
# 반환: 0 = 포함, 1 = 제외. 제외시킨 규칙은 MATCHED_RULE 에 담긴다.
MATCHED_RULE=""
rule_keep() {
_path="$1"
_leaf="${_path##*/}"
_keep=0
MATCHED_RULE=""
_i=0
while [ $_i -lt ${#RULES[@]} ]; do
_rule="${RULES[$_i]}"
_i=$((_i + 1))
_neg=0
case "$_rule" in
'!'*) _neg=1; _rule="${_rule#!}" ;;
esac
# 디렉터리 규칙: 하위 전체
case "$_rule" in
*/) _rule="${_rule}*" ;;
esac
# '/' 가 있으면 전체 경로, 없으면 파일명과 매칭
case "$_rule" in
*/*) _target="$_path" ;;
*) _target="$_leaf" ;;
esac
# shellcheck disable=SC2254 # 의도적으로 unquoted — glob 패턴으로 쓴다
case "$_target" in
$_rule)
if [ $_neg -eq 1 ]; then
_keep=0; MATCHED_RULE=""
else
_keep=1; MATCHED_RULE="$_rule"
fi
;;
esac
done
return $_keep
}
csv_escape() {
printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"
}
# ---- 준비 -----------------------------------------------------------------
if [ "$DRY_RUN" -eq 0 ]; then
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/src"
fi
MANIFEST="$OUT_DIR/MANIFEST.csv"
SUMMARY="$OUT_DIR/SUMMARY.md"
TMP_SUMMARY="$(mktemp)"
trap 'rm -f "$TMP_SUMMARY"' EXIT
if [ "$DRY_RUN" -eq 0 ]; then
printf '\357\273\277' > "$MANIFEST" # Excel 한글용 UTF-8 BOM
echo 'module,path,ext,added_commit,added_date,author' >> "$MANIFEST"
fi
# dry-run(목록 뽑기) 모드에서는 파일 목록만 stdout으로 내보내고
# 진행 로그는 전부 stderr로 보낸다. 그래야 파이프/리다이렉트로 목록을 바로 가공할 수 있다.
if [ "$DRY_RUN" -eq 1 ]; then LOG=2; else LOG=1; fi
{
echo "저장소 루트 : $REPO_ROOT"
echo "기준 날짜 : $CUTOFF (이 날짜 직전 마지막 커밋이 base)"
echo "출력 경로 : $OUT_DIR"
[ "$DRY_RUN" -eq 1 ] && echo "모드 : DRY-RUN (목록만 출력, 복사 안 함)"
echo
} >&$LOG
TOTAL=0
MISSING=0
EXCLUDED=0
# ---- 모듈 순회 ------------------------------------------------------------
# 파이프 대신 process substitution 을 써서 집계 변수가 서브셸에 갇히지 않게 한다.
while IFS= read -r mline; do
[ -z "$mline" ] && continue
MOD="${mline%%|*}"
PATHS="${mline#*|}"
if [ -n "$ONLY_MODULES" ]; then
case " $ONLY_MODULES " in
*" $MOD "*) : ;;
*) continue ;;
esac
fi
MOD_DIR="$REPO_ROOT/$MOD"
if [ ! -d "$MOD_DIR/.git" ]; then
echo "[건너뜀] $MOD — git 저장소 아님 ($MOD_DIR)" >&$LOG
continue
fi
# base 커밋 결정
BASE="$(git -C "$MOD_DIR" -c core.quotepath=false rev-list -1 --before="$CUTOFF" HEAD 2>/dev/null || true)"
if [ -z "$BASE" ]; then
BASE="$EMPTY_TREE"
BASE_DESC="(컷오프 이전 커밋 없음 → 전체를 신규로 간주)"
else
BASE_DESC="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --format='%h %ad %s' --date=short "$BASE" | cut -c1-80)"
fi
# 실제 존재하는 소스 경로만 pathspec으로 사용
SPEC=""
for p in $PATHS; do
[ -d "$MOD_DIR/$p" ] && SPEC="$SPEC $p"
done
if [ -z "$SPEC" ]; then
echo "[건너뜀] $MOD — 설정된 소스 경로가 존재하지 않음" >&$LOG
continue
fi
# 신규(Added) 파일만. -M 으로 rename은 신규에서 제외한다.
FILES="$(git -C "$MOD_DIR" -c core.quotepath=false diff --name-only --diff-filter=A -M "$BASE" HEAD -- $SPEC 2>/dev/null || true)"
if [ "$INCLUDE_UNTRACKED" -eq 1 ]; then
UNTRACKED="$(git -C "$MOD_DIR" -c core.quotepath=false ls-files --others --exclude-standard -- $SPEC 2>/dev/null || true)"
FILES="$FILES
$UNTRACKED"
fi
COUNT=0
MISS=0
while IFS= read -r f; do
[ -z "$f" ] && continue
is_allowed_ext "$f" || continue
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
if ! rule_keep "$MOD/$f"; then
EXCLUDED=$((EXCLUDED + 1))
[ "$SHOW_EXCLUDED" -eq 1 ] && printf '%s\t# %s\n' "$MOD/$f" "$MATCHED_RULE"
continue
fi
[ "$SHOW_EXCLUDED" -eq 1 ] && continue
SRC="$MOD_DIR/$f"
if [ ! -f "$SRC" ]; then
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
echo " [없음] $MOD/$f" >&2
MISS=$((MISS + 1))
continue
fi
COUNT=$((COUNT + 1))
if [ "$DRY_RUN" -eq 1 ]; then
echo "$MOD/$f" # 목록은 stdout으로 (가공하기 쉽게 접두 공백 없음)
continue
fi
DST="$OUT_DIR/src/$MOD/$f"
mkdir -p "$(dirname "$DST")"
cp -p "$SRC" "$DST"
META="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --diff-filter=A --format='%h|%ad|%an' --date=short -- "$f" 2>/dev/null || true)"
C_HASH="${META%%|*}"; _r="${META#*|}"
C_DATE="${_r%%|*}"
C_AUTH="${_r#*|}"
BASE_F="${f##*/}"
case "$BASE_F" in *.*) EXT="${BASE_F##*.}" ;; *) EXT="" ;; esac
{
csv_escape "$MOD"; printf ','
csv_escape "$f"; printf ','
csv_escape "$EXT"; printf ','
csv_escape "$C_HASH"; printf ','
csv_escape "$C_DATE"; printf ','
csv_escape "$C_AUTH"; printf '\n'
} >> "$MANIFEST"
done < <(printf '%s\n' "$FILES" | sort -u)
TOTAL=$((TOTAL + COUNT))
MISSING=$((MISSING + MISS))
printf '%s|%s|%s\n' "$MOD" "$COUNT" "$BASE_DESC" >> "$TMP_SUMMARY"
printf '[수집] %-22s %5s 개 base: %s\n' "$MOD" "$COUNT" "$BASE_DESC" >&$LOG
done < <(printf '%s\n' "$MODULE_LINES")
# ---- 요약 ------------------------------------------------------------------
if [ "$DRY_RUN" -eq 0 ]; then
{
echo "# DJB 커스텀 소스 납품본"
echo
echo "- 생성 일시: $(date '+%Y-%m-%d %H:%M:%S')"
echo "- 기준 날짜: \`$CUTOFF\` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)"
echo "- 수집 규칙: \`git diff --diff-filter=A -M <base> HEAD\` / 확장자 allowlist / \`export-filter.rules\` 필터"
echo "- 대상 확장자: \`$ALLOW_EXT\`"
echo "- 규칙 파일: \`$(basename "$RULES_FILE")\` (${#RULES[@]} 개 규칙, 제외 ${EXCLUDED} 건)"
echo
echo "## 모듈별 수집 결과"
echo
echo "| 모듈 | 파일 수 | base 커밋 |"
echo "|---|---:|---|"
while IFS='|' read -r m n d; do
[ -z "$m" ] && continue
echo "| $m | $n | $d |"
done < "$TMP_SUMMARY"
echo
echo "**합계: ${TOTAL} 개**"
[ "$MISSING" -gt 0 ] && echo "> 추가 후 삭제/이동되어 현재 트리에 없는 파일 ${MISSING} 개는 제외됨."
echo
echo "## 파일 목록"
echo
echo "\`MANIFEST.csv\` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자)."
} > "$SUMMARY"
echo
echo "MANIFEST : $MANIFEST"
echo "SUMMARY : $SUMMARY"
if [ "$MAKE_ZIP" -eq 1 ]; then
ZIP_NAME="djb-custom-export-$(date '+%Y%m%d').zip"
ZIP_PATH="$(dirname "$OUT_DIR")/$ZIP_NAME"
rm -f "$ZIP_PATH"
( cd "$OUT_DIR" && zip -qr "$ZIP_PATH" . )
echo "ZIP : $ZIP_PATH"
fi
fi
@@ -0,0 +1,74 @@
# DJB 커스텀 소스 납품 - 포함/제외 규칙
#
# .gitignore 와 비슷하게 동작한다.
# - 한 줄에 패턴 하나. '#' 주석, 빈 줄 무시.
# - 기본은 "모두 포함". 패턴에 걸리면 제외된다.
# - '!' 로 시작하면 예외(다시 포함).
# - 뒤에 오는 규칙이 앞 규칙을 덮는다 (마지막 매칭 승리). → 순서가 중요하다.
# - 패턴에 '/' 가 있으면 <모듈>/<경로> 전체와 glob 매칭,
# 없으면 파일명(basename)과만 매칭한다.
# - '/' 로 끝나면 그 디렉터리 하위 전체를 뜻한다.
# - '*' 는 '/' 도 포함해 매칭한다(단순 glob).
#
# 매칭 대상 경로 예:
# eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
# eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
#
# 확인: ./export-custom.sh --dry-run (남는 목록)
# ./export-custom.sh --dry-run --show-excluded (규칙에 걸려 빠진 목록)
# ===========================================================================
# 1) 제품 업그레이드로 유입된 신규 파일 — 커스텀 아니므로 제외
# filelist-no-djb-marker.txt(djb/custom 마커 없는 신규 파일)를 검토하며 채운다.
# ===========================================================================
# 예시)
# eapim-online/src/main/java/com/eactive/eai/agent/inflow/
# eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/
eapim-portal/src/main/java/com/eactive/apim/portal/config/
eapim-portal/src/main/java/com/eactive/apim/portal/common/
eapim-portal/src/main/resources/templates/views/fragment/popup/
eapim-portal/src/test/
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/
# ===========================================================================
# 2) 위 제외에 걸렸더라도 되살릴 예외
# djb 마커가 붙은 파일은 어떤 경우에도 커스텀이다.
# ※ 아래 3)의 빌드 산출물 제외보다 반드시 앞에 와야 한다.
# ===========================================================================
!*djb*
!*DJB*
!*Djb*
# ===========================================================================
# 3) 무조건 제외 (최종 승리) — 소스가 아니거나 서드파티
# ===========================================================================
*/build/
*/out/
*/bin/
*/.gradle/
*/.metadata/
*/node_modules/
*/generated/
*/design-backup/
*/WebContent/plugins/
# SafeDB(damo) 벤더 API stub — 컴파일용 껍데기라 납품 대상 아님
damo-manager/src/stub/
# 서드파티 폴더 안에 섞여 있는 우리 커스텀 파일은 되살린다
# (예: WebContent/plugins/swaggerUI/djb-swagger-i18n.js)
!*/WebContent/plugins/*djb*
@@ -0,0 +1,157 @@
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsHourRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsMonthRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwApiStatusRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwAuthClientRepository.java
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwSvcInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorChannel.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorInfoResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorSendResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorVerifyResponse.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpAuthInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorCleanupScheduler.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorContext.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/notice/dto/IncidentAffectedApiDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/partnership/dto/PartnershipApplicationSummaryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginFailureReason.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginType.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/ReadinessController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/entity/UserSession.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/filter/SessionValidationFilter.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/repository/UserSessionRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsPeriodDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/entity/JobInfo.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/JobInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/apps/user/service/UserRoleHistoryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/ClientGuardService.java
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/LoginLockPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordChangeEnforcementInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordEnforcementPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/PortalPropertyDuplicateChecker.java
eapim-portal/src/main/java/com/eactive/apim/portal/config/StartupInfoPrinter.java
eapim-portal/src/main/resources/menu.yml
eapim-portal/src/main/resources/roles.yml
eapim-portal/src/main/resources/static/js/api-selector.js
eapim-portal/src/main/resources/static/js/password-policy.js
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
eapim-portal/src/main/resources/static/sass/components/_toast.scss
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
eapim-portal/src/main/resources/templates/views/fragment/popup/cancelInvitationPopup.html
eapim-portal/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html
eapim-portal/src/main/resources/templates/views/fragment/popup/twoFactorAuthPopup.html
eapim-portal/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/PhoneNumberUtilTest.java
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/StringMaskingUtilTest.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapController.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapService.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapServiceImpl.java
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/ui/SitemapNode.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestEmsRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestUISearch.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataService.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataServiceImpl.java
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigRepository.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/authserver/ClientBlockApiController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtils.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryClosingService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUIMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentAffectedApiUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManController.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManService.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUI.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUiMapper.java
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/inflow/inflow/InflowClientControlManController.java
eapim-admin/src/main/resources/apistatus-draft.yml
eapim-admin/src/main/resources/logback-rinjae_ma.xml
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MaskingUtilsTest.java
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtilsTest.java
eapim-admin/WebContent/damo.jsp
eapim-admin/WebContent/jsp/common/acl/sitemap/sitemapMan.jsp
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlMan.jsp
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlManDetail.jsp
eapim-admin/WebContent/jsp/onl/admin/rule/transform2/transform2ManApiPopup.jsp
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleMan.jsp
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleManDetail.jsp
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestMan.jsp
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestManDetail.jsp
eapim-admin/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogMan.jsp
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogManDetail.jsp
eapim-admin/WebContent/jsp/onl/kjb/statistics/apiUseStatsMan.jsp
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/PhoneNumberUtil.java
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/PersonalDataEncryptConverter.java
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/UuidV7Generator.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuItem.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuPlacement.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRole.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthority.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthorityId.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuItemRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuPlacementRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleAuthorityRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/service/PortalMenuDataService.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
elink-portal-common/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java
+173
View File
@@ -0,0 +1,173 @@
eapim-portal/src/main/java/com/eactive/apim/portal/custom/config/DjbPasswordEncoder.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/controller/ApiStatusController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ActiveIncidentDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/AffectedApiDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiCurrentStatusDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/ApiOptionDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/DailyStatDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/IssueDateEntryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MaintenanceCardDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/MyApiStatusDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/PastIssueCardDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/dto/TimelineEntryDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusAssembler.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusSupport.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/apistatus/service/MyApiStatusQueryService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentCreateRequest.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/dto/InquiryCommentDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacade.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbInquiryStatus.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryClosedException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/exception/InquiryCommentNotOwnedException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/community/qna/support/InquiryCommentPermissionChecker.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSite.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/footer/RelatedSiteService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuAccessInterceptor.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuInternalController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuModelAdvice.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuNode.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuSeeder.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/MenuView.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalMenuYmlProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/menu/PortalRolesProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/advice/DjbTestbedExceptionHandler.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedAuthController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialOptionDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbCredentialSecretDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/dto/DjbTestbedContextDto.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbGatewayMode.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/exception/DjbUnsupportedAuthTypeException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedAuthService.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookCreatedResult.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookEventTypeDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookAlreadyExistsException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/exception/WebhookNotFoundException.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestApiRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestEventRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/WebhookRequestRepository.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApi.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestApiId.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEvent.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequestEventId.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookEventTypeProvider.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookSecretGenerator.java
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java
eapim-portal/src/main/resources/menu.yml
eapim-portal/src/main/resources/roles.yml
eapim-portal/src/main/resources/static/favicon.png
eapim-portal/src/main/resources/static/img/avatar1.svg
eapim-portal/src/main/resources/static/img/avatar2.svg
eapim-portal/src/main/resources/static/img/favicon/apple-touch-icon.png
eapim-portal/src/main/resources/static/img/favicon/favicon-16x16.png
eapim-portal/src/main/resources/static/img/favicon/favicon-180x180.png
eapim-portal/src/main/resources/static/img/favicon/favicon-192x192.png
eapim-portal/src/main/resources/static/img/favicon/favicon-32x32.png
eapim-portal/src/main/resources/static/img/favicon/favicon-48x48.png
eapim-portal/src/main/resources/static/img/favicon/favicon-512x512.png
eapim-portal/src/main/resources/static/img/favicon/favicon.png
eapim-portal/src/main/resources/static/img/icon/icon_check_green.png
eapim-portal/src/main/resources/static/img/icon/img_icon.png
eapim-portal/src/main/resources/static/img/keyimage/api_img.png
eapim-portal/src/main/resources/static/img/keyimage/api_img.svg
eapim-portal/src/main/resources/static/img/keyimage/faq_img.png
eapim-portal/src/main/resources/static/img/keyimage/faq_img.svg
eapim-portal/src/main/resources/static/img/keyimage/feedback_img.svg
eapim-portal/src/main/resources/static/img/keyimage/notice_img.png
eapim-portal/src/main/resources/static/img/keyimage/notice_img.svg
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.png
eapim-portal/src/main/resources/static/img/keyimage/q&a_img.svg
eapim-portal/src/main/resources/static/img/keyimage/sigdnUp_img.svg
eapim-portal/src/main/resources/static/img/keyimage/signUp_img.png
eapim-portal/src/main/resources/static/img/logo/logo-djb.png
eapim-portal/src/main/resources/static/img/logo/logo-jjb.png
eapim-portal/src/main/resources/static/img/logo/logo-jjb_white.png
eapim-portal/src/main/resources/static/js/api-selector.js
eapim-portal/src/main/resources/static/js/djb/api-status-issues.js
eapim-portal/src/main/resources/static/js/djb/api-status.js
eapim-portal/src/main/resources/static/js/djb/client-guard.js
eapim-portal/src/main/resources/static/js/djb/inquiry-comments.js
eapim-portal/src/main/resources/static/js/djb/inquiry-view.js
eapim-portal/src/main/resources/static/js/djb/toast.js
eapim-portal/src/main/resources/static/js/password-policy.js
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-i18n.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippets.js
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js
eapim-portal/src/main/resources/static/sass/base/_djb-font.scss
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
eapim-portal/src/main/resources/static/sass/components/_djb-inquiry-comments.scss
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
eapim-portal/src/main/resources/static/sass/components/_toast.scss
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
eapim-portal/src/main/resources/templates/views/apps/community/djb/fragments-inquiry-comments.html
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
eapim-portal/src/main/resources/templates/views/djb/apistatus/index.html
eapim-portal/src/main/resources/templates/views/djb/apistatus/issues.html
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/footer.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/header_container.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/service_sidebar.html
eapim-portal/src/main/resources/templates/views/fragment/djbank/terms_agreements.html
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncident.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApi.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentApiId.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/DjbApistatusIncidentTimeline.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentKind.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/entity/IncidentState.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentApiRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/djb/apistatus/incident/repository/DjbApistatusIncidentTimelineRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
damo-manager/src/main/java/com/eactive/ext/djb/DamoCli.java
damo-manager/src/main/java/com/eactive/ext/djb/DamoManager.java
+26
View File
@@ -0,0 +1,26 @@
# DJB 커스텀 소스 납품 수집 설정
#
# 형식:
# MODULE|<모듈 디렉터리>|<소스 경로들, 공백 구분>
# EXT|<수집 대상 확장자, 콤마 구분>
#
# - 모듈 디렉터리는 저장소 루트(djb-eapim) 기준 상대 경로.
# - 소스 경로가 실제로 없으면 조용히 건너뛴다.
# - '#'으로 시작하는 줄과 빈 줄은 무시된다.
# - 파일 단위 포함/제외는 export-filter.rules 에서 관리한다.
MODULE|eapim-portal|src/main
#MODULE|eapim-admin|src WebContent
MODULE|elink-portal-common|src/main
MODULE|damo-manager|src
# eapim-online 은 납품 범위에서 제외한다.
# Gateway 제품(elink-online) 본체라 신규 파일 중 무엇이 DJB 커스텀이고
# 무엇이 제품 업그레이드분인지 포털 쪽에서 판정할 수 없다.
# 규칙 파일이 아니라 여기서 빼는 이유: export-filter.rules 의 '!*djb*' 예외가
# 뒤에 있어 DJBEncrypt.java 같은 파일이 되살아나기 때문.
#MODULE|eapim-online|src elink-online-common/src elink-online-core/src elink-online-core-jpa/src elink-online-emsclient/src elink-online-transformer/src
# 수집 대상 확장자 (확장자 없는 파일은 제외된다)
# EXT|java,xml,yml,yaml,properties,sql,html,jsp,jspf,tag,js,ts,css,scss,json,txt,md
EXT|java,xml,yml,yaml,properties,html,jsp,jspf,tag,js,ts,css,scss,jpg,jpeg,png,gif,svg,ico
+61
View File
@@ -0,0 +1,61 @@
# SonarQube 분석 설정 (SonarScanner CLI 용)
#
# Jenkinsfile.sonar 가 이 파일을 그대로 읽고, 아래 항목만 실행 시점에 -D 로 덮어쓴다.
# sonar.projectVersion : 빌드 번호
# sonar.java.libraries : ci/sonar-classpath.gradle 이 덤프한 컴파일 클래스패스
# sonar.java.test.libraries : 위 + 테스트 클래스패스
# sonar.junit.reportPaths : 테스트를 실행한 경우에만
#
# 로컬에서 수동 분석 시(스캐너는 JDK 17 필요):
# gradle clean classes testClasses
# gradle -I ci/sonar-classpath.gradle exportSonarClasspath
# sonar-scanner -Dsonar.host.url=... -Dsonar.token=... \
# -Dsonar.java.libraries="$(cat build/sonar/java-libraries.txt)"
sonar.projectKey=djb-eapim-portal
sonar.projectName=DJB eAPIM Portal
sonar.sourceEncoding=UTF-8
# --- 분석 대상 -------------------------------------------------------------
# src/main/resources 를 포함시켜 Thymeleaf 템플릿(XSS), yml/properties(하드코딩 시크릿)까지 검사한다.
sonar.sources=src/main/java,src/main/resources
sonar.tests=src/test/java
# --- Java ------------------------------------------------------------------
# 소스는 Java 8. 스캐너 실행 JRE 는 별개로 17 이상이어야 한다(Jenkinsfile.sonar 참고).
sonar.java.source=8
sonar.java.binaries=build/classes/java/main
sonar.java.test.binaries=build/classes/java/test
# --- 제외 대상 -------------------------------------------------------------
# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다.
sonar.exclusions=\
**/build/**,\
**/node_modules/**,\
**/lib/**,\
**/libs/**,\
**/local_jars/**,\
src/main/resources/static/plugins/**,\
src/main/resources/static/js/egovframework/**,\
src/main/resources/static/js/**/*.min.js,\
src/main/resources/static/js/lodash.js,\
src/main/resources/static/js/popper.js,\
src/main/resources/static/js/daterangepicker.js,\
src/main/resources/static/js/json-lint.js,\
src/main/resources/static/html/**,\
src/main/resources/static/css/**,\
src/main/resources/static/sass/**,\
src/main/resources/static/font/**,\
src/main/resources/static/webfonts/**,\
src/main/resources/static/img/**
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
# --- SCM -------------------------------------------------------------------
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
sonar.scm.provider=git
# --- 브랜치 ----------------------------------------------------------------
# sonar.branch.name 은 SonarQube Developer Edition 이상에서만 동작한다.
# Community Edition 에서 지정하면 분석이 실패하므로 기본적으로 설정하지 않는다.
# sonar.branch.name=feats/ci-test
@@ -0,0 +1,36 @@
package com.eactive.apim.gateway.data.statistics.entity;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@Data
public class GwApiStatus {
/** EAI 서비스명 */
@Id
@Column(name = "EAISVCNAME", length = 30)
private String eaisvcname;
/** N 정상 / C 점검 / D 지연 / E 장애 */
@Column(name = "STATUS_CODE", length = 1)
private String statusCode;
@Column(name = "MODIFIED_BY", length = 20)
private String modifiedBy;
@Column(name = "MODIFIED_DATE")
private LocalDateTime modifiedDate;
}
@@ -0,0 +1,17 @@
package com.eactive.apim.gateway.data.statistics.repository;
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
}
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -31,6 +32,7 @@ public class ApiController {
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -86,6 +88,8 @@ public class ApiController {
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList";
}
@@ -44,13 +44,14 @@ public class TestbedSpecController {
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
/** default 토큰 spec 또는 저장 spec(서버 → GW 주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return null;
@@ -61,6 +62,6 @@ public class TestbedSpecController {
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
}
}
@@ -12,6 +12,9 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,6 +25,57 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
/** 응답 charset 을 Content-Type 에서 얻지 못할 때 사용할 기본값. */
private static final String DEFAULT_CHARSET = "UTF-8";
/**
* 프록시 대상(GW/mock) 응답을 상태코드까지 포함해 전달하기 위한 홀더.
*
* <p>본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로
* 상태코드와 Content-Type 을 함께 담는다.</p>
*/
public static class ApiResponse {
private final int status;
private final String body;
private final String contentType;
private final Map<String, List<String>> headers;
public ApiResponse(int status, String body, String contentType, Map<String, List<String>> headers) {
this.status = status;
this.body = body;
this.contentType = contentType;
this.headers = headers == null
? Collections.<String, List<String>>emptyMap()
: Collections.unmodifiableMap(headers);
}
public int getStatus() {
return status;
}
public String getBody() {
return body;
}
/** 대상 응답의 Content-Type 원본 (없으면 null). */
public String getContentType() {
return contentType;
}
/** 대상 응답 헤더 (상태줄 의사헤더인 null 키는 제외). 값은 헤더당 복수 가능. */
public Map<String, List<String>> getHeaders() {
return headers;
}
@Override
public String toString() {
return "ApiResponse{status=" + status + ", contentType=" + contentType
+ ", bodyLen=" + (body == null ? 0 : body.length())
+ ", headers=" + headers.keySet() + "}";
}
}
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
@@ -36,7 +90,7 @@ public class APISender {
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
public ApiResponse requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
@@ -45,11 +99,11 @@ public class APISender {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection);
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
@@ -75,7 +129,7 @@ public class APISender {
return uriBuilder.toString();
}
public String requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
public ApiResponse requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -95,16 +149,16 @@ public class APISender {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
}
String response = getResponse(connection);
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
logger.debug("APISender GET 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
public String requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
public ApiResponse requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
@@ -132,30 +186,67 @@ public class APISender {
outputStream.flush();
}
String response = getResponse(connection);
ApiResponse response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
logger.debug("APISender POST 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
}
return response;
}
private static String getResponse(HttpURLConnection connection) throws IOException {
/**
* 대상 응답을 상태코드·Content-Type·본문으로 읽는다.
*
* <p>4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고,
* 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.</p>
*/
private static ApiResponse getResponse(HttpURLConnection connection) throws IOException {
int responseCode = connection.getResponseCode();
String contentType = connection.getContentType();
StringBuilder response = new StringBuilder();
try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream);
BufferedReader reader = new BufferedReader(isr)) {
InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream();
if (stream != null) {
try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType));
BufferedReader reader = new BufferedReader(isr)) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
}
return response.toString();
return new ApiResponse(responseCode, response.toString(), contentType, copyHeaders(connection));
}
/** 대상 응답 헤더 복사. {@code getHeaderFields()} 의 null 키(상태줄)는 제외. */
private static Map<String, List<String>> copyHeaders(HttpURLConnection connection) {
Map<String, List<String>> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {
if (entry.getKey() != null) {
headers.put(entry.getKey(), entry.getValue());
}
}
return headers;
}
/** Content-Type 의 charset 파라미터를 파싱. 없거나 인식 불가면 UTF-8. */
private static String charsetOf(String contentType) {
if (contentType != null) {
for (String part : contentType.split(";")) {
String token = part.trim();
if (token.toLowerCase().startsWith("charset=")) {
String charset = token.substring("charset=".length()).replace("\"", "").trim();
if (!charset.isEmpty() && java.nio.charset.Charset.isSupported(charset)) {
return charset;
}
}
}
}
return DEFAULT_CHARSET;
}
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
@@ -11,9 +11,13 @@ import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
@@ -32,6 +36,13 @@ public class ApiTesterFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(ApiTesterFilter.class);
/** 대상 응답에서 클라이언트로 되돌리지 않는 헤더 (소문자 비교). */
private static final Set<String> BLOCKED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList(
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade",
"content-length", "content-encoding", "content-type",
"set-cookie", "set-cookie2"));
@Override
public void init(FilterConfig filterConfig) throws ServletException {
logger.debug("ApiTesterFilter initialized");
@@ -141,14 +152,16 @@ public class ApiTesterFilter implements Filter {
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
}
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
response.getWriter().println(tokenResponse);
writeUpstream(response, tokenResponse);
}
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
// 스펙 조회는 original-api-id 헤더(Swagger UI 가 x-original-api-id 확장에서 전달) 우선.
// mock/gw 응답유형은 서버주소가 mockUrl·GW base 로 치환돼 original-url 의 path 가 저장된
// api_url 과 일치하지 않으므로 URL 매칭만으로는 스펙을 찾지 못한다.
ApiSpecInfoDto apiSpecInfoDto = selectSpec(apiSpecInfoDtoService, httpServletRequest, url);
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
@@ -190,11 +203,12 @@ public class ApiTesterFilter implements Filter {
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
paramMap = new HashMap<>();
} else {
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
@@ -212,19 +226,18 @@ public class ApiTesterFilter implements Filter {
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
APISender.ApiResponse upstream;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
upstream = apiSender.requestGet(targetUri, headers, paramMap);
}
if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
auditType, auditId, targetUri,
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
auditType, auditId, targetUri, upstream.getStatus(),
upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
writeUpstream(response, upstream);
}
} catch (java.net.SocketTimeoutException e) {
@@ -305,6 +318,25 @@ public class ApiTesterFilter implements Filter {
return sb.toString();
}
/**
* 호출 대상 API 명세를 찾는다. {@code original-api-id} 헤더가 있으면 API ID 로, 없으면 기존처럼
* {@code original-url} 의 path + 메서드로 조회한다. 없으면 null.
*
* <p>API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 <b>포탈 게시(display_yn='Y')</b> 인
* 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.</p>
*/
private ApiSpecInfoDto selectSpec(ApiService apiService, HttpServletRequest request, String url) {
String apiId = request.getHeader("original-api-id");
if (apiId != null && !apiId.trim().isEmpty()) {
ApiSpecInfoDto dto = apiService.selectDetail(apiId.trim());
if (dto != null && "Y".equalsIgnoreCase(dto.getDisplayYn())) {
return dto;
}
logger.debug("original-api-id 로 게시된 스펙을 찾지 못해 URL 매칭으로 폴백 - apiId={}", apiId);
}
return apiService.selectDetailByURLAndMethod(parseUri(url), request.getMethod());
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
@@ -320,7 +352,53 @@ public class ApiTesterFilter implements Filter {
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
/**
* 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다.
*
* <p>대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다.
* 단 아래 헤더는 전달하지 않는다.</p>
* <ul>
* <li>hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님</li>
* <li>content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음</li>
* <li>set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단</li>
* </ul>
*/
private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(upstream.getStatus());
for (Map.Entry<String, List<String>> entry : upstream.getHeaders().entrySet()) {
String name = entry.getKey();
if (isBlockedResponseHeader(name) || entry.getValue() == null) {
continue;
}
boolean first = true;
for (String value : entry.getValue()) {
if (value == null) {
continue;
}
if (first) {
httpResponse.setHeader(name, value);
first = false;
} else {
httpResponse.addHeader(name, value);
}
}
}
// Content-Type 은 응답 문자셋까지 결정하므로 헤더 복사와 별개로 마지막에 확정한다.
String contentType = upstream.getContentType();
response.setContentType(contentType == null || contentType.trim().isEmpty()
? "application/json" : contentType);
response.getWriter().println(upstream.getBody() == null ? "" : upstream.getBody());
}
/** 클라이언트로 되돌리면 안 되는 응답 헤더인지. */
private boolean isBlockedResponseHeader(String name) {
return name == null || BLOCKED_RESPONSE_HEADERS.contains(name.toLowerCase());
}
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{");
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
/**
* 조회 결과가 없으면 {@code null} 을 반환한다.
*
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
*/
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto());
return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
}
}
@@ -23,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -102,8 +104,28 @@ public class MyAppController {
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(user.getPortalOrg());
model.addAttribute("appRequests", appRequests);
// 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다.
// (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문)
// 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다.
Set<String> ownedClientIds = apikeys.stream()
.map(ClientDTO::getClientid)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
Map<String, AppRequest> openRequestByClient = new java.util.HashMap<>();
List<AppRequest> standaloneRequests = new java.util.ArrayList<>();
for (AppRequest request : appRequests) {
String clientId = request.getClientId();
if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId)
&& openRequestByClient.putIfAbsent(clientId, request) == null) {
continue;
}
standaloneRequests.add(request);
}
model.addAttribute("appRequests", standaloneRequests);
model.addAttribute("apiKeys", apikeys);
model.addAttribute("openRequestByClient", openRequestByClient);
return new ModelAndView(API_KEY_LIST);
}
@@ -184,6 +206,10 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -247,6 +273,36 @@ public class MyAppController {
* @param requestData 요청 데이터 (clientId, reason)
* @return 성공/실패 결과를 담은 Map
*/
/**
* 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다.
*
* <p>step-up 2FA 가 걸린 {@code /clients/api_key_delete} <b>이전</b>에 호출해,
* 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다).
* 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.</p>
*/
@GetMapping("/credential/delete-precheck")
@Secured("ROLE_API_KEY_REQUEST")
@ResponseBody
public Map<String, Object> deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) {
Map<String, Object> result = new java.util.HashMap<>();
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지)
if (clientId == null || clientId.trim().isEmpty()
|| appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("blocked", true);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId);
result.put("blocked", blockReason != null);
result.put("msg", blockReason);
return result;
}
@PostMapping("/api_key_delete")
@Secured("ROLE_API_KEY_REQUEST")
@ResponseBody
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
private byte[] appIconData;
private String appIconContentType;
@NotBlank(message = " 이름을 입력해주세요.")
@Length(max = 100, message = " 이름은 100자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 이름을 입력해주세요.")
@Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
private String appName;
@NotBlank(message = " 설명을 입력해주세요.")
@Length(max = 500, message = " 설명은 500자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 설명을 입력해주세요.")
@Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
private String appDescription;
private String callbackUrl;
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.CreatedState;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
@@ -169,21 +170,9 @@ public class AppServiceFacade {
*/
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
for (AppRequest r : related) {
if (r.getApproval() == null) {
continue;
}
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
if (!inProgress) {
continue;
}
if (AppRequestType.DELETE.equals(r.getType())) {
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
}
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
String blockReason = resolveDeleteBlockReason(clientId);
if (blockReason != null) {
throw new IllegalStateException(blockReason);
}
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
@@ -203,6 +192,58 @@ public class AppServiceFacade {
beginApproval(saved.getApproval().getId());
}
/**
* 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다.
*
* <p>미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와
* 화면/사전검사가 <b>같은 판정</b>({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다.
* (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고,
* 재시도할 때마다 2FA 가 반복된다.)</p>
*/
public String resolveDeleteBlockReason(String clientId) {
AppRequest open = findOpenRequestEntity(clientId);
if (open == null) {
return null;
}
if (AppRequestType.DELETE.equals(open.getType())) {
return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.";
}
return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.";
}
/**
* 해당 인증키에 걸려 있는 <b>미완료</b> 변경/해지 신청 1건. 없으면 {@code null}.
* 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다.
*/
public AppRequestDTO findOpenRequest(String clientId) {
AppRequest open = findOpenRequestEntity(clientId);
return open == null ? null : appRequestMapper.toVo(open);
}
/**
* 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건.
*
* <p>결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에
* 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다.
* 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.</p>
*/
private AppRequest findOpenRequestEntity(String clientId) {
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
for (AppRequest r : related) {
if (r.getApproval() == null) {
return r;
}
Object status = r.getApproval().getApprovalStatus();
if (status instanceof CreatedState
|| status instanceof RequestedState
|| status instanceof ProcessingState) {
return r;
}
}
return null;
}
/**
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
@@ -4,11 +4,23 @@ public interface AuthNumberService {
String sendRequestAuthNumber(String recipientKey, String msgType);
/**
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
*/
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
/**
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
*/
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);
}
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
@Override
@Transactional(noRollbackFor = AuthNumberException.class)
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
@Transactional(noRollbackFor = AuthNumberException.class)
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);
validateResendTime(recipientKey);
String authNumber = generator.generateAuthNumber();
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
messageSender.sendAuthMessage(recipient, authNumber, msgType);
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();
recipient.setUserId(recipientKey);
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
if (username != null && !username.trim().isEmpty()) {
recipient.setUsername(username);
}
if ("SMS".equalsIgnoreCase(msgType)) {
recipient.setPhone(recipientKey);
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
@@ -41,8 +41,9 @@ public class TwoFactorController {
public TwoFactorSendResponse send(@RequestParam String channel,
@RequestParam(required = false) String purpose,
@RequestParam(required = false, defaultValue = "false") boolean force,
HttpServletRequest request,
HttpSession session) {
return twoFactorService.send(session, channel, purpose, force);
return twoFactorService.send(request, session, channel, purpose, force);
}
/** 인증번호 검증 */
@@ -115,7 +115,8 @@ public class TwoFactorService {
// SEND
// =========================================================================
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
String purpose, boolean force) {
TwoFactorSendResponse res = new TwoFactorSendResponse();
TwoFactorContext.Mode mode = resolveMode(session);
@@ -156,7 +157,7 @@ public class TwoFactorService {
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
return res;
}
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
} else if (ctx.getStartedAt() != null
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
res.setValid(false);
@@ -280,7 +281,7 @@ public class TwoFactorService {
String stateError = revalidateLoginState(user);
if (stateError != null) {
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.ACCOUNT_DISABLED);
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
clearPending(session);
res.setValid(false);
res.setTerminated(true);
@@ -316,7 +317,8 @@ public class TwoFactorService {
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
request.getHeader("User-Agent"));
}
if (ctx != null && ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
@@ -456,10 +458,11 @@ public class TwoFactorService {
}
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
private void discardContext(HttpSession session, TwoFactorContext ctx) {
private void discardContext(HttpServletRequest request, HttpSession session, TwoFactorContext ctx) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
}
if (ctx.getRecipient() != null) {
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
@@ -472,7 +475,8 @@ public class TwoFactorService {
LoginFailureReason reason, HttpServletRequest request) {
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
request.getHeader("User-Agent"));
clearPending(session);
}
if (ctx.getRecipient() != null) {
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
private List<TimelineEntryDTO> timeline = Collections.emptyList();
public boolean hasTimeline() {
return timeline != null && !timeline.isEmpty();
}
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
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.JpaSpecificationExecutor;
@EMSDataSource
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
/**
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
*/
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
}
@@ -5,6 +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.mapper.PortalNoticeMapper;
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.ApiStatusCatalogService;
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.portalNotice.entity.PortalNotice;
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusAssembler apiStatusAssembler;
private final ApiStatusCatalogService apiStatusCatalogService;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
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.setHiddenApiCount(hiddenCount);
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
dto.setTimeline(degrading
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
: Collections.emptyList());
}
}
@@ -44,6 +44,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
FileTypeContext.setFileType("business");
file = fileService.createFile(partnershipApplicationDTO.getFiles());
} finally {
FileTypeContext.clear();
FileService.clearInternalUserContext();
}
}
@@ -92,7 +92,12 @@ public class LoginFinalizer {
String ip = request.getRemoteAddr();
String sessionId = request.getSession().getId();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
// 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate);
String contextPath = request.getContextPath();
HttpSession session = request.getSession();
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
private final AuthNoticeProperties authNoticeProperties;
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 {
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
response.setValid(true);
response.setMessage("인증번호를 발송하였습니다.");
// 테스트 환경(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.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final PortalUserRepository portalUserRepository;
private final PortalUserMapper portalUserMapper;
private final PortalProperties portalProperties;
private final PortalRolesProperties portalRolesProperties;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
List<String> roles = portalRolesProperties.getAuthorities(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
@@ -12,17 +12,22 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
public class PortalUserLogService {
/** PTL_USER_LOG.USER_AGENT 컬럼 길이. 초과분은 잘라서 저장한다(초과 시 ORA-12899 로 로그인 실패). */
private static final int USER_AGENT_MAX_LENGTH = 500;
private final UserLogRepository userLogRepository;
public PortalUserLogService(UserLogRepository userLogRepository) {
this.userLogRepository = userLogRepository;
}
public void logSuccess(String userId, String ip, String sessionId) {
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
}
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
/**
* 로그인 성공 기록.
*
* @param duplicate 로그인 시점에 다른 활성 세션이 남아 있어 기존 세션이 강제 해제된 건이면 true
*/
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType, String userAgent,
boolean duplicate) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now());
@@ -30,15 +35,13 @@ public class PortalUserLogService {
log.setSessionId(sessionId);
log.setSuccess(true);
log.setLoginType(loginType != null ? loginType.name() : null);
log.setUserAgent(truncateUserAgent(userAgent));
log.setDuplicate(duplicate);
userLogRepository.save(log);
}
public void logFailure(String userId, String ip, String sessionId) {
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
}
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason, String userAgent) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLoginTime(LocalDateTime.now());
@@ -46,19 +49,29 @@ public class PortalUserLogService {
log.setSessionId(sessionId);
log.setSuccess(false);
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log);
}
@Transactional
public void logLogout(String userId, String ip, String sessionId) {
public void logLogout(String userId, String ip, String sessionId, String userAgent) {
UserLog log = new UserLog();
log.setLoginId(userId);
log.setLogoutTime(LocalDateTime.now());
log.setIp(ip);
log.setSessionId(sessionId);
log.setSuccess(true);
log.setUserAgent(truncateUserAgent(userAgent));
userLogRepository.save(log);
}
private String truncateUserAgent(String userAgent) {
if (userAgent == null) {
return null;
}
return userAgent.length() <= USER_AGENT_MAX_LENGTH
? userAgent : userAgent.substring(0, USER_AGENT_MAX_LENGTH);
}
}
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@@ -35,7 +36,7 @@ public class UserRoleHistoryService {
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
*/
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
try {
String actor = SecurityUtil.getCurrentLoginId();
@@ -12,6 +12,9 @@ import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
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.djb.guide.GuideProperty;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice
@@ -35,6 +38,12 @@ public class GlobalControllerAdvice {
@Autowired
private AuthNoticeProperties authNoticeProperties;
@Autowired
private RelatedSiteService relatedSiteService;
@Autowired
private GuideProperty guideProperty;
@Autowired
private Environment environment;
@@ -111,4 +120,36 @@ public class GlobalControllerAdvice {
return portalPropertyService.getOrCreateProperty(
"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();
}
/**
* 개발가이드 안내페이지에 표기할 게이트웨이 base URL.
* PortalProperty(Portal/djb.guide.gw-url)에서 조회하며, 환경별로 값이 달라진다.
*
* <p>가이드 페이지(/service/*-guide)에서만 필요하므로 다른 요청에서는 DB 조회 없이 null 을 반환한다.
*/
@ModelAttribute("guideGwUrl")
public String guideGwUrl(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null || !uri.endsWith("-guide")) {
return null;
}
return guideProperty.gatewayUrl();
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.migration;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
@@ -20,6 +21,8 @@ import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
@@ -29,15 +32,19 @@ import java.util.Map;
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
*
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
* 작업 완료 후 이 클래스는 제거한다.</p>
*
* <pre>
* # 미리보기(변경 안 함)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
* # 실제 실행 (PII 컬럼)
* 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'
* </pre>
*/
@@ -46,13 +53,14 @@ import java.util.Map;
@RequestMapping("/internal/migration")
public class LegacyEncryptionMigrationController {
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
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_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_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) 상속 테이블의 감사 컬럼 (옵션) */
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
"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");
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 PortalPropertyService portalPropertyService;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
PortalPropertyService portalPropertyService) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
this.portalPropertyService = portalPropertyService;
}
@PostMapping("/encrypt-legacy")
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) {
assertLocalOnly(request);
assertAllowedIp(request);
assertNotBypass();
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
List<Map<String, Object>> results = new ArrayList<>();
int totalChanged = 0;
int totalSkipped = 0;
for (TargetTable target : targets) {
for (String column : target.columns) {
Map<String, Object> r = processColumn(target.table, column, dryRun);
results.add(r);
totalChanged += (int) r.get("changed");
totalSkipped += (int) r.get("skipped");
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("damoMode", resolveDamoMode());
response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged);
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
response.put("totalSkipped", totalSkipped);
response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
return response;
}
private String resolveDamoMode() {
if (converter.isBypassMode()) {
return "BYPASS";
}
return converter.isFakeMode() ? "FAKE" : "REAL";
}
/**
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
*/
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
r.put("distinct", 0);
r.put("changed", 0);
r.put("skipped", 0);
r.put("error", e.getMessage());
return r;
}
int changed = 0;
int skipped = 0;
for (String value : values) {
String normalized;
try {
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
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 (!dryRun) {
jdbcTemplate.update(
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
r.put("distinct", values.size());
r.put("changed", changed);
r.put("skipped", skipped);
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) {
String remote = request.getRemoteAddr();
boolean localAddr = "127.0.0.1".equals(remote)
|| "0:0:0:0:0:0:0:1".equals(remote)
|| "::1".equals(remote);
private void assertAllowedIp(HttpServletRequest request) {
String remote = canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (!localAddr || viaProxy) {
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
.map(String::trim)
.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 {
final String table;
final List<String> columns;
@@ -3,9 +3,18 @@ package com.eactive.apim.portal.common.util;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 사용자 유형 판별 유틸리티
* 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공
*
* 내부 사용자로 인정할 이메일 도메인은 application.yml 의
* {@code portal.internal-user.email-domains} 로 관리한다.
* 설정이 없으면 {@link #DEFAULT_INTERNAL_EMAIL_DOMAINS} 를 사용한다.
*/
public final class UserTypeUtil {
@@ -13,7 +22,47 @@ public final class UserTypeUtil {
throw new IllegalStateException("Utility class");
}
private static final String INTERNAL_EMAIL_DOMAIN = "@kjbank.com";
/** 설정 미주입 시 사용할 기본 내부 도메인 */
private static final List<String> DEFAULT_INTERNAL_EMAIL_DOMAINS =
Collections.unmodifiableList(Arrays.asList("@djbank.com", "@unit-test.com"));
private static volatile List<String> internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
/**
* 내부 사용자 이메일 도메인 목록 설정.
* {@code UserTypeConfigurer} 가 기동 시 application.yml 값으로 주입한다.
*
* @param domains 도메인 목록 ("@" 접두사 유무 무관, 대소문자 무관)
*/
public static void setInternalEmailDomains(List<String> domains) {
if (domains == null || domains.isEmpty()) {
internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
return;
}
List<String> normalized = new ArrayList<>();
for (String domain : domains) {
if (StringUtils.isBlank(domain)) {
continue;
}
String value = domain.trim().toLowerCase();
if (!value.startsWith("@")) {
value = "@" + value;
}
if (!normalized.contains(value)) {
normalized.add(value);
}
}
internalEmailDomains = normalized.isEmpty()
? DEFAULT_INTERNAL_EMAIL_DOMAINS
: Collections.unmodifiableList(normalized);
}
/**
* 현재 적용 중인 내부 사용자 이메일 도메인 목록
*/
public static List<String> getInternalEmailDomains() {
return internalEmailDomains;
}
/**
* 현재 사용자가 내부 운영자인지 확인
@@ -21,10 +70,28 @@ public final class UserTypeUtil {
* @return 내부 운영자 여부
*/
public static boolean isInternalUser(PortalAuthenticatedUser user) {
if (user == null || StringUtils.isEmpty(user.getEmailAddr())) {
if (user == null) {
return false;
}
return user.getEmailAddr().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN);
return isInternalEmail(user.getEmailAddr());
}
/**
* 이메일 주소의 호스트(도메인)로 내부 운영자 여부 판별
* @param emailAddr 이메일 주소
* @return 내부 운영자 여부
*/
public static boolean isInternalEmail(String emailAddr) {
if (StringUtils.isEmpty(emailAddr)) {
return false;
}
String email = emailAddr.trim().toLowerCase();
for (String domain : internalEmailDomains) {
if (email.endsWith(domain)) {
return true;
}
}
return false;
}
/**
@@ -0,0 +1,165 @@
package com.eactive.apim.portal.config;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
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.TreeMap;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.handler.AbstractHandlerMapping;
import org.springframework.web.servlet.handler.MappedInterceptor;
/**
* HandlerInterceptor 목록을 Actuator 로 노출한다.
*
* <p>배경: Actuator 의 {@code /actuator/mappings} 는 servlet filter / servlet / handler mapping 만
* 보여주고 interceptor 는 항목 자체가 없다. {@code /actuator/beans} 로도 안 보이는데,
* {@link PortalConfigWebDispatcherServlet#addInterceptors} 가 {@code new} 로 직접 생성해 등록하므로
* 스프링 빈이 아니기 때문이다. 그래서 HandlerMapping 내부 목록을 직접 읽어서 노출한다.
*
* <p>노출 경로 2가지:
* <ul>
* <li>{@code GET /actuator/interceptors} — 전용 엔드포인트</li>
* <li>{@code GET /actuator/info} 의 {@code interceptors} 항목 — Spring Boot Admin 은 커스텀
* 엔드포인트용 화면이 없으므로, SBA UI(Details 탭의 Info 카드)에서 보려면 이쪽이 필요하다.
* {@code management.info.env.enabled} 와 무관하게 InfoContributor 는 항상 동작한다.</li>
* </ul>
*
* <p>이 설정은 actuator 가 classpath 에 있을 때만 활성화된다. actuator 는 build.gradle 에서
* 로컬 전용(runtimeOnly + war 산출물 제외)이므로 WebLogic 배포본(dev/stage/prod)에서는
* 조건이 거짓이 되어 이 클래스 자체가 로드되지 않는다.
*/
@Configuration
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
public class InterceptorsEndpointConfig {
@Bean
public InterceptorsEndpoint interceptorsEndpoint(ApplicationContext applicationContext) {
return new InterceptorsEndpoint(applicationContext);
}
@Endpoint(id = "interceptors")
public static class InterceptorsEndpoint implements InfoContributor {
/** AbstractHandlerMapping 이 실제로 실행하는 interceptor 목록(private final). */
private static final Field ADAPTED_INTERCEPTORS =
ReflectionUtils.findField(AbstractHandlerMapping.class, "adaptedInterceptors");
/** MappedInterceptor 의 제외 패턴(private final PatternAdapter[]). include 는 public getter 가 있다. */
private static final Field EXCLUDE_PATTERNS =
ReflectionUtils.findField(MappedInterceptor.class, "excludePatterns");
static {
if (ADAPTED_INTERCEPTORS != null) {
ReflectionUtils.makeAccessible(ADAPTED_INTERCEPTORS);
}
if (EXCLUDE_PATTERNS != null) {
ReflectionUtils.makeAccessible(EXCLUDE_PATTERNS);
}
}
private final ApplicationContext applicationContext;
public InterceptorsEndpoint(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
/**
* HandlerMapping 빈 이름 → 등록 순서대로의 interceptor 목록.
* 목록 순서가 곧 preHandle 실행 순서다(afterCompletion 은 역순).
*/
@ReadOperation
public Map<String, List<Map<String, Object>>> interceptors() {
Map<String, List<Map<String, Object>>> result = new TreeMap<>();
Map<String, AbstractHandlerMapping> mappings =
applicationContext.getBeansOfType(AbstractHandlerMapping.class);
for (Map.Entry<String, AbstractHandlerMapping> entry : mappings.entrySet()) {
List<Map<String, Object>> described = describe(entry.getValue());
if (!described.isEmpty()) {
result.put(entry.getKey(), described);
}
}
return result;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("interceptors", interceptors());
}
private List<Map<String, Object>> describe(AbstractHandlerMapping mapping) {
if (ADAPTED_INTERCEPTORS == null) {
return Collections.emptyList();
}
Object raw = ReflectionUtils.getField(ADAPTED_INTERCEPTORS, mapping);
if (!(raw instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> described = new ArrayList<>();
int order = 0;
for (Object each : (List<?>) raw) {
described.add(describe(order++, (HandlerInterceptor) each));
}
return described;
}
private Map<String, Object> describe(int order, HandlerInterceptor interceptor) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("order", order);
if (interceptor instanceof MappedInterceptor) {
MappedInterceptor mapped = (MappedInterceptor) interceptor;
row.put("interceptor", mapped.getInterceptor().getClass().getName());
row.put("include", nullSafe(mapped.getPathPatterns()));
row.put("exclude", excludePatterns(mapped));
} else {
// WebMvcConfigurationSupport 가 자동으로 넣는 것들(ConversionServiceExposing 등).
// 경로 제한이 없어 모든 요청에 적용된다.
row.put("interceptor", interceptor.getClass().getName());
row.put("include", Collections.singletonList("/**"));
row.put("exclude", Collections.emptyList());
}
return row;
}
/** PatternAdapter[] → 패턴 문자열 목록. PatternAdapter 는 package-private 이라 리플렉션으로 읽는다. */
private List<String> excludePatterns(MappedInterceptor mapped) {
if (EXCLUDE_PATTERNS == null) {
return Collections.emptyList();
}
Object adapters = ReflectionUtils.getField(EXCLUDE_PATTERNS, mapped);
if (adapters == null) {
return Collections.emptyList();
}
int length = Array.getLength(adapters);
List<String> patterns = new ArrayList<>(length);
for (int i = 0; i < length; i++) {
Object adapter = Array.get(adapters, i);
Method getPatternString = ReflectionUtils.findMethod(adapter.getClass(), "getPatternString");
if (getPatternString == null) {
continue;
}
ReflectionUtils.makeAccessible(getPatternString);
patterns.add((String) ReflectionUtils.invokeMethod(getPatternString, adapter));
}
return patterns;
}
private List<String> nullSafe(String[] values) {
return values == null ? Collections.emptyList() : Arrays.asList(values);
}
}
}
@@ -97,7 +97,8 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
}
}
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception),
request.getHeader("User-Agent"));
// 로그인 실패 시 세션 정보 로깅
logLoginFailure(request, username, exception);
@@ -0,0 +1,91 @@
package com.eactive.apim.portal.config;
import javax.servlet.DispatcherType;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.filter.ForwardedHeaderFilter;
/**
* 리버스프록시(OHS) 전달 헤더 처리 필터 등록.
*
* <p>{@code server.forward-headers-strategy=framework} 일 때만 활성화된다. Spring Boot 오토컨피그
* ({@code ServletWebServerFactoryAutoConfiguration.ForwardedHeaderFilterConfiguration}) 가 동일 조건에서
* 기본 {@link ForwardedHeaderFilter} 를 등록하지만, 여기서 직접 등록해 <b>{@code /internal/**} 를 필터 대상에서
* 제외</b>한다. 오토컨피그는 해당 필터 빈이 이미 있으면 물러난다.
*
* <h3>왜 native 가 아니라 framework 인가</h3>
* {@code native} 는 내장 Tomcat 의 {@code RemoteIpValve} 에 위임하는 전략이라 WAR 로 WebLogic 에 배포하면
* 아무 동작도 하지 않는다({@link com.eactive.apim.portal.PortalApplication} 은 {@code SpringBootServletInitializer}
* 상속). {@code framework} 는 평범한 서블릿 필터라 배포 형태와 무관하게 동작한다.
*
* <h3>왜 {@code /internal/**} 를 제외하는가</h3>
* {@link ForwardedHeaderFilter} 는 (1) {@code getRemoteAddr()} 을 {@code X-Forwarded-For} 값으로 치환하고
* (2) 처리한 forwarded 헤더를 요청에서 제거한다. 내부 API 는 이 두 값으로 접근을 통제하므로 그대로 두면 가드가 뒤집힌다.
*
* <pre>
* 공격자: X-Forwarded-For: 127.0.0.1
* → getRemoteAddr() = "127.0.0.1" (위조값이 허용 IP 검사를 통과)
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
* </pre>
*
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
* 검사되므로 기존 가드가 설계대로 동작한다.
*
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
*
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "server.forward-headers-strategy", havingValue = "framework")
public class PortalConfigForwardedHeader {
/** 프록시 헤더를 신뢰하지 않고 원 소켓 IP 로 판정해야 하는 경로. */
private static final String INTERNAL_PREFIX = "/internal/";
@Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilterRegistration() {
FilterRegistrationBean<ForwardedHeaderFilter> registration =
new FilterRegistrationBean<>(new InternalExcludingForwardedHeaderFilter());
// 다른 필터(XSS, Security 등)가 요청을 읽기 전에 scheme/host/port/remoteAddr 이 교정되어야 한다.
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR);
log.info("ForwardedHeaderFilter 등록 - X-Forwarded-* 반영, 제외 경로: {}**", INTERNAL_PREFIX);
return registration;
}
/** {@code /internal/**} 요청은 원본 요청 그대로 통과시키는 {@link ForwardedHeaderFilter}. */
static class InternalExcludingForwardedHeaderFilter extends ForwardedHeaderFilter {
/**
* {@code super} 구현은 "forwarded 계열 헤더가 하나도 없으면 필터링하지 않는다"이다.
* 그 최적화를 유지해야 프록시를 거치지 않는 환경(로컬·내부 직접 접속)의 동작이 기존과 동일하게 남으므로
* 내부 경로 판정과 반드시 OR 로 결합한다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return isInternal(request) || super.shouldNotFilter(request);
}
private boolean isInternal(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// context-path 는 현재 "/" 이지만, 변경되더라도 경로 판정이 어긋나지 않게 접두를 걷어낸다.
String contextPath = request.getContextPath();
if (contextPath != null && contextPath.length() > 1 && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return uri.startsWith(INTERNAL_PREFIX);
}
}
}
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
private final Environment environment;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
com.eactive.apim.portal.djb.menu.MenuService menuService) {
this.environment = environment;
this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties;
this.menuService = menuService;
}
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/auth/2fa/**");
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/internal/**", "/auth/2fa/**",
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
}
@Bean
@@ -1,13 +1,12 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Data
@Component
@@ -25,10 +24,22 @@ public class PortalProperties {
private String authVirtualCode = "";
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties();
private InternalUserProperties internalUser = new InternalUserProperties();
/**
* 내부 사용자(운영자) 판별 설정
*/
@Data
public static class InternalUserProperties {
/**
* 내부 사용자로 분류할 이메일 도메인 목록.
* "@" 접두사는 있어도 되고 없어도 되며, 대소문자를 구분하지 않는다.
*/
private List<String> emailDomains = new ArrayList<>();
}
/**
* 파일 업로드 관련 설정
*/
@@ -0,0 +1,26 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
/**
* application.yml 의 {@code portal.internal-user.email-domains} 값을
* 정적 유틸리티인 {@link UserTypeUtil} 에 주입한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class UserTypeConfigurer {
private final PortalProperties portalProperties;
@PostConstruct
public void init() {
UserTypeUtil.setInternalEmailDomains(portalProperties.getInternalUser().getEmailDomains());
log.info("내부 사용자 이메일 도메인: {}", UserTypeUtil.getInternalEmailDomains());
}
}
@@ -0,0 +1,211 @@
package com.eactive.apim.portal.djb.apistatus.controller;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusSupport;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusUptimeService;
import com.eactive.apim.portal.djb.apistatus.service.MyApiStatusQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 개발자포탈 API Status 화면.
*
* <p>장애/점검 데이터는 관리자 공지사항(PTL_NOTICE + DJB_APISTATUS_INCIDENT)과
* 자동 탐지(eapim-admin ApiStatusDetectionService)가 채운다. 본 컨트롤러는 읽기 전용이다.</p>
*
* <p>비로그인 사용자도 모두 조회 가능하며, "내 API 현황"만 로그인을 요구한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("/apistatus")
@RequiredArgsConstructor
public class ApiStatusController {
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
private static final int MAX_PAGE_SIZE = 50;
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
private static final int MAX_STATUS_BATCH = 100;
private final ApiStatusQueryService apiStatusQueryService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusIssueHistoryService issueHistoryService;
private final MyApiStatusQueryService myApiStatusQueryService;
private final ApiStatusCatalogService catalogService;
private final ApiCurrentStatusService apiCurrentStatusService;
/** P1 - API Status 메인 */
@GetMapping
public ModelAndView index() {
ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", catalogService.getWindowDays());
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
mav.addObject("lastFireAt", lastFireAt);
mav.addObject("lastFireRelative",
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
return mav;
}
/**
* P8 - 전체 이슈 이력. 날짜를 지정하지 않으면 조회 기간(90일) 전체를 본다.
* 날짜 선택 가능 범위는 서버 기준 일자로 내려 클라이언트 timezone 차이를 없앤다.
*/
@GetMapping("/issues")
public ModelAndView issues(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
LocalDate today = ApiStatusSupport.now().toLocalDate();
int windowDays = catalogService.getWindowDays();
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
mav.addObject("windowDays", windowDays);
mav.addObject("today", today);
mav.addObject("minDate", today.minusDays(windowDays - 1L));
mav.addObject("selectedDate", date);
mav.addObject("selectedApiId", apiId);
mav.addObject("selectedKind", kind);
return mav;
}
/** 이슈 이력 API 필터 - 현재 사용자가 조회 가능한 API 목록 */
@GetMapping("/apis.json")
@ResponseBody
public List<ApiOptionDTO> selectableApis() {
return catalogService.getSelectableApis();
}
/** P2 - 90일 가동률 */
@GetMapping("/uptime.json")
@ResponseBody
public List<DailyStatDTO> uptime(
@RequestParam(value = "days", defaultValue = "0") int days) {
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
}
/** P3 - 진행 중 장애 */
@GetMapping("/active.json")
@ResponseBody
public List<ActiveIncidentDTO> active() {
return apiStatusQueryService.getActiveIncidents();
}
/** P4 - 내 API 현황 (로그인 필수) */
@GetMapping("/my-apis.json")
@ResponseBody
public ResponseEntity<List<MyApiStatusDTO>> myApis() {
if (!SecurityUtil.isAuthenticated()) {
return new ResponseEntity<>(Collections.emptyList(), HttpStatus.UNAUTHORIZED);
}
return ResponseEntity.ok(myApiStatusQueryService.getMyApiStatuses());
}
/** P5 - 예정/진행 중 점검 */
@GetMapping("/maintenance.json")
@ResponseBody
public List<MaintenanceCardDTO> maintenance() {
return apiStatusQueryService.getOngoingMaintenance();
}
/** P6 - 지난 이슈 (종결) */
@GetMapping("/recent-issues.json")
@ResponseBody
public List<PastIssueCardDTO> recentIssues(
@RequestParam(value = "size", defaultValue = "" + DEFAULT_RECENT_ISSUE_SIZE) int size) {
return apiStatusQueryService.getRecentClosedIssues(size);
}
/** P7 - 이슈 상세 */
@GetMapping("/incident/{incidentId}")
@ResponseBody
public ResponseEntity<PastIssueCardDTO> incidentDetail(@PathVariable Long incidentId) {
return apiStatusQueryService.getIssueDetail(incidentId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** P9 - 90일 이슈 일자 인덱스 */
@GetMapping("/issues/dates.json")
@ResponseBody
public List<IssueDateEntryDTO> issueDates(
@RequestParam(value = "days", defaultValue = "0") int days,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
}
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
@GetMapping("/issues/list.json")
@ResponseBody
public Page<PastIssueCardDTO> issueList(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
int safePage = Math.max(page, 0);
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
}
/**
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
*
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
*/
@GetMapping("/current.json")
@ResponseBody
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
return apiCurrentStatusService.getStatus(apiId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.badRequest().build());
}
/**
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
*/
@GetMapping("/current-list.json")
@ResponseBody
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
}
}
@@ -0,0 +1,34 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 진행 중 장애 카드 (P3)
*/
@Data
public class ActiveIncidentDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 이슈 영향 API
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AffectedApiDTO {
private String apiId;
private String apiName;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime recoveredAt;
}
@@ -0,0 +1,53 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* API 한 건의 현재 상태 (정상/점검/지연/장애).
*
* <p>게시된 장애·점검 공지(PTL_NOTICE + DJB_APISTATUS_INCIDENT)만 근거로 한다.
* 미게시(초안/USE_YN='N') 건은 다른 API Status 화면과 동일하게 보이지 않는다.</p>
*/
@Data
public class ApiCurrentStatusDTO {
private String apiId;
/** 공개 API 목록(PTL_API_SPEC_INFO, DISPLAY_YN='Y')에 없으면 null */
private String apiName;
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String currentStatus;
/** 정상 / 지연 / 장애 / 점검 */
private String currentStatusLabel;
/** 현재 상태의 근거가 된 이슈. 정상이면 null */
private Long activeIncidentId;
/** INCIDENT / MAINTENANCE */
private String activeIncidentKind;
private String activeIncidentTitle;
/** 현재 상태가 시작된 시각 (근거 이슈의 시작 시각) */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime statusSince;
/** 점검 종료 예정 시각. 장애이거나 미정이면 null */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime expectedEndAt;
/** 조회 기간 내 마지막 장애 발생 시각 */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastIncidentAt;
/** 조회 기간 가동률 (0.0 ~ 1.0). 상태만 계산하는 경로에서는 채우지 않는다 */
private Double uptimeRatio;
/** 가동률·최근 장애 집계 기간(일) */
private int windowDays;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 이슈 이력 API 필터 옵션. 화면에는 API 명만 노출하고 apiId 는 조회 파라미터로만 쓴다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiOptionDTO {
private String apiId;
private String apiName;
/** 소속 API 그룹명 (오픈 API 목록과 동일 기준) */
private String groupName;
}
@@ -0,0 +1,50 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 90일 가동률 일별 집계 (P2)
*/
@Data
public class DailyStatDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate statDate;
/** 0.0000 ~ 1.0000 */
private double uptimeRatio;
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
private long incidentMinutes;
/** 그 중 지연(자동 탐지) 분 */
private long delayMinutes;
private long maintenanceMinutes;
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String status;
private List<IssueRefDTO> issues = new ArrayList<>();
@Data
public static class IssueRefDTO {
private Long incidentId;
private String kind;
private String title;
public IssueRefDTO() {
}
public IssueRefDTO(Long incidentId, String kind, String title) {
this.incidentId = incidentId;
this.kind = kind;
this.title = title;
}
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 이슈 이력 페이지의 90일 인덱스바 1칸 (P9)
*/
@Data
public class IssueDateEntryDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate date;
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
private List<String> kinds = new ArrayList<>();
/** 장애 건수 (자동 탐지 지연 제외) */
private int incCount;
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
private int dlyCount;
private int mntCount;
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 점검 사항 카드 (P5). 상태 관리를 하지 않으므로(ADR-F15) 진행 단계는 화면이 현재 시각과 비교해 부여한다.
*/
@Data
public class MaintenanceCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime registeredAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastModifiedAt;
}
@@ -0,0 +1,25 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 내가 이용 중인 API 의 현재 상태 (P4)
*/
@Data
public class MyApiStatusDTO {
private String apiId;
private String apiName;
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String currentStatus;
private String currentStatusLabel;
private Long activeIncidentId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastIncidentAt;
private double uptime90d;
}
@@ -0,0 +1,39 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 지난 이슈 카드 (P6, P10). 장애는 타임라인 전체, 점검은 요약만 담는다.
*/
@Data
public class PastIssueCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 장애 처리 타임라인 1건
*/
@Data
public class TimelineEntryDTO {
private Long timelineId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime eventAt;
private String stateAfter;
private String labelKo;
private String body;
private String authorType;
}
@@ -0,0 +1,118 @@
package com.eactive.apim.portal.djb.apistatus.repository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
*
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
* <ul>
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
* </ul>
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY";
String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE";
/** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */
String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = " + KIND_MAINTENANCE
+ " AND i.endAt IS NOT NULL AND i.endAt < :now)) ";
/**
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind IN :kinds"
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now);
/**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
*/
@Query(value = "SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION
+ " ORDER BY i.startedAt DESC",
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
Pageable pageable);
/**
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
*/
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
+ " WHERE " + VISIBLE
+ " AND a.incidentId = i.incidentId"
+ " AND a.apiId = :apiId"
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("apiId") String apiId);
/**
* 공개 상세 (P7)
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
}
@@ -0,0 +1,274 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
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.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* API 별 현재 상태(정상/점검/지연/장애) 조회.
*
* <p>"내 API 현황"(P4)과 API 상세 화면이 이 서비스를 공유해 판정 기준을 하나로 유지한다.
* 근거는 <b>게시된</b> 장애·점검 공지뿐이며(PTL_NOTICE.USE_YN='Y' + DRAFT_YN='N'),
* 미게시 건은 다른 API Status 화면과 마찬가지로 반영하지 않는다.</p>
*
* <p>판정 규칙 (심각한 쪽 우선)
* <ul>
* <li>진행 중 장애 → 장애(OUTAGE). 단 모니터링 상태이거나 자동 탐지 지연 건이면 지연(DEGRADED)</li>
* <li>이미 시작된 점검 → 점검(MAINTENANCE)</li>
* <li>해당 없음 → 정상(NORMAL)</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiCurrentStatusService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
/** API 한 건의 현재 상태. apiId 가 비어 있으면 empty */
public Optional<ApiCurrentStatusDTO> getStatus(String apiId) {
if (StringUtils.isBlank(apiId)) {
return Optional.empty();
}
return getStatuses(Collections.singletonList(apiId)).stream().findFirst();
}
/**
* 여러 API 의 현재 상태 (API 명·가동률 포함). 요청 순서를 유지하며 중복 apiId 는 한 번만 반환한다.
*/
public List<ApiCurrentStatusDTO> getStatuses(Collection<String> apiIds) {
Set<String> ids = normalize(apiIds);
if (ids.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = resolveStatuses(ids);
Map<String, String> names = resolveApiNames(ids);
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(ids, catalogService.getWindowDays());
List<ApiCurrentStatusDTO> result = new ArrayList<>(ids.size());
for (String apiId : ids) {
ApiCurrentStatusDTO dto = statuses.get(apiId);
dto.setApiName(names.get(apiId));
dto.setUptimeRatio(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
return result;
}
/**
* 상태만 계산한다 (API 명·가동률 제외). 대상 API 를 이미 알고 있고 이름·가동률을
* 따로 채우는 호출자(P4 내 API 현황)를 위한 경량 경로다.
*/
public Map<String, ApiCurrentStatusDTO> resolveStatuses(Set<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
LocalDateTime now = ApiStatusSupport.now();
int windowDays = catalogService.getWindowDays();
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(apiIds);
Map<String, DjbApistatusIncident> maintenanceByApi = mapStartedMaintenance(apiIds, now);
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(apiIds, now, windowDays);
Map<String, ApiCurrentStatusDTO> result = new LinkedHashMap<>();
for (String apiId : apiIds) {
DjbApistatusIncident open = openByApi.get(apiId);
DjbApistatusIncident maintenance = maintenanceByApi.get(apiId);
ApiCurrentStatusDTO dto = new ApiCurrentStatusDTO();
dto.setApiId(apiId);
dto.setCurrentStatus(resolveStatus(open, maintenance != null));
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
dto.setWindowDays(windowDays);
// 장애가 점검보다 심각하므로 근거 이슈도 장애를 우선한다
DjbApistatusIncident active = open != null ? open : maintenance;
if (active != null) {
dto.setActiveIncidentId(active.getIncidentId());
dto.setActiveIncidentKind(active.getKind() == null ? null : active.getKind().name());
dto.setActiveIncidentTitle(active.getTitle());
dto.setStatusSince(active.getStartedAt());
dto.setExpectedEndAt(active.getEndAt());
}
result.put(apiId, dto);
}
return result;
}
/** 빈 값·중복 제거. 요청 순서는 유지한다 */
private Set<String> normalize(Collection<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptySet();
}
return apiIds.stream()
.filter(StringUtils::isNotBlank)
.map(StringUtils::trim)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* API 명은 공개 목록(DISPLAY_YN='Y')에서만 가져온다. 비공개 API 의 이름이 새어나가지 않게 한다.
*/
private Map<String, String> resolveApiNames(Set<String> apiIds) {
List<ApiSpecInfo> specs =
apiSpecInfoRepository.findAllByDisplayYnAndApiIdIn("Y", new ArrayList<>(apiIds));
Map<String, String> names = new HashMap<>();
for (ApiSpecInfo spec : specs) {
names.put(spec.getApiId(), StringUtils.defaultIfBlank(spec.getApiName(), spec.getApiId()));
}
return names;
}
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = openIncidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
// 같은 API 에 여러 장애가 열려 있으면 더 심각한(장애 > 지연, 조사중 > 모니터링) 쪽을 우선한다
if (previous == null || severity(incident) < severity(previous)) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList());
if (maintenances.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = maintenances.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
if (previous == null
|| (incident.getStartedAt() != null && previous.getStartedAt() != null
&& incident.getStartedAt().isBefore(previous.getStartedAt()))) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 조회 기간 내 마지막 장애·지연 발생 시각 */
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, LocalDateTime> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
LocalDateTime previous = result.get(api.getApiId());
if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
result.put(api.getApiId(), startedAt);
}
}
return result;
}
/**
* 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다.
*
* <p>KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라
* 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.</p>
*/
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
if (open != null) {
boolean degraded = open.getKind() == IncidentKind.DELAY
|| open.getState() == IncidentState.MONITORING;
return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
}
if (underMaintenance) {
return ApiStatusSupport.STATUS_MAINTENANCE;
}
return ApiStatusSupport.STATUS_NORMAL;
}
/**
* 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각).
* 지연은 장애보다 뒤로 민다.
*/
private int severity(DjbApistatusIncident incident) {
if (incident == null) {
return 99;
}
int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0;
IncidentState state = incident.getState();
if (state == null) {
return base + 9;
}
switch (state) {
case INVESTIGATING: return base;
case IDENTIFIED: return base + 1;
// 모니터링 단계는 지연으로 표기되므로 같은 장애라도 뒤로 민다
case MONITORING: return base + 12;
default: return base + 8;
}
}
}
@@ -0,0 +1,283 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.AffectedApiDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
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.incident.entity.DjbApistatusIncidentTimeline;
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.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 org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 장애/점검 엔티티 → 화면 DTO 변환. 영향 API·타임라인은 한 번에 모아 읽어 1+N 조회를 피한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusAssembler {
private static final int RECENT_TIMELINE_SIZE = 3;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
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) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<AffectedApiDTO>> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds)) {
result.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
.add(new AffectedApiDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getRecoveredAt()));
}
return result;
}
/**
* 공개 타임라인. 최신순으로 담는다.
*/
public Map<Long, List<TimelineEntryDTO>> loadTimelines(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<TimelineEntryDTO>> result = new HashMap<>();
for (DjbApistatusIncidentTimeline timeline :
timelineRepository.findByIncidentIdInAndVisibleYnOrderByEventAtDesc(incidentIds, "Y")) {
result.computeIfAbsent(timeline.getIncidentId(), key -> new ArrayList<>())
.add(toTimelineEntry(timeline));
}
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) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(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<>();
for (DjbApistatusIncident incident : incidents) {
ActiveIncidentDTO dto = new ActiveIncidentDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
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());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
? new ArrayList<>(all.subList(0, RECENT_TIMELINE_SIZE)) : all);
result.add(dto);
}
return result;
}
public List<MaintenanceCardDTO> toMaintenanceCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
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<>();
for (DjbApistatusIncident incident : incidents) {
MaintenanceCardDTO dto = new MaintenanceCardDTO();
dto.setIncidentId(incident.getIncidentId());
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.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setRegisteredAt(incident.getCreatedDate());
dto.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
}
return result;
}
/**
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
*/
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
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<>();
for (DjbApistatusIncident incident : incidents) {
PastIssueCardDTO dto = new PastIssueCardDTO();
dto.setIncidentId(incident.getIncidentId());
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.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
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()));
result.add(dto);
}
return result;
}
private TimelineEntryDTO toTimelineEntry(DjbApistatusIncidentTimeline timeline) {
TimelineEntryDTO dto = new TimelineEntryDTO();
dto.setTimelineId(timeline.getTimelineId());
dto.setEventAt(timeline.getEventAt());
dto.setStateAfter(timeline.getStateAfter() == null ? null : timeline.getStateAfter().name());
dto.setLabelKo(ApiStatusSupport.stateLabel(timeline.getStateAfter()));
dto.setBody(timeline.getBody());
dto.setAuthorType(timeline.getAuthorType());
return dto;
}
private List<Long> incidentIds(List<DjbApistatusIncident> incidents) {
return incidents.stream().map(DjbApistatusIncident::getIncidentId).collect(Collectors.toList());
}
}
@@ -0,0 +1,198 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.gateway.data.statistics.repository.GwApiStatusRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusCatalogService {
public static final String PROPERTY_GROUP = "Portal";
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
/** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
private static final int MIN_WINDOW_DAYS = 1;
private static final int MAX_WINDOW_DAYS = 365;
private final ApiSearchFacade apiSearchFacade;
private final PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/**
* 이슈 이력 필터용 API 목록.
*
* <p>"오픈 API" 목록 화면과 <b>같은 조회 경로</b>({@link ApiSearchFacade#searchApis})를 쓴다.
* 즉 API 그룹(AGWAPP.API_GROUP + API_GROUP_API)에 편성된 API 중
* PTL_API_SPEC_INFO 에 스펙이 있고 현재 사용자에게 공개된 것만 나온다.</p>
*/
@SuppressWarnings("unchecked")
public List<ApiOptionDTO> getSelectableApis() {
Object apis = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
if (!(apis instanceof List)) {
return Collections.emptyList();
}
return ((List<ApiSpecInfoDto>) apis).stream()
.filter(api -> StringUtils.isNotBlank(api.getApiId()))
.map(api -> new ApiOptionDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getApiGroupName()))
.sorted(Comparator.comparing(ApiOptionDTO::getApiName,
Comparator.nullsLast(Comparator.naturalOrder())))
.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)의 마지막 실행 시각.
*
* <p>Job 이름은 PTL_PROPERTY({@code Portal} / {@code djb.apistatus.monitor.job-name})로
* 바꿀 수 있으며, 최초 접근 시 기본값으로 row 가 자동 생성된다.
* Quartz 메타(QRTZ_TRIGGERS)는 EMSAPP 스키마라 EMS EntityManager 로 직접 읽는다.</p>
*
* <p>readOnly 를 풀어야 최초 접근 시 프로퍼티 row 자동 생성(INSERT)이 가능하다.</p>
*/
@Transactional
public LocalDateTime getLastMonitorFireAt() {
try {
String jobName = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_MONITOR_JOB_NAME, DEFAULT_MONITOR_JOB_NAME,
"API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME)");
if (StringUtils.isBlank(jobName)) {
jobName = DEFAULT_MONITOR_JOB_NAME;
}
Object fireTime = entityManager.createNativeQuery(
"SELECT MAX(PREV_FIRE_TIME) FROM QRTZ_TRIGGERS WHERE JOB_NAME = :jobName")
.setParameter("jobName", jobName)
.getSingleResult();
if (!(fireTime instanceof Number)) {
return null;
}
long epochMillis = ((Number) fireTime).longValue();
if (epochMillis <= 0) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ApiStatusSupport.ZONE);
} catch (Exception e) {
// 스케줄 메타 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 모니터링 실행 시각 조회 실패", e);
return null;
}
}
/**
* 상태/이력 조회 기간(일). PTL_PROPERTY({@code Portal} / {@code djb.apistatus.window-days})로
* 조절하며 1~365 로 clamp 한다. 값이 이상하면 기본 90일.
*/
@Transactional
public int getWindowDays() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_WINDOW_DAYS,
String.valueOf(ApiStatusSupport.DEFAULT_WINDOW_DAYS),
"API Status 조회 기간(일). 가동률 바·이슈 인덱스·날짜 선택 범위에 적용 (1~365)");
int days = Integer.parseInt(StringUtils.trimToEmpty(value));
return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, days));
} catch (Exception e) {
log.warn("조회 기간 프로퍼티 해석 실패 - 기본값 {}일 사용", ApiStatusSupport.DEFAULT_WINDOW_DAYS, e);
return ApiStatusSupport.DEFAULT_WINDOW_DAYS;
}
}
/**
* OPEN API 목록(/apis) 카드에 현재 상태 태그를 노출할지 여부.
* PTL_PROPERTY({@code Portal} / {@code djb.apistatus.api-list-status-badge}) 로 켜고 끈다.
*
* <p>{@code true/Y/1} 이면 노출, {@code false/N/0} 이면 숨김. 값이 비었거나 이상하면 기본 노출.</p>
*/
@Transactional
public boolean isApiListStatusBadgeEnabled() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_API_LIST_STATUS_BADGE,
String.valueOf(DEFAULT_API_LIST_STATUS_BADGE),
"OPEN API 목록 카드에 현재 상태(정상/점검/지연/장애) 태그 노출 여부 (true/false)");
String normalized = StringUtils.trimToEmpty(value);
if (StringUtils.equalsAnyIgnoreCase(normalized, "false", "n", "0")) {
return false;
}
if (StringUtils.equalsAnyIgnoreCase(normalized, "true", "y", "1")) {
return true;
}
return DEFAULT_API_LIST_STATUS_BADGE;
} catch (Exception e) {
log.warn("API 목록 상태 태그 노출 프로퍼티 조회 실패 - 기본값 {} 사용", DEFAULT_API_LIST_STATUS_BADGE, e);
return DEFAULT_API_LIST_STATUS_BADGE;
}
}
/**
* API 상태가 마지막으로 <b>변경</b>된 시각 (AGWAPP.API_STATUS 최근 upsert).
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
*/
public LocalDateTime getLastStatusChangedAt() {
try {
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) {
log.warn("API 상태 변경 시각 조회 실패", e);
return null;
}
}
}
@@ -0,0 +1,159 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 전체 이슈 이력 페이지 조회 (ADR-F16/F17). 필터는 날짜와 API 두 축만 지원한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusIssueHistoryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/**
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
*/
public List<IssueDateEntryDTO> getIssueDates(int days, String apiId, String kind) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
LocalDateTime windowStart = from.atStartOfDay();
LocalDateTime windowEnd = today.plusDays(1).atStartOfDay();
List<DjbApistatusIncident> incidents = findOverlapping(windowStart, windowEnd, apiId, kind);
Map<LocalDate, IssueDateEntryDTO> byDate = new LinkedHashMap<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
IssueDateEntryDTO entry = new IssueDateEntryDTO();
entry.setDate(date);
byDate.put(date, entry);
}
for (DjbApistatusIncident incident : incidents) {
if (incident.getStartedAt() == null) {
continue;
}
LocalDate start = incident.getStartedAt().toLocalDate();
LocalDateTime effectiveEnd = incident.getEndAt() == null ? now : incident.getEndAt();
LocalDate end = effectiveEnd.toLocalDate();
LocalDate cursor = start.isBefore(from) ? from : start;
LocalDate last = end.isAfter(today) ? today : end;
while (!cursor.isAfter(last)) {
IssueDateEntryDTO entry = byDate.get(cursor);
if (entry != null) {
tally(entry, incident);
}
cursor = cursor.plusDays(1);
}
}
return new ArrayList<>(byDate.values());
}
/**
* 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
*/
private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
IncidentKind kind = incident.getKind();
if (kind == null) {
return;
}
switch (kind) {
case MAINTENANCE: entry.setMntCount(entry.getMntCount() + 1); break;
case DELAY: entry.setDlyCount(entry.getDlyCount() + 1); break;
default: entry.setIncCount(entry.getIncCount() + 1); break;
}
if (!entry.getKinds().contains(kind.name())) {
entry.getKinds().add(kind.name());
}
}
/**
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
*/
public Page<PastIssueCardDTO> getIssues(LocalDate date, String apiId, String kind, Pageable pageable) {
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime from;
LocalDateTime to;
if (date == null) {
LocalDate today = now.toLocalDate();
from = today.minusDays(catalogService.getWindowDays() - 1L).atStartOfDay();
to = today.plusDays(1).atStartOfDay();
} else {
from = date.atStartOfDay();
to = date.plusDays(1).atStartOfDay();
}
List<DjbApistatusIncident> incidents = findOverlapping(from, to, apiId, kind);
if (incidents.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int offset = (int) pageable.getOffset();
if (offset >= incidents.size()) {
return new PageImpl<>(Collections.emptyList(), pageable, incidents.size());
}
int end = Math.min(offset + pageable.getPageSize(), incidents.size());
List<PastIssueCardDTO> content = assembler.toPastIssueCards(incidents.subList(offset, end));
return new PageImpl<>(content, pageable, incidents.size());
}
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
return incidents;
}
return incidents.stream().filter(filter).collect(Collectors.toList());
}
/**
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
*/
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null;
}
try {
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -0,0 +1,59 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* API Status 메인 화면 조회 (진행 중 장애 / 점검 사항 / 지난 이슈 / 이슈 상세).
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
return assembler.toActiveIncidents(incidents, now);
}
/** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
return assembler.toMaintenanceCards(incidents);
}
/** P6 - 종결된 이슈 최근 N건 */
public List<PastIssueCardDTO> getRecentClosedIssues(int size) {
int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit))
.getContent();
return assembler.toPastIssueCards(incidents);
}
/** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId)
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
}
}
@@ -0,0 +1,132 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* API Status 조회 서비스 공통 상수/헬퍼.
*/
public final class ApiStatusSupport {
/** 일자 경계 기준 timezone (OQ-10) */
public static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
/** 90일 가동률/이력 인덱스 기본 조회 일수 */
public static final int DEFAULT_WINDOW_DAYS = 90;
/** 장애 종결 상태 */
public static final List<IncidentState> CLOSED_STATES =
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
public static final String STATUS_NORMAL = "NORMAL";
public static final String STATUS_DEGRADED = "DEGRADED";
public static final String STATUS_OUTAGE = "OUTAGE";
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
private ApiStatusSupport() {
}
public static LocalDateTime now() {
return LocalDateTime.now(ZONE);
}
/** 지연 건인지 여부 (장애와 구분) */
public static boolean isDelay(DjbApistatusIncident incident) {
return incident != null && incident.getKind() == IncidentKind.DELAY;
}
/** 이슈 종류에 대응하는 현재 상태 코드 */
public static String statusOf(IncidentKind kind) {
if (kind == null) {
return STATUS_NORMAL;
}
switch (kind) {
case INCIDENT: return STATUS_OUTAGE;
case DELAY: return STATUS_DEGRADED;
case MAINTENANCE: return STATUS_MAINTENANCE;
default: return STATUS_NORMAL;
}
}
/**
* 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
*/
public static int statusRank(String status) {
if (status == null) {
return 9;
}
switch (status) {
case STATUS_OUTAGE: return 0;
case STATUS_MAINTENANCE: return 1;
case STATUS_DEGRADED: return 2;
default: return 3;
}
}
public static String stateLabel(IncidentState state) {
if (state == null) {
return null;
}
switch (state) {
case INVESTIGATING: return "발생";
case IDENTIFIED: return "원인 확인";
case MONITORING: return "모니터링";
case RESOLVED: return "해소";
case CANCELED: return "취소";
default: return state.name();
}
}
public static String statusLabel(String status) {
if (status == null) {
return null;
}
switch (status) {
case STATUS_NORMAL: return "정상";
case STATUS_DEGRADED: return "지연";
case STATUS_OUTAGE: return "장애";
case STATUS_MAINTENANCE: return "점검";
default: return status;
}
}
/**
* 두 시각 사이의 분. 종료가 없거나 역순이면 0.
*/
public static long minutesBetween(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)) {
return 0L;
}
return Duration.between(from, to).toMinutes();
}
/**
* "N분 전" 형태의 상대 시간 표기.
*/
public static String relativeTime(LocalDateTime past, LocalDateTime now) {
if (past == null || now == null) {
return null;
}
long minutes = Duration.between(past, now).toMinutes();
if (minutes < 1) {
return "방금 전";
}
if (minutes < 60) {
return minutes + "분 전";
}
long hours = minutes / 60;
if (hours < 24) {
long restMinutes = minutes % 60;
return restMinutes == 0 ? hours + "시간 전" : hours + "시간 " + restMinutes + "분 전";
}
return (hours / 24) + "일 전";
}
}
@@ -0,0 +1,236 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
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.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 90일 가동률 집계. 일별 캐시 테이블(DJB_APISTATUS_DAILY_STAT) 없이 이슈 구간을 그때그때 합산한다.
*
* <p>이슈 건수가 연 1천 건 미만이므로 90일 구간을 메모리에서 합산해도 부담이 없다.
* 같은 시간대에 겹치는 이슈는 구간 합집합으로 계산해 중복 차감을 막는다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
/** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
result.add(buildDailyStat(date, incidents, now));
}
return result;
}
/**
* API 별 90일 가동률. 장애(INCIDENT) 구간만 차감한다.
*/
public Map<String, Double> getApiUptimeRatios(Set<String> apiIds, int days) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
if (!incidents.isEmpty()) {
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
LocalDateTime start = max(incident.getStartedAt(), windowStart);
// 개별 API 복구 시각이 있으면 그 시점까지만 장애로 본다
LocalDateTime end = min(firstNonNull(api.getRecoveredAt(), incident.getEndAt(), now), now);
long minutes = ApiStatusSupport.minutesBetween(start, end);
if (minutes <= 0) {
continue;
}
intervalsByApi.computeIfAbsent(api.getApiId(), key -> new ArrayList<>())
.add(new long[]{toEpochMinute(start), toEpochMinute(end)});
}
}
long totalMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(windowStart, now));
Map<String, Double> ratios = new HashMap<>();
for (String apiId : apiIds) {
long down = unionMinutes(intervalsByApi.get(apiId));
double ratio = (double) (totalMinutes - Math.min(down, totalMinutes)) / totalMinutes;
ratios.put(apiId, round4(ratio));
}
return ratios;
}
private DailyStatDTO buildDailyStat(LocalDate date, List<DjbApistatusIncident> incidents, LocalDateTime now) {
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = min(date.plusDays(1).atStartOfDay(), now);
DailyStatDTO dto = new DailyStatDTO();
dto.setStatDate(date);
if (!dayEnd.isAfter(dayStart)) {
// 아직 시작되지 않은 날짜 (오늘 자정 직후 등)
dto.setUptimeRatio(1.0d);
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
return dto;
}
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
List<long[]> incidentIntervals = new ArrayList<>();
List<long[]> outageIntervals = new ArrayList<>();
List<long[]> delayIntervals = new ArrayList<>();
List<long[]> maintenanceIntervals = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DjbApistatusIncident incident : incidents) {
LocalDateTime start = max(incident.getStartedAt(), dayStart);
LocalDateTime end = min(firstNonNull(incident.getEndAt(), now), dayEnd);
if (!end.isAfter(start)) {
continue;
}
long[] interval = new long[]{toEpochMinute(start), toEpochMinute(end)};
if (incident.getKind() == IncidentKind.MAINTENANCE) {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
if (incident.getKind() == IncidentKind.DELAY) {
delayIntervals.add(interval);
} else {
outageIntervals.add(interval);
}
}
if (seen.add(incident.getIncidentId())) {
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
incident.getKind() == null ? null : incident.getKind().name(),
incident.getTitle()));
}
}
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
long incidentMinutes = unionMinutes(incidentIntervals);
long outageMinutes = unionMinutes(outageIntervals);
long delayMinutes = unionMinutes(delayIntervals);
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
dto.setIncidentMinutes(incidentMinutes);
dto.setDelayMinutes(delayMinutes);
dto.setMaintenanceMinutes(maintenanceMinutes);
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
if (outageMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
} else if (maintenanceMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
} else if (delayMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
} else {
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
}
return dto;
}
/**
* 겹치는 구간을 합쳐 총 분을 구한다.
*/
private long unionMinutes(List<long[]> intervals) {
if (intervals == null || intervals.isEmpty()) {
return 0L;
}
intervals.sort((left, right) -> Long.compare(left[0], right[0]));
long total = 0L;
long currentStart = intervals.get(0)[0];
long currentEnd = intervals.get(0)[1];
for (int index = 1; index < intervals.size(); index++) {
long[] interval = intervals.get(index);
if (interval[0] > currentEnd) {
total += currentEnd - currentStart;
currentStart = interval[0];
currentEnd = interval[1];
} else if (interval[1] > currentEnd) {
currentEnd = interval[1];
}
}
total += currentEnd - currentStart;
return total;
}
private long toEpochMinute(LocalDateTime dateTime) {
return dateTime.atZone(ApiStatusSupport.ZONE).toEpochSecond() / 60L;
}
private LocalDateTime max(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isAfter(right) ? left : right;
}
private LocalDateTime min(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isBefore(right) ? left : right;
}
private LocalDateTime firstNonNull(LocalDateTime... candidates) {
for (LocalDateTime candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
}
private double round4(double value) {
double clamped = Math.max(0.0d, Math.min(1.0d, value));
return Math.round(clamped * 10000.0d) / 10000.0d;
}
}
@@ -0,0 +1,94 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
*
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MyApiStatusQueryService {
private final CredentialRepository credentialRepository;
private final ApiCurrentStatusService apiCurrentStatusService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
public List<MyApiStatusDTO> getMyApiStatuses() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null || StringUtils.isBlank(user.getPortalOrg().getId())) {
return Collections.emptyList();
}
Map<String, String> myApis = collectMyApis(user.getPortalOrg().getId());
if (myApis.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
Map<String, Double> uptimes =
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
List<MyApiStatusDTO> result = new ArrayList<>();
for (Map.Entry<String, String> entry : myApis.entrySet()) {
String apiId = entry.getKey();
ApiCurrentStatusDTO status = statuses.get(apiId);
MyApiStatusDTO dto = new MyApiStatusDTO();
dto.setApiId(apiId);
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
dto.setApiName(entry.getValue());
dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
result.sort(Comparator
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
return result;
}
/**
* 기관이 보유한 앱들의 API 목록 (중복 제거, API 명 기준 정렬)
*/
private Map<String, String> collectMyApis(String orgId) {
Map<String, String> myApis = new TreeMap<>();
for (Credential credential : credentialRepository.findAllByOrgid(orgId)) {
if (credential.getApiList() == null) {
continue;
}
for (ApiSpecInfo api : credential.getApiList()) {
if (StringUtils.isBlank(api.getApiId())) {
continue;
}
myApis.put(api.getApiId(), StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()));
}
}
return myApis;
}
}
@@ -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,45 @@
package com.eactive.apim.portal.djb.guide;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 개발가이드 안내페이지에 노출할 값을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.guide.})
* 에서 읽는 얇은 접근자.
*
* <p>안내페이지에 표기되는 게이트웨이 주소는 서비스 환경(dev/stage/prod)마다 달라지므로 템플릿에 하드코딩하지 않고
* DB 프로퍼티로 분리한다. 값 미존재 시 {@link PortalPropertyService#getOrCreateProperty} 가 기본값으로 INSERT 하며,
* 공백 값은 {@link #resolve} 에서 기본값으로 폴백한다({@code getOrCreateProperty} 는 공백 보정을 하지 않음).
*/
@Component
@RequiredArgsConstructor
public class GuideProperty {
private final PortalPropertyService portalPropertyService;
private static final String GROUP = "Portal";
public static final String KEY_GW_URL = "djb.guide.gw-url";
public static final String DEFAULT_GW_URL = "https://openapi.jejubank.co.kr";
/** 안내페이지(OAuth2 개발가이드 등)에 표기할 게이트웨이 base URL. 끝 슬래시는 제거한다. */
public String gatewayUrl() {
return stripTrailingSlash(resolve(KEY_GW_URL, DEFAULT_GW_URL,
"개발가이드 안내페이지에 표기할 게이트웨이 base URL (예: https://openapi.jejubank.co.kr)"));
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return "";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
@@ -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());
}
}
@@ -2,7 +2,10 @@ package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.net.URI;
import java.net.URISyntaxException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@@ -19,6 +22,7 @@ import org.springframework.util.StringUtils;
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService;
@@ -33,8 +37,17 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String KEY_SPEC_SERVER_URL = "djb.gateway.spec-server-url";
public static final String DEFAULT_BASE_URL = "PortalMock";
/**
* {@link #KEY_SPEC_SERVER_URL} 미설정 표식. spec 서버 주소를 {@link #resolveApiBaseUrl} 기존 규칙
* (GATEWAY→base-url, PortalMock→포탈 origin)으로 결정한다는 뜻.
*
* <p>빈 문자열을 기본값으로 쓰면 {@code PTL_PROPERTY.property_value} 가 NOT NULL 이라
* Oracle 에서 빈 문자열=NULL 로 저장되며 제약 위반이 나므로, 비어 있지 않은 표식을 쓴다.</p>
*/
public static final String DEFAULT_SPEC_SERVER_URL = "auto";
public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "true";
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
@@ -130,15 +143,42 @@ public class DjbTestbedGatewayProperty {
}
/**
* OAuth 토큰 발급 URL.
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
* GATEWAY → {@code baseUrl + tokenPath}.
* 브라우저에 내려주는 OAuth 토큰 발급 URL — spec 노출 주소({@link #resolveSpecServerUrl}) 기준.
*
* <p>PortalMock → {@code portalOrigin + /api/v1/oauth/token}(기존 mock 필터가 가로챔),
* GATEWAY → {@code specServerUrl + tokenPath}.</p>
*
* <p>대외/대내 주소가 다른 환경에서 <b>직접호출 모드</b>({@code djb.gateway.token-use-proxy=false})는
* 브라우저가 이 주소로 바로 나가므로 대외 주소여야 한다. 프록시 경유일 때는 이 값이
* {@code original-url} 헤더로만 전달되고, 실제 forward 는 필터가
* {@code baseUrl + tokenPath}(대내)로 수행한다.</p>
*/
public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
}
return stripTrailingSlash(baseUrl()) + tokenPath();
return stripTrailingSlash(resolveSpecServerUrl(portalOrigin)) + tokenPath();
}
/**
* gw 응답유형의 실제 forward 대상 URL. 브라우저가 spec 서버주소(대외)로 만든 {@code originalUrl} 에서
* path/query 만 떼어 <b>대내 {@code base-url}</b> 에 재조립한다 — 포탈 서버는 대외 주소로 나갈 수 없다.
*
* <p>PortalMock 모드는 실 GW 가 없으므로 원본을 그대로 쓴다. URL 파싱 실패 시에도 원본 유지(멱등).</p>
*/
public String resolveGatewayCallUrl(String originalUrl) {
if (originalUrl == null || resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return originalUrl;
}
try {
URI uri = new URI(originalUrl);
String path = StringUtils.hasText(uri.getRawPath()) ? uri.getRawPath() : "";
String query = StringUtils.hasText(uri.getRawQuery()) ? "?" + uri.getRawQuery() : "";
return stripTrailingSlash(baseUrl()) + path + query;
} catch (URISyntaxException e) {
log.warn("gw 호출 URL 파싱 실패, original-url 그대로 사용 - url={}, cause={}", originalUrl, e.getMessage());
return originalUrl;
}
}
/**
@@ -153,6 +193,30 @@ public class DjbTestbedGatewayProperty {
return stripTrailingSlash(baseUrl());
}
/**
* spec({@code servers[0].url})에 노출할 주소. 응답유형과 무관하게 이 값 하나로 고정된다.
*
* <p>{@link #KEY_SPEC_SERVER_URL} 에 주소를 넣으면 그 값을 그대로 쓴다. PortalMock 모드처럼
* {@code base-url} 이 실제 URL 이 아니어서 노출할 GW 주소를 코드가 알 수 없을 때 이 값으로 고정한다.
* 대외 공개 도메인이 내부 GW 주소와 다른 경우에도 사용한다.</p>
*
* <p>{@value #DEFAULT_SPEC_SERVER_URL}(기본) 이면 {@link #resolveApiBaseUrl} 규칙을 그대로 따른다.</p>
*/
public String resolveSpecServerUrl(String portalOrigin) {
String configured = specServerUrl();
if (StringUtils.hasText(configured) && !DEFAULT_SPEC_SERVER_URL.equalsIgnoreCase(configured.trim())) {
return stripTrailingSlash(configured);
}
return resolveApiBaseUrl(portalOrigin);
}
/** spec servers 노출 주소. {@value #DEFAULT_SPEC_SERVER_URL} 이면 미설정(자동 결정). */
public String specServerUrl() {
return resolve(KEY_SPEC_SERVER_URL, DEFAULT_SPEC_SERVER_URL,
"spec(servers)에 노출할 API 호출 주소. \"" + DEFAULT_SPEC_SERVER_URL
+ "\" 이면 GW Base URL(PortalMock 이면 포탈 주소)로 자동 결정");
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
@@ -48,34 +48,37 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true);
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true);
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
/**
* Swagger UI 전용 spec. 서버 주소는 다운로드용과 동일하게 GW 기준이며, 여기에만
* {@code x-original-api-id} 를 심어 UI 실행 시 프록시가 API ID 로 스펙을 찾게 한다.
*/
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
/**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 GW 치환)을 JSON 으로 반환. 없으면 null.
* @param forUi true 면 Swagger UI 실행용으로 {@code x-original-api-id} 를 주입한다
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
private String buildSpecJson(String id, HttpServletRequest request, boolean forUi) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
// 클래스패스 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로
// (PORTAL_MOCK_TOKEN_PATH)라 GW 호스트를 붙이면 실재하지 않는 주소가 되고, servers 를
// 비워 두면 Swagger UI 가 문서 origin(포탈)을 사용해 프록시가 mock 토큰을 발급한다.
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -85,8 +88,12 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(enriched, request)
: serverRewriter.rewriteServer(enriched, spec.get(), request);
String rewritten = serverRewriter.rewriteServerToGateway(enriched, request);
if (!forUi) {
return rewritten;
}
// UI 용 spec 에만 API ID 를 심는다. 서버주소가 GW 로 고정돼 mock 은 호출 URL 의 path 로 스펙을
// 되찾을 수 없으므로, Swagger UI 가 original-api-id 헤더로 프록시에 API ID 를 전달하게 한다.
return enricher.injectOriginalApiId(rewritten, id);
}
}
@@ -32,5 +32,11 @@ public class DjbTestbedContextDto {
private String tokenUrl;
private String apiKeyHeader;
private String oauthTokenHeader;
/**
* sample/mock 응답유형 API 에서 실제 토큰 발급 없이 주입할 고정 액세스 토큰.
* (실호출이 없거나 mock 서버로만 나가므로 검증 대상이 아니다 — {@code ApiTesterFilter} 의 mock 토큰과 동일 값)
*/
private String mockAccessToken;
}
}
@@ -35,6 +35,13 @@ public class DjbSwaggerSpecEnricher {
public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey";
/** operation 에 주입하는 API ID 확장 키. 템플릿 JS 가 {@code original-api-id} 헤더로 프록시에 전달한다. */
public static final String API_ID_EXTENSION = "x-original-api-id";
/** OpenAPI path item 에서 operation 으로 취급하는 필드 (그 외 parameters/servers/summary 등은 제외). */
private static final String[] HTTP_METHODS =
{"get", "put", "post", "delete", "options", "head", "patch", "trace"};
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
@@ -71,6 +78,42 @@ public class DjbSwaggerSpecEnricher {
return objectMapper.writeValueAsString(root);
}
/**
* spec 의 모든 operation 에 {@value #API_ID_EXTENSION} 를 주입한다.
*
* <p>테스트베드 프록시({@code /api/call-api})는 스펙을 찾을 때 {@code original-url} 의 path 로 매칭했는데,
* mock/gw 응답유형에서는 서버 주소가 mockUrl·GW base 로 치환되어 path 가 저장된 {@code api_url} 과
* 일치하지 않는다. 템플릿 JS 가 이 확장값을 {@code original-api-id} 헤더로 보내면 프록시가 API ID 로
* 정확히 스펙을 찾을 수 있다.</p>
*
* <p>파싱 불가·paths 부재 시 원본 유지(멱등).</p>
*/
public String injectOriginalApiId(String specJson, String apiId) throws JsonProcessingException {
if (specJson == null || apiId == null || apiId.trim().isEmpty()) {
return specJson;
}
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
JsonNode paths = parsed.get("paths");
if (paths == null || !paths.isObject()) {
return specJson;
}
for (JsonNode pathItem : paths) {
if (!pathItem.isObject()) {
continue;
}
for (String method : HTTP_METHODS) {
JsonNode operation = pathItem.get(method);
if (operation != null && operation.isObject()) {
((ObjectNode) operation).put(API_ID_EXTENSION, apiId);
}
}
}
return objectMapper.writeValueAsString(parsed);
}
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
String headerName = (authType == DjbAuthType.OAUTH)
@@ -119,6 +119,7 @@ public class DjbTestbedAuthService {
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
.apiKeyHeader(gatewayProperty.apiKeyHeader())
.oauthTokenHeader(gatewayProperty.oauthHeader())
.mockAccessToken(gatewayProperty.mockAccessToken())
.build();
}
@@ -1,8 +1,10 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,58 +14,61 @@ import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
* testbed spec(JSON)의 서버 주소를 제공 시점에 게이트웨이 주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
* <p><b>spec 의 {@code servers} 는 응답유형(sample/mock/gw)과 무관하게 항상 GW 기준이다.</b>
* spec 은 "이 API 를 실제로 어디로 호출하는가"의 공개 계약이고 그 기준은 게이트웨이 하나다.
* admin(app.js buildOpenApiSpec)도 같은 규칙으로 저장하며, 포탈은 제공 시점에 자기 환경의
* {@link DjbTestbedGatewayProperty#resolveSpecServerUrl} 로 덮어써 저장 환경(dev)의 주소가 다른 환경까지
* 따라오지 않게 한다. 노출 주소를 명시 고정해야 하면
* {@code djb.gateway.spec-server-url}(PTL_PROPERTY) 에 넣는다.
*
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
* <p>응답유형에 따른 차이는 <b>테스트베드 동작</b>에서만 난다 — Swagger UI 의 실행은
* {@code /api/call-api} 프록시를 타고, {@code ApiTesterFilter} 가 응답유형별로
* sample(저장 샘플 응답) / mock({@code mockUrl} forward) / gw(게이트웨이 forward) 를 가른다.
* 즉 표시 주소는 GW 로 고정이고 호출 대상만 갈린다.
*
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
* <p>치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다(문자열 치환 아님). 과거 admin 이 mock/포탈
* 주소를 굳혀 저장한 spec 도 GW 기준으로 정정된다. 다만 그 시절 mock 저장본은 {@code paths} 키가 mock
* 경로로 바뀌어 있어, 어댑터경로 복원은 admin 재저장이 필요하다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
* spec 의 {@code servers} 를 GW 주소로 치환해 반환한다. UI 표시용·다운로드용 구분 없이 동일 기준.
*
* @return 치환된 JSON. 파싱 실패·형태 불일치 시 원본 유지(멱등).
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
if (specJson == null) {
return null;
}
String base = stripTrailingSlash(gatewayProperty.resolveSpecServerUrl(originOf(request)));
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
if (StringUtils.hasText(base)) {
ArrayNode servers = objectMapper.createArrayNode();
servers.add(objectMapper.createObjectNode().put("url", base));
root.set("servers", servers);
} else {
// GW 주소 미확정 — servers 를 비워 Swagger UI 가 문서 origin(포탈)을 쓰게 한다.
root.remove("servers");
}
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
log.error("spec 서버 치환 실패, 원본 유지 - base={}", base, e);
return specJson;
}
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
@@ -80,20 +85,6 @@ public class DjbTestbedSpecServerRewriter {
}
}
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(rt)) {
return gatewayProperty.resolveApiBaseUrl(originOf(request));
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) {
if (request == null) {

Some files were not shown because too many files have changed in this diff Show More