47 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
hong 8743592fde fix : footer 수정 2026-08-04 14:59:49 +09:00
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +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 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
148 changed files with 7896 additions and 3595 deletions
+3
View File
@@ -83,6 +83,9 @@ file_upload/
# Ignore Gradle build output directory
build
# SonarScanner 작업 디렉터리
.scannerwork/
build_knbank.sh
+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())}"
}
}
}
}
}
}
+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`
+59 -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 {
+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

@@ -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
@@ -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;
@@ -78,7 +79,7 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
@@ -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);
}
}
@@ -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());
}
}
@@ -15,6 +15,10 @@ 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;
@@ -24,5 +28,7 @@ public class ActiveIncidentDTO {
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -15,6 +15,10 @@ 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")
@@ -23,6 +27,8 @@ public class MaintenanceCardDTO {
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")
@@ -16,6 +16,10 @@ 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;
@@ -29,5 +33,7 @@ public class PastIssueCardDTO {
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -11,6 +11,9 @@ import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusInciden
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.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;
@@ -37,6 +40,42 @@ public class ApiStatusAssembler {
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()) {
@@ -69,6 +108,30 @@ public class ApiStatusAssembler {
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();
@@ -76,6 +139,10 @@ public class ApiStatusAssembler {
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) {
@@ -89,7 +156,16 @@ public class ApiStatusAssembler {
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
dto.setApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
@@ -104,19 +180,33 @@ public class ApiStatusAssembler {
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()));
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setRegisteredAt(incident.getCreatedDate());
dto.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
@@ -133,18 +223,29 @@ public class ApiStatusAssembler {
}
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()));
@@ -155,7 +256,10 @@ public class ApiStatusAssembler {
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
result.add(dto);
}
@@ -18,7 +18,9 @@ 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;
/**
@@ -80,6 +82,23 @@ public class ApiStatusCatalogService {
.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)의 마지막 실행 시각.
*
@@ -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) {
+7 -1
View File
@@ -42,4 +42,10 @@ portal:
# auth-virtual-code: 654321
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
hot-reload-pages: true
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+7 -1
View File
@@ -32,4 +32,10 @@ app:
resource-versioning:
enabled: true
resource-caching:
enabled: true
enabled: true
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+6
View File
@@ -47,3 +47,9 @@ gateway:
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
+27 -20
View File
@@ -19,10 +19,23 @@ server:
whitelabel:
enabled: false
path: /error
# 리버스프록시(OHS) 전달 헤더(X-Forwarded-*)를 요청에 반영하는 전략.
# native = 내장 Tomcat RemoteIpValve 위임 → WAR(WebLogic) 배포에서는 동작하지 않는다.
# framework = Spring ForwardedHeaderFilter(서블릿 필터) → 배포 방식과 무관하게 동작한다.
# 기본값 native: 로컬 개발은 gradle bootRun(내장 Tomcat)으로 기동하므로 컨테이너 기본 처리에 맡긴다.
# RemoteIpValve 는 internalProxies(사설/loopback 대역) 에서 온 요청의 헤더만 신뢰하므로 로컬에서 안전하다.
# 프록시 뒤에 놓이는 WAR 배포 환경(dev/stage/prod)만 각 프로파일에서 framework 로 올린다.
# ※ framework 로 올리는 환경은 OHS 에서 반드시 아래를 수행해야 한다(신뢰 프록시 목록이 없어 헤더를 무조건 신뢰).
# RequestHeader unset X-Forwarded-For / -Proto / -Host / -Port / Forwarded (클라이언트 위조분 제거)
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
forward-headers-strategy: native
spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
data:
web:
pageable:
@@ -78,6 +91,11 @@ security:
basic:
enabled: false
# ※ Actuator / Spring Boot Admin 설정은 이 파일에 두지 않는다.
# application-local*.yml 에만 두고, 해당 파일과 관련 라이브러리는 war/bootWar 산출물에서
# 제외된다(build.gradle 의 localOnly configuration + rootSpec.exclude 참고).
# WebLogic 배포본(dev/stage/prod)에는 actuator 자체가 존재하지 않는다.
sample-code-path: classpath:/templates/sample_code
@@ -109,6 +127,14 @@ portal:
logging:
log-path: /logs/eapim
# 내부 사용자(운영자) 판별 설정
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
# "@" 접두사 유무·대소문자 무관
internal-user:
email-domains:
- djbank.com
- unit-test.com
auth-ttl: 300
auth:
resend_limit_seconds: 30
@@ -218,26 +244,7 @@ portal:
view-name: apps/apis/static/tokenApiSpec
bean: apiHandler
portal_security:
ROLE_USER:
- ROLE_INQUIRY
- ROLE_ACCOUNT
ROLE_CORP_USER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
ROLE_CORP_MANAGER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# portal_security 는 roles.yml (portal-roles) 로 이동
page:
home:
name: "Home"
+55
View File
@@ -0,0 +1,55 @@
# ------------------------------------------------------------------------------
# 포탈 GNB 메뉴 정의 (menu.yml)
#
# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면
# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다.
# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다.
# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행.
# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지.
# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작.
# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운)
# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자,
# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of)
# ------------------------------------------------------------------------------
portal-menu:
items:
- id: service
name: "서비스 소개"
group: true
children:
- { id: service-intro, name: "API 포탈 소개", path: /service/intro }
- { id: service-guide, name: "회원가입 안내", path: /service/guide }
- { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide }
- { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide }
- { id: open-api, name: "오픈 API", path: /apis }
- id: support
name: "고객지원"
group: true
children:
- { id: support-notice, name: "공지사항", path: /portalnotice }
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: support-qna, name: "Q&A", path: /inquiry }
- { id: support-partnership, name: "피드백/개선요청", path: /partnership }
- { id: api-status, name: "API Status", path: /apistatus }
- id: my-page
name: "마이페이지"
group: true
section: MYPAGE
expose-roles: [AUTHENTICATED]
children:
- { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users,
expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] }
- { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
- { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
- { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
+47
View File
@@ -0,0 +1,47 @@
# ------------------------------------------------------------------------------
# 포탈 역할 정의 (roles.yml)
#
# - 기존 application.yml 의 portal.portal_security 를 이동한 파일.
# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다.
# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며,
# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다.
# ------------------------------------------------------------------------------
portal-roles:
roles:
- code: ROLE_USER
name: "개인사용자"
authorities:
- ROLE_INQUIRY
- ROLE_ACCOUNT
- code: ROLE_CORP_USER
name: "법인사용자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- code: ROLE_CORP_MANAGER
name: "법인관리자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용
authority-names:
ROLE_INQUIRY: "문의 작성"
ROLE_ACCOUNT: "계정 관리"
ROLE_API_KEY_REQUEST: "인증키 신청"
ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회"
ROLE_APP: "앱/API 신청 관리"
ROLE_WEBHOOK: "웹훅 관리"
ROLE_CORP_API: "법인 API 조회"
ROLE_DASHBOARD: "대시보드"
ROLE_USER_MANAGER: "개발자 관리"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -7,6 +7,7 @@
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
@@ -174,21 +175,61 @@
// ---------------- 이슈 카드 ----------------
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + '</div>';
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(issue) {
if (!issue.noticeDetail) return '';
const link = issue.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
function issueCardHtml(issue) {
@@ -222,8 +263,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ '</div>'
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis)
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
+ '</article>';
}
+56 -14
View File
@@ -8,6 +8,7 @@
const authenticated = page.classList.contains('is-authenticated');
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
@@ -87,21 +88,60 @@
}
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis, label) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, label, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + '</div>';
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(incident) {
if (!incident.noticeDetail) return '';
const link = incident.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
// ---------------- ❶ 진행 중 장애 ----------------
@@ -139,8 +179,9 @@
+ '</div>'
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
+ apiPillsHtml(incident.apis, '영향 API:')
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
noticeHtml(incident))
+ '</section>';
}).join('');
@@ -252,8 +293,9 @@
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
+ '</div>'
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
+ apiPillsHtml(card.impactedApis, '영향 API:')
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
noticeHtml(card))
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
+ '</div>'
@@ -300,8 +342,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis, '영향 API:')
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
+ '</article>'
+ '</div>';
}
@@ -285,20 +285,40 @@
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
transform-origin: 400px 110px;
transform-box: fill-box;
animation: pulseLine 2s ease-in-out infinite;
}
@keyframes pulseLine {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 0.5;
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@@ -87,11 +87,18 @@
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
}
/* Noto Sans KR (Variable) */
/* Noto Sans KR (Variable)
- 본문 3순위 폴백이므로 로컬 설치본을 최우선 사용한다.
- 웹폰트는 한글 음절 전체(U+AC00–D7A3)·라틴·기호만 남긴 woff2 서브셋(약 1.1MB).
원본 VariableFont TTF(9.9MB)는 전송 중 abort로 broken pipe 로그를 유발해 제거했다.
서브셋 재생성은 docs/font-subset.md 참고. */
@font-face {
font-family: 'Noto Sans KR';
font-weight: 100 900;
font-style: normal;
font-display: swap;
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype');
src: local('Noto Sans KR'),
local('NotoSansKR'),
local('Noto Sans CJK KR'),
url('/font/noto-sans/NotoSansKR-subset.woff2') format('woff2');
}
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background-color: #fcfcfc;
&::placeholder {
color: #7f8a95;
@@ -1010,6 +1010,7 @@ select.form-control {
resize: none;
transition: border-color 0.2s;
min-height: 250px;
background-color: #fcfcfc;
&::placeholder {
color: #94A3B8;

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