diff --git a/.gitignore b/.gitignore
index 1e040dc..abe6f4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,4 +110,6 @@ TODO.txt
diff
*rinjae*
-*gf63*
\ No newline at end of file
+*gf63*
+*obsidian*
+design-backup
\ No newline at end of file
diff --git a/Jenkinsfile.deploy b/Jenkinsfile.deploy
index 0dd45e7..930f2ca 100644
--- a/Jenkinsfile.deploy
+++ b/Jenkinsfile.deploy
@@ -1,180 +1,172 @@
pipeline {
- agent { label 'djb-vm' }
-
- parameters {
- string(name: 'BRANCH', defaultValue: 'master', description: 'Branch to deploy')
- }
+ agent none
options {
timestamps()
disableConcurrentBuilds()
+ skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
- JAVA_HOME = '/apps/opts/jdk8'
- JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
- GRADLE_HOME = '/apps/opts/gradle-8.7'
- GRADLE_USER_HOME = '/apps/opts/gradle-home'
- NODE_HOME = '/apps/opts/node-v24'
- PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
- CATALINA_BASE = '/prod/eapim/devportal'
- CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
- CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
- DEPLOY_HTTP_PORT = '39130'
+ WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
}
stages {
- stage('Checkout') {
- steps {
- checkout([
- $class: 'GitSCM',
- branches: [[name: "*/${params.BRANCH}"]],
- userRemoteConfigs: [[url: 'ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git']]
- ])
+ stage('Build (djb-vm)') {
+ agent { label 'djb-vm' }
+ environment {
+ JAVA_HOME = '/apps/opts/jdk8'
+ GRADLE_HOME = '/apps/opts/gradle-8.7'
+ GRADLE_USER_HOME = '/apps/opts/gradle-home'
+ NODE_HOME = '/apps/opts/node-v24'
+ PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
}
- }
+ stages {
+ stage('Checkout') {
+ steps { checkout scm }
+ }
+ 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; java -version; gradle --version' }
+ }
+ stage('Test') {
+ steps { sh 'gradle clean test --no-daemon -Pprofile=weblogic' }
+ post {
+ always {
+ junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
+ archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
+ }
+ }
+ }
+ stage('Build WAR') {
+ steps { sh 'gradle build -x test --no-daemon -Pprofile=weblogic' }
+ post {
+ success {
+ sh '''
+ set -eu
+ cd build/libs
+ sha256sum eapim-portal.war > eapim-portal.war.sha256
+ '''
+ archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
+ stash name: 'war', includes: 'build/libs/eapim-portal.war'
+ }
+ }
+ }
- stage('Checkout dependencies') {
- steps {
- sh '''
- set -eu
- cd "$WORKSPACE/.."
-
- if [ ! -d elink-portal-common/.git ]; then
- 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
- 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
- java -version
- gradle --version
- '''
- }
- }
-
- stage('Build WAR') {
- steps {
- sh 'gradle clean build -x test --no-daemon'
- }
- post {
- success {
- sh '''
- set -eu
- cd build/libs
- sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
- sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
- '''
- archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
+ // SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
+ // 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
+ stage('SBOM') {
+ steps {
+ catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
+ sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
+ }
+ }
+ post {
+ always {
+ archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
+ }
+ }
}
}
}
- stage('Stop Tomcat') {
- steps {
+ stage('Deploy (weblogic)') {
+ agent { label 'weblogic' }
+ environment {
+ JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
+ WL_HOME = '/app/eapim/devportal'
+ WL_DEPLOY_DIR = '/app/eapim/devportal'
+ WL_WAR_NAME = 'eapim-portal.war'
+ WL_HTTP_PORT = '39130'
+ WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
+ }
+ stages {
+ stage('Stop WebLogic') {
+ steps {
+ sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
+ }
+ }
+ stage('Deploy WAR') {
+ steps {
+ unstash 'war'
+ sh '''
+ set -eu
+ cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
+ ls -la "$WL_DEPLOY_DIR"
+ '''
+ }
+ }
+ stage('Start WebLogic and readiness') {
+ steps {
+ sh '''
+ set -eu
+ "$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
+
+ DEADLINE=$(($(date +%s) + 300))
+ STATUS=000
+ while [ "$(date +%s)" -lt "$DEADLINE" ]; do
+ STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
+ "http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
+ [ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
+ sleep 3
+ done
+
+ if [ "$STATUS" != "200" ]; then
+ echo "Readiness failed within 300s, last HTTP=$STATUS"
+ [ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
+ exit 1
+ fi
+ '''
+ }
+ }
+ }
+ }
+ }
+
+ post {
+ success {
+ node('weblogic') {
sh '''
set +e
- systemctl --user stop eapim-portal 2>/dev/null
-
- if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
- JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
- for i in $(seq 1 30); do
- [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
- sleep 1
- done
- fi
- rm -f "$CATALINA_PID"
+ payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
+ curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
'''
}
}
-
- stage('Deploy ROOT.war') {
- steps {
+ failure {
+ node('weblogic') {
sh '''
- set -eu
- DEPLOY_DIR="$CATALINA_BASE/webapps"
- rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
- cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
- ls -la "$DEPLOY_DIR"
- '''
- }
- }
-
- stage('Start Tomcat and readiness') {
- steps {
- sh '''
- set -eu
- LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
- BEFORE=0
- [ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
-
- systemctl --user start eapim-portal
-
- DEADLINE=$(($(date +%s) + 120))
- LIVE_OK=0
- STATUS=000
-
- while [ "$(date +%s)" -lt "$DEADLINE" ]; do
- if [ "$LIVE_OK" = "0" ]; then
- LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
- "http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
- if [ "$LIVE" = "200" ]; then
- echo "Liveness OK (/health)"
- LIVE_OK=1
- fi
- fi
-
- if [ "$LIVE_OK" = "1" ]; then
- STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
- "http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
- if [ "$STATUS" = "200" ]; then
- echo "Readiness OK (/health/ready)"
- cat /tmp/eapim-ready-body.json
- echo
- break
- fi
- fi
-
- if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
- echo "Startup error detected in log"
- tail -c +$((BEFORE+1)) "$LOG" | tail -80
- exit 1
- fi
- sleep 2
- done
-
- if [ "$STATUS" != "200" ]; then
- echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
- [ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
- [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
- exit 1
- fi
-
- echo "--- key boot log lines ---"
- tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
+ set +e
+ payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
+ curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
'''
}
}
diff --git a/Jenkinsfile.test-build b/Jenkinsfile.test-build
index e1d2d4c..2935c2e 100644
--- a/Jenkinsfile.test-build
+++ b/Jenkinsfile.test-build
@@ -91,10 +91,28 @@ pipeline {
sh '''
set -eu
cd build/libs
- sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
- sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
+ for f in eapim-portal.war eapim-portal-boot.war; do
+ sha1sum "$f" > "$f.sha1"
+ sha256sum "$f" > "$f.sha256"
+ md5sum "$f" > "$f.md5"
+ done
'''
- archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
+ archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
+ }
+ }
+ }
+
+ // SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
+ // 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
+ stage('SBOM') {
+ steps {
+ catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
+ sh 'gradle sbomXlsx --no-daemon'
+ }
+ }
+ post {
+ always {
+ archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
diff --git a/README.md b/README.md
index b96b873..88c3868 100644
--- a/README.md
+++ b/README.md
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
+- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
diff --git a/build-gf63.sh b/build-gf63.sh
deleted file mode 100644
index 9a8060e..0000000
--- a/build-gf63.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
-export GRADLE_HOME=/c/eactive/apps/gradle-8.7
-export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
-
-export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
-
-
-echo "========================"
-java -version
-javac -version
-echo "========================"
-gradle --version
-echo "========================"
-
-gradle clean build -x test --no-daemon
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 26be4b9..3f1ad5b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
+ id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
}
@@ -23,15 +24,36 @@ 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"
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
-// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
-// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
- implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
+ // OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
+ // swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
+ // 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
+ implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
+ implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
+ implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
implementation project(':elink-online-core-jpa')
implementation project(':elink-portal-common')
@@ -41,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'
@@ -76,9 +115,10 @@ dependencies {
// exclude group: 'commons-collections', module: 'commons-collections'
}
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
- implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
- implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
- implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
+ // WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
+ implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
+ implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -124,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]
}
@@ -141,6 +190,14 @@ sourceSets {
configurations {
annotationProcessor
+
+ // WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
+ // 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
+ // WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
+ // 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
+ all {
+ exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
+ }
}
compileJava {
@@ -150,11 +207,11 @@ compileJava {
}
processResources {
- exclude { details ->
- details.file.name.startsWith('application-') &&
- details.file.name.endsWith('.yml') &&
- !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
- }
+// exclude { details ->
+// details.file.name.startsWith('application-') &&
+// details.file.name.endsWith('.yml') &&
+// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
+// }
}
test {
@@ -168,8 +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 {
@@ -178,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 {
@@ -189,4 +261,6 @@ task printSourceSets {
println " Output dir : ${srcSet.output.classesDirs.asPath}"
}
}
-}
\ No newline at end of file
+}
+// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
+apply from: "$projectDir/gradle/sbom-xlsx.gradle"
diff --git a/build_docker.sh b/build_docker.sh
deleted file mode 100644
index 1cf0a19..0000000
--- a/build_docker.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-./gradlew bootWar
-docker build -t eactive-portal .
-
diff --git a/deploy_portal.sh b/deploy_portal.sh
deleted file mode 100644
index 5344dea..0000000
--- a/deploy_portal.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-
-# Check if the war file is provided
-if [ -z "$1" ]; then
- echo "Usage: $0 /path/to/DEVPortal.war"
- exit 1
-fi
-
-# Variables
-WAR_FILE="$1"
-TIMESTAMP=$(date +%Y%m%d%H%M%S)
-DEST_DIR="/app/apim/deploy/DVPWeb"
-BACKUP_DIR="/app/apim/backup"
-
-# Create backup directory if not exists
-mkdir -p $BACKUP_DIR
-
-# Backup the existing directory
-if [ -d "$DEST_DIR" ]; then
- echo "Backing up existing directory..."
- mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
-fi
-
-# Create destination directory if not exists
-mkdir -p $DEST_DIR
-
-# Unzip the file to the dedicated location
-echo "Extracting WAR file to $DEST_DIR..."
-unzip $WAR_FILE -d $DEST_DIR
-
-echo "deploy successful"
diff --git a/deploy_portal2.sh b/deploy_portal2.sh
deleted file mode 100644
index 952665c..0000000
--- a/deploy_portal2.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/bin/bash
-
-# Check if the war file is provided
-if [ -z "$1" ]; then
- echo "Usage: $0 /path/to/DEVPortal.war"
- exit 1
-fi
-
-# Variables
-WAR_FILE="$1"
-TIMESTAMP=$(date +%Y%m%d%H%M%S)
-DEST_DIR="/app/apim/deploy/DVPWeb"
-STATIC_DIR="/app/apim/deploy/static"
-BACKUP_DIR="/app/apim/backup"
-
-# Create backup directory if not exists
-mkdir -p $BACKUP_DIR
-
-# Backup the existing directory
-if [ -d "$STATIC_DIR" ]; then
- echo "Backing up existing static directory..."
- mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
-fi
-
-if [ -d "$DEST_DIR" ]; then
- echo "Backing up existing directory..."
- mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
-fi
-
-
-# Create destination directory if not exists
-mkdir -p $DEST_DIR
-mkdir -p $STATIC_DIR
-
-# unzip the file to the dedicated location
-echo "Extracting WAR file to $DEST_DIR..."
-unzip $WAR_FILE -d $DEST_DIR
-mv $DEST_DIR/static/* $STATIC_DIR/
-
-echo "deploy successful"
diff --git a/email_template/djbank/email-auth-template-datauri.html b/email_template/djbank/email-auth-template-datauri.html
new file mode 100644
index 0000000..e273f5d
--- /dev/null
+++ b/email_template/djbank/email-auth-template-datauri.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+DJBank 파트너포탈 이메일 인증
+
+
+
+
+DJBank 파트너포탈 이메일 인증번호 안내입니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | |
+
+
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ | 이메일인증 |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+ 위 이메일 인증코드를 입력하여 이메일을 인증해 주세요.
+
+ |
+
+
+
+
+ | ※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다. |
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ 본 메일은 발신 전용으로 회신되지 않습니다.
+ 관련 문의사항은 고객센터 (1588-0079) 또는 홈페이지를 이용하시기 바랍니다.
+ 제주 제주시 1100로 3351 (노형동) | 고객센터 : 1588-0079
+ Copyright JEJUBANK. ALL Rights Reserved.
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
diff --git a/email_template/djbank/email-auth-template-linked.html b/email_template/djbank/email-auth-template-linked.html
new file mode 100644
index 0000000..a151e95
--- /dev/null
+++ b/email_template/djbank/email-auth-template-linked.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+DJBank API Portal 이메일 인증
+
+
+
+
+DJBank 개발자포탈 이메일 인증번호 안내입니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | |
+
+
+ |
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ | ##subject:이메일인증## |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ 안녕하세요, 김철수 님.
+ DJBank 개발자포탈 회원가입 시 입력하신 이메일을 인증해 주세요.
+ 아래 인증번호를 회원가입 화면에 입력하시면 인증이 완료됩니다.
+ |
+
+
+
+
+ | ※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다. |
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ 본 메일은 발신 전용으로 회신되지 않습니다.
+ 관련 문의사항은 고객센터 (????-????) 또는 홈페이지를 이용하시기 바랍니다.
+ 제주 제주시 1100로 3351 (노형동) | 고객센터 : ????-????? | 사업자 등록번호 : 616-81-00615
+ Copyright JEJUBANK. ALL Rights Reserved.
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
diff --git a/email_template/djbank/images/logo-apiportal.png b/email_template/djbank/images/logo-apiportal.png
new file mode 100644
index 0000000..2800423
Binary files /dev/null and b/email_template/djbank/images/logo-apiportal.png differ
diff --git a/email_template/djbank/images/logo-djbank.png b/email_template/djbank/images/logo-djbank.png
new file mode 100644
index 0000000..2155729
Binary files /dev/null and b/email_template/djbank/images/logo-djbank.png differ
diff --git a/email_template/djbank/images/logo-jejubank.png b/email_template/djbank/images/logo-jejubank.png
new file mode 100644
index 0000000..600a58f
Binary files /dev/null and b/email_template/djbank/images/logo-jejubank.png differ
diff --git a/email_template/email_common_template.html b/email_template/kjbank/email_common_template.html
similarity index 100%
rename from email_template/email_common_template.html
rename to email_template/kjbank/email_common_template.html
diff --git a/email_template/email_logo_bottom.png b/email_template/kjbank/email_logo_bottom.png
similarity index 100%
rename from email_template/email_logo_bottom.png
rename to email_template/kjbank/email_logo_bottom.png
diff --git a/email_template/email_logo_top.png b/email_template/kjbank/email_logo_top.png
similarity index 100%
rename from email_template/email_logo_top.png
rename to email_template/kjbank/email_logo_top.png
diff --git a/email_template/email_otp_password.html b/email_template/kjbank/email_otp_password.html
similarity index 100%
rename from email_template/email_otp_password.html
rename to email_template/kjbank/email_otp_password.html
diff --git a/email_template/email_password_changed.html b/email_template/kjbank/email_password_changed.html
similarity index 100%
rename from email_template/email_password_changed.html
rename to email_template/kjbank/email_password_changed.html
diff --git a/email_template/email_verification.html b/email_template/kjbank/email_verification.html
similarity index 100%
rename from email_template/email_verification.html
rename to email_template/kjbank/email_verification.html
diff --git a/gradle-gf63.sh b/gradle-gf63.sh
deleted file mode 100644
index 35d9cc1..0000000
--- a/gradle-gf63.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/bash
-
-export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
-export GRADLE_HOME=/c/eactive/apps/gradle-8.7
-export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
-
-export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
-
-
-# gradle 명령을 대체합니다.
-command gradle $@
\ No newline at end of file
diff --git a/gradle/sbom-xlsx.gradle b/gradle/sbom-xlsx.gradle
new file mode 100644
index 0000000..8e506d8
--- /dev/null
+++ b/gradle/sbom-xlsx.gradle
@@ -0,0 +1,326 @@
+/*
+ * CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
+ *
+ * gradle sbomXlsx # cyclonedxBom 실행 후 변환
+ * gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
+ * gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
+ *
+ * buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
+ * classpath 나 WAR 산출물에는 포함되지 않는다.
+ *
+ * 시트: 요약 / WAR 기준
+ * 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
+ * test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
+ * bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
+ */
+buildscript {
+ repositories {
+ maven {
+ url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
+ allowInsecureProtocol = true
+ }
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'org.apache.poi:poi-ooxml:3.17'
+ }
+}
+
+import groovy.json.JsonSlurper
+import org.apache.poi.ss.usermodel.BorderStyle
+import org.apache.poi.ss.usermodel.FillPatternType
+import org.apache.poi.ss.usermodel.HorizontalAlignment
+import org.apache.poi.ss.usermodel.IndexedColors
+import org.apache.poi.ss.usermodel.VerticalAlignment
+import org.apache.poi.ss.util.CellRangeAddress
+import org.apache.poi.xssf.usermodel.XSSFWorkbook
+
+// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
+ext.SBOM_CELL_LIMIT = 32000
+
+task sbomXlsx {
+ group = 'sbom'
+ description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
+
+ // -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
+ if (!project.hasProperty('sbomJson')) {
+ dependsOn 'cyclonedxBom'
+ }
+
+ doLast {
+ File src = resolveBomJson(project)
+ File out = project.hasProperty('sbomOut')
+ ? project.file(project.property('sbomOut'))
+ : new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
+ out.parentFile.mkdirs()
+
+ def bom = new JsonSlurper().parse(src, 'UTF-8')
+ def deploy = collectDeployJars(project)
+ def warRows = joinWarRows(deploy.jars, indexComponents(bom))
+
+ def wb = new XSSFWorkbook()
+ def st = createStyles(wb)
+ writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
+ writeWarSheet(wb, st, warRows)
+
+ out.withOutputStream { os -> wb.write(os) }
+ wb.close()
+
+ int unmatched = warRows.count { it.matched == 'N' }
+ logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
+ "(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
+ }
+}
+
+/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
+String sbomFileName(Project p) {
+ def warTask = p.tasks.findByName('war')
+ if (warTask != null) {
+ String archive = warTask.archiveFileName.get()
+ return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
+ }
+ String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
+ return "${p.name}${ver}-sbom.xlsx"
+}
+
+/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
+File resolveBomJson(Project p) {
+ if (p.hasProperty('sbomJson')) {
+ File f = p.file(p.property('sbomJson'))
+ if (!f.exists()) {
+ throw new GradleException("bom.json 없음: ${f.absolutePath}")
+ }
+ return f
+ }
+ def candidates = [
+ new File(p.buildDir, 'reports/cyclonedx/bom.json'),
+ new File(p.buildDir, 'reports/bom.json'),
+ ]
+ File found = candidates.find { it.exists() }
+ if (found == null) {
+ throw new GradleException(
+ "bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
+ "\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
+ }
+ return found
+}
+
+/**
+ * 실제 배포물에 packaging 되는 jar 목록.
+ * war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
+ * 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
+ *
+ * @return [label: 기준 설명, jars: 행 목록]
+ */
+Map collectDeployJars(Project p) {
+ def cfg = p.configurations.findByName('runtimeClasspath')
+ if (cfg == null) {
+ p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
+ return [label: '(없음)', jars: []]
+ }
+
+ def warTask = p.tasks.findByName('war')
+ def files
+ String label
+ if (warTask != null) {
+ files = warTask.classpath.files
+ label = 'WAR WEB-INF/lib (war 태스크 classpath)'
+ } else {
+ files = cfg.files
+ label = 'runtimeClasspath (war 태스크 없음)'
+ }
+
+ def coordByFile = [:]
+ cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
+ def id = a.moduleVersion.id
+ coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
+ }
+ def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
+ def c = coordByFile[f]
+ [
+ file : f.name,
+ group : c?.group ?: '',
+ name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
+ version: c?.version ?: '',
+ coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
+ ]
+ }.sort { it.file }
+
+ return [label: label, jars: jars]
+}
+
+/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
+Map indexComponents(bom) {
+ String rootRef = bom.metadata?.component?.'bom-ref'
+ Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
+
+ def index = [:]
+ bom.components?.each { c ->
+ def hashes = [:]
+ c.hashes?.each { h -> hashes[h.alg] = h.content }
+
+ def licenses = (c.licenses ?: []).collect { l ->
+ l.license?.id ?: l.license?.name ?: l.expression ?: ''
+ }.findAll { it }
+
+ index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
+ direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
+ licenses : licenses.join('; '),
+ licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
+ purl : c.purl ?: '',
+ sha256 : hashes['SHA-256'] ?: '',
+ sha1 : hashes['SHA-1'] ?: '',
+ description: c.description ?: '',
+ ]
+ }
+ return index
+}
+
+/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
+List joinWarRows(List warJars, Map index) {
+ def result = []
+ warJars.eachWithIndex { j, i ->
+ def m = j.coord ? index[j.coord] : null
+ result << [
+ no : i + 1,
+ file : j.file,
+ group : j.group,
+ name : j.name,
+ version : j.version,
+ direct : m?.direct ?: '',
+ licenses : m?.licenses ?: '',
+ licenseList: m?.licenseList ?: ['(미상)'],
+ purl : m?.purl ?: '',
+ sha256 : m?.sha256 ?: '',
+ sha1 : m?.sha1 ?: '',
+ matched : (m != null) ? 'Y' : 'N',
+ description: m?.description ?: '',
+ ]
+ }
+ return result
+}
+
+Map createStyles(wb) {
+ def headFont = wb.createFont()
+ headFont.setBold(true)
+ headFont.setColor(IndexedColors.WHITE.getIndex())
+
+ def head = wb.createCellStyle()
+ head.setFont(headFont)
+ head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
+ head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
+ head.setAlignment(HorizontalAlignment.CENTER)
+ head.setVerticalAlignment(VerticalAlignment.CENTER)
+ head.setBorderBottom(BorderStyle.THIN)
+
+ def body = wb.createCellStyle()
+ body.setVerticalAlignment(VerticalAlignment.TOP)
+
+ def wrap = wb.createCellStyle()
+ wrap.setVerticalAlignment(VerticalAlignment.TOP)
+ wrap.setWrapText(true)
+
+ def labelFont = wb.createFont()
+ labelFont.setBold(true)
+ def label = wb.createCellStyle()
+ label.setFont(labelFont)
+
+ return [head: head, body: body, wrap: wrap, label: label]
+}
+
+/** 헤더 행 생성 + 폭 지정 + 틀고정 */
+def writeHeader(sheet, style, List headers, List widths) {
+ def row = sheet.createRow(0)
+ row.setHeightInPoints(20f)
+ headers.eachWithIndex { h, i ->
+ def cell = row.createCell(i)
+ cell.setCellValue(h)
+ cell.setCellStyle(style)
+ sheet.setColumnWidth(i, widths[i] * 256)
+ }
+ sheet.createFreezePane(0, 1)
+}
+
+def cellOf(row, int idx, value, style) {
+ def cell = row.createCell(idx)
+ String s = (value == null) ? '' : value.toString()
+ if (s.length() > SBOM_CELL_LIMIT) {
+ s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
+ }
+ cell.setCellValue(s)
+ cell.setCellStyle(style)
+ return cell
+}
+
+def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
+ def sheet = wb.createSheet('요약')
+ def comp = bom.metadata?.component ?: [:]
+ def tool = bom.metadata?.tools?.components?.getAt(0)
+ Set licenseKinds = warRows.collectMany { it.licenseList } as Set
+
+ def items = [
+ ['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
+ ['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
+ ['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
+ ['serialNumber', bom.serialNumber ?: ''],
+ ['생성 시각', bom.metadata?.timestamp ?: ''],
+ ['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
+ ['원본 파일', src.absolutePath],
+ ['배포 수록 jar', warRows.size()],
+ [' └ 직접 의존', warRows.count { it.direct == '직접' }],
+ [' └ 전이 의존', warRows.count { it.direct == '전이' }],
+ [' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
+ ['라이선스 종류', licenseKinds.size()],
+ ['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
+ ]
+
+ writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
+ items.eachWithIndex { item, i ->
+ def row = sheet.createRow(i + 1)
+ cellOf(row, 0, item[0], st.label)
+ cellOf(row, 1, item[1], st.body)
+ }
+
+ // 라이선스 분포 (요약 하단)
+ def byLicense = [:].withDefault { 0 }
+ warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
+ def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
+
+ int base = items.size() + 2
+ def hdr = sheet.createRow(base)
+ cellOf(hdr, 0, '라이선스', st.head)
+ cellOf(hdr, 1, 'jar 수', st.head)
+ sorted.eachWithIndex { e, i ->
+ def row = sheet.createRow(base + 1 + i)
+ cellOf(row, 0, e.key, st.body)
+ cellOf(row, 1, e.value, st.body)
+ }
+}
+
+/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
+def writeWarSheet(wb, st, List warRows) {
+ def sheet = wb.createSheet('WAR 기준')
+ def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
+ 'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
+ def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
+ writeHeader(sheet, st.head, headers, widths)
+
+ warRows.eachWithIndex { r, i ->
+ def row = sheet.createRow(i + 1)
+ cellOf(row, 0, r.no, st.body)
+ cellOf(row, 1, r.file, st.body)
+ cellOf(row, 2, r.group, st.body)
+ cellOf(row, 3, r.name, st.body)
+ cellOf(row, 4, r.version, st.body)
+ cellOf(row, 5, r.direct, st.body)
+ cellOf(row, 6, r.licenses, st.body)
+ cellOf(row, 7, r.purl, st.body)
+ cellOf(row, 8, r.sha256, st.body)
+ cellOf(row, 9, r.sha1, st.body)
+ cellOf(row, 10, r.matched, st.body)
+ cellOf(row, 11, r.description, st.wrap)
+ }
+ if (!warRows.isEmpty()) {
+ sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
+ }
+}
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/libs/swagger/jackson-dataformat-yaml-2.18.1.jar b/libs/swagger/jackson-dataformat-yaml-2.18.1.jar
deleted file mode 100644
index a1e25c1..0000000
Binary files a/libs/swagger/jackson-dataformat-yaml-2.18.1.jar and /dev/null differ
diff --git a/libs/swagger/swagger-annotations-1.6.14.jar b/libs/swagger/swagger-annotations-1.6.14.jar
deleted file mode 100644
index 1f8db9b..0000000
Binary files a/libs/swagger/swagger-annotations-1.6.14.jar and /dev/null differ
diff --git a/libs/swagger/swagger-annotations-2.2.25.jar b/libs/swagger/swagger-annotations-2.2.25.jar
deleted file mode 100644
index 23ef295..0000000
Binary files a/libs/swagger/swagger-annotations-2.2.25.jar and /dev/null differ
diff --git a/libs/swagger/swagger-compat-spec-parser-1.0.71.jar b/libs/swagger/swagger-compat-spec-parser-1.0.71.jar
deleted file mode 100644
index d768696..0000000
Binary files a/libs/swagger/swagger-compat-spec-parser-1.0.71.jar and /dev/null differ
diff --git a/libs/swagger/swagger-core-1.6.14.jar b/libs/swagger/swagger-core-1.6.14.jar
deleted file mode 100644
index 037dce7..0000000
Binary files a/libs/swagger/swagger-core-1.6.14.jar and /dev/null differ
diff --git a/libs/swagger/swagger-core-2.2.25.jar b/libs/swagger/swagger-core-2.2.25.jar
deleted file mode 100644
index 286953d..0000000
Binary files a/libs/swagger/swagger-core-2.2.25.jar and /dev/null differ
diff --git a/libs/swagger/swagger-models-1.6.14.jar b/libs/swagger/swagger-models-1.6.14.jar
deleted file mode 100644
index fcb069a..0000000
Binary files a/libs/swagger/swagger-models-1.6.14.jar and /dev/null differ
diff --git a/libs/swagger/swagger-models-2.2.25.jar b/libs/swagger/swagger-models-2.2.25.jar
deleted file mode 100644
index 8113753..0000000
Binary files a/libs/swagger/swagger-models-2.2.25.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-1.0.71.jar b/libs/swagger/swagger-parser-1.0.71.jar
deleted file mode 100644
index 2d34db0..0000000
Binary files a/libs/swagger/swagger-parser-1.0.71.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-2.1.23.jar b/libs/swagger/swagger-parser-2.1.23.jar
deleted file mode 100644
index eb18923..0000000
Binary files a/libs/swagger/swagger-parser-2.1.23.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-core-2.1.23.jar b/libs/swagger/swagger-parser-core-2.1.23.jar
deleted file mode 100644
index e1455ae..0000000
Binary files a/libs/swagger/swagger-parser-core-2.1.23.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-safe-url-resolver-1.0.71.jar b/libs/swagger/swagger-parser-safe-url-resolver-1.0.71.jar
deleted file mode 100644
index e26b64e..0000000
Binary files a/libs/swagger/swagger-parser-safe-url-resolver-1.0.71.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-safe-url-resolver-2.1.23.jar b/libs/swagger/swagger-parser-safe-url-resolver-2.1.23.jar
deleted file mode 100644
index 7e88d67..0000000
Binary files a/libs/swagger/swagger-parser-safe-url-resolver-2.1.23.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-v2-converter-2.1.23.jar b/libs/swagger/swagger-parser-v2-converter-2.1.23.jar
deleted file mode 100644
index dc0fe3c..0000000
Binary files a/libs/swagger/swagger-parser-v2-converter-2.1.23.jar and /dev/null differ
diff --git a/libs/swagger/swagger-parser-v3-2.1.23.jar b/libs/swagger/swagger-parser-v3-2.1.23.jar
deleted file mode 100644
index 2dbf66c..0000000
Binary files a/libs/swagger/swagger-parser-v3-2.1.23.jar and /dev/null differ
diff --git a/readme-docs/메뉴-관리-개발-가이드.md b/readme-docs/메뉴-관리-개발-가이드.md
new file mode 100644
index 0000000..95aa529
--- /dev/null
+++ b/readme-docs/메뉴-관리-개발-가이드.md
@@ -0,0 +1,85 @@
+# 메뉴 관리 개발 가이드
+
+포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
+노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
+
+## 구성 요소
+
+| 구성 | 위치 | 역할 |
+|---|---|---|
+| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
+| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
+| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
+| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
+| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
+| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
+| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
+| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
+
+메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
+`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
+
+## menu.yml 스키마
+
+```yaml
+portal-menu:
+ items:
+ - id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
+ name: "고객지원"
+ group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
+ section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
+ expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
+ children:
+ - { id: support-faq, name: "FAQ", path: /faq_list }
+ - { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
+ expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
+```
+
+- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
+- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
+- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
+
+## 시딩 규칙 (MenuSeeder)
+
+1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
+ **관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
+ 구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
+2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩.
+ 이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
+3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
+4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
+
+## 캐시와 리로드
+
+- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
+- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
+ (admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
+- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
+ + X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
+- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
+
+## 새 메뉴 추가 절차
+
+**기본 메뉴(코드 배포와 함께)**
+1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
+2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
+3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
+4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
+ admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
+
+**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
+커스텀 항목은 미배치 시 삭제된다.
+
+## 로컬 개발 주의
+
+- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
+ `JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
+ (미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
+- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
+- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
+
+## 역할(roles.yml) 변경
+
+- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
+ admin 권한 선택 체크박스 소스 전용.
+- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
diff --git a/script/djb-custom-export/README.md b/script/djb-custom-export/README.md
new file mode 100644
index 0000000..624ed95
--- /dev/null
+++ b/script/djb-custom-export/README.md
@@ -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= HEAD`
+ 컷오프 이전 커밋이 아예 없으면 저장소 전체를 신규로 본다.
+2. `git diff --name-only --diff-filter=A -M HEAD -- <소스경로>` 로
+ **신규 추가(A)** 파일만 뽑는다. `-M` 이라 단순 rename 은 신규로 잡히지 않는다.
+3. `modules.conf` 의 확장자 allowlist 로 거른다. (확장자 없는 파일은 제외)
+4. `export-filter.rules` 로 포함·제외를 최종 결정한다.
+5. 남은 파일을 `/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` | 출력 디렉터리 (기본 `/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` 에 남긴다.
diff --git a/script/djb-custom-export/export-custom.bat b/script/djb-custom-export/export-custom.bat
new file mode 100644
index 0000000..5325663
--- /dev/null
+++ b/script/djb-custom-export/export-custom.bat
@@ -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%
diff --git a/script/djb-custom-export/export-custom.ps1 b/script/djb-custom-export/export-custom.ps1
new file mode 100644
index 0000000..7b859ec
--- /dev/null
+++ b/script/djb-custom-export/export-custom.ps1
@@ -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'
+
+# 저장소 루트: 스크립트는 \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 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"
+ }
+}
diff --git a/script/djb-custom-export/export-custom.sh b/script/djb-custom-export/export-custom.sh
new file mode 100755
index 0000000..1c5e30b
--- /dev/null
+++ b/script/djb-custom-export/export-custom.sh
@@ -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 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다.
+ (기본: 2026-05-01)
+ -r, --root 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지.
+ -o, --out 출력 디렉터리. (기본: /build/djb-custom-export)
+ -m, --module 특정 모듈만 수집. 여러 번 지정 가능.
+ --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
+
+# ---- 저장소 루트 탐지 -----------------------------------------------------
+# 스크립트는 /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 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
diff --git a/script/djb-custom-export/export-filter.rules b/script/djb-custom-export/export-filter.rules
new file mode 100644
index 0000000..3dba8bc
--- /dev/null
+++ b/script/djb-custom-export/export-filter.rules
@@ -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*
diff --git a/script/djb-custom-export/filelist-no-djb-marker.txt b/script/djb-custom-export/filelist-no-djb-marker.txt
new file mode 100644
index 0000000..1b12182
--- /dev/null
+++ b/script/djb-custom-export/filelist-no-djb-marker.txt
@@ -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
diff --git a/script/djb-custom-export/filelist.txt b/script/djb-custom-export/filelist.txt
new file mode 100644
index 0000000..7254cdd
--- /dev/null
+++ b/script/djb-custom-export/filelist.txt
@@ -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
diff --git a/script/djb-custom-export/modules.conf b/script/djb-custom-export/modules.conf
new file mode 100644
index 0000000..ebdb2c2
--- /dev/null
+++ b/script/djb-custom-export/modules.conf
@@ -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
diff --git a/script/portal/oracle/1_portal.sql b/script/portal/oracle/1_portal.sql
index 520b32d..774a557 100644
--- a/script/portal/oracle/1_portal.sql
+++ b/script/portal/oracle/1_portal.sql
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
)
;
-create table DVPOWN.PT_TOKEN
+create table PT_TOKEN
+(
(
TOKEN VARCHAR2(255) not null
primary key,
diff --git a/script/portal/oracle/5_ptl_user_session.sql b/script/portal/oracle/5_ptl_user_session.sql
new file mode 100644
index 0000000..e0b3f2d
--- /dev/null
+++ b/script/portal/oracle/5_ptl_user_session.sql
@@ -0,0 +1,36 @@
+-- =============================================================================
+-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
+-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
+-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
+-- =============================================================================
+
+CREATE TABLE PTL_USER_SESSION
+(
+ SESSION_ID VARCHAR2(128) NOT NULL,
+ USER_ID VARCHAR2(36) NOT NULL,
+ LOGIN_ID VARCHAR2(200) NOT NULL,
+ LOGIN_TIME TIMESTAMP(6) NOT NULL,
+ LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
+ IP_ADDRESS VARCHAR2(45),
+ USER_AGENT VARCHAR2(500),
+ FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
+ CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
+);
+
+-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
+CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
+
+COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
+COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
+COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
+COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
+COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
+COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
+COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
+COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
+COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
+
+-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
+-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
+-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
+-- COMMIT;
diff --git a/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql b/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql
new file mode 100644
index 0000000..96139b5
--- /dev/null
+++ b/script/portal/oracle/6_ptl_two_factor_auth_recipient_key.sql
@@ -0,0 +1,23 @@
+-- =============================================================================
+-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
+-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
+-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
+--
+-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
+-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
+-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
+-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
+-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
+--
+-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
+-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
+-- =============================================================================
+
+ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
+
+-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
+CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
+
+COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
+
+COMMIT;
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
new file mode 100644
index 0000000..080aec8
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
@@ -0,0 +1,72 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.Table;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * API Gateway 일별 처리 통계 (AGWAPP.API_STATS_DAY) — 읽기 전용.
+ *
+ * 월별 조회 시 "현재 월"은 아직 API_STATS_MONTH 에 집계되지 않으므로(월 집계 잡은 익월 1일 실행)
+ * 현재 월분은 이 DAY 테이블에서 조회한다.
+ * 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "API_STATS_DAY")
+@IdClass(ApiStatsDayId.class)
+public class ApiStatsDay implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "STAT_TIME", nullable = false)
+ private LocalDate statTime;
+
+ @Id
+ @Column(name = "API_NAME", nullable = false, length = 100)
+ private String apiName;
+
+ @Id
+ @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
+ private String gwInstanceId;
+
+ @Id
+ @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
+ private String bizDivCode = "NONE";
+
+ @Id
+ @Column(name = "CLIENT_ID", nullable = false, length = 100)
+ private String clientId = "NONE";
+
+ @Id
+ @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
+ private String inboundAdapter = "NONE";
+
+ @Id
+ @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
+ private String outboundAdapter = "NONE";
+
+ @Column(name = "TOTAL_CNT", nullable = false)
+ private Long totalCnt = 0L;
+
+ @Column(name = "SUCCESS_CNT", nullable = false)
+ private Long successCnt = 0L;
+
+ @Column(name = "TIMEOUT_CNT", nullable = false)
+ private Long timeoutCnt = 0L;
+
+ @Column(name = "SYSTEM_ERR_CNT", nullable = false)
+ private Long systemErrCnt = 0L;
+
+ @Column(name = "BIZ_ERR_CNT", nullable = false)
+ private Long bizErrCnt = 0L;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
new file mode 100644
index 0000000..cdc1694
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
@@ -0,0 +1,26 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * API 통계 일별 복합키
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiStatsDayId implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private LocalDate statTime;
+ private String apiName;
+ private String gwInstanceId;
+ private String bizDivCode;
+ private String clientId;
+ private String inboundAdapter;
+ private String outboundAdapter;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
new file mode 100644
index 0000000..644577e
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
@@ -0,0 +1,71 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.Table;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * API Gateway 시간별 처리 통계 (AGWAPP.API_STATS_HOUR).
+ *
+ * 스키마명은 소스에 하드코딩하지 않는다 — gateway datasource 의 hibernate.default_schema(AGWAPP)로 해석된다.
+ * 포털은 읽기 전용으로만 사용한다(집계 주체는 eapim-admin 스케줄러). 사용 컬럼(카운트)만 매핑한다.
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "API_STATS_HOUR")
+@IdClass(ApiStatsHourId.class)
+public class ApiStatsHour implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "STAT_TIME", nullable = false)
+ private LocalDateTime statTime;
+
+ @Id
+ @Column(name = "API_NAME", nullable = false, length = 100)
+ private String apiName;
+
+ @Id
+ @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
+ private String gwInstanceId;
+
+ @Id
+ @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
+ private String bizDivCode = "NONE";
+
+ @Id
+ @Column(name = "CLIENT_ID", nullable = false, length = 100)
+ private String clientId = "NONE";
+
+ @Id
+ @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
+ private String inboundAdapter = "NONE";
+
+ @Id
+ @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
+ private String outboundAdapter = "NONE";
+
+ @Column(name = "TOTAL_CNT", nullable = false)
+ private Long totalCnt = 0L;
+
+ @Column(name = "SUCCESS_CNT", nullable = false)
+ private Long successCnt = 0L;
+
+ @Column(name = "TIMEOUT_CNT", nullable = false)
+ private Long timeoutCnt = 0L;
+
+ @Column(name = "SYSTEM_ERR_CNT", nullable = false)
+ private Long systemErrCnt = 0L;
+
+ @Column(name = "BIZ_ERR_CNT", nullable = false)
+ private Long bizErrCnt = 0L;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
new file mode 100644
index 0000000..b8b2f53
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
@@ -0,0 +1,26 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * API 통계 시간별 복합키
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiStatsHourId implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private LocalDateTime statTime;
+ private String apiName;
+ private String gwInstanceId;
+ private String bizDivCode;
+ private String clientId;
+ private String inboundAdapter;
+ private String outboundAdapter;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
new file mode 100644
index 0000000..b4ec522
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
@@ -0,0 +1,70 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.Table;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * API Gateway 월별 처리 통계 (AGWAPP.API_STATS_MONTH).
+ *
+ * STAT_TIME 은 YYYYMM 문자열. 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
+ * 포털은 읽기 전용으로만 사용한다. 사용 컬럼(카운트)만 매핑한다.
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "API_STATS_MONTH")
+@IdClass(ApiStatsMonthId.class)
+public class ApiStatsMonth implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "STAT_TIME", nullable = false, length = 6)
+ private String statTime; // YYYYMM format
+
+ @Id
+ @Column(name = "API_NAME", nullable = false, length = 100)
+ private String apiName;
+
+ @Id
+ @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
+ private String gwInstanceId;
+
+ @Id
+ @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
+ private String bizDivCode = "NONE";
+
+ @Id
+ @Column(name = "CLIENT_ID", nullable = false, length = 100)
+ private String clientId = "NONE";
+
+ @Id
+ @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
+ private String inboundAdapter = "NONE";
+
+ @Id
+ @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
+ private String outboundAdapter = "NONE";
+
+ @Column(name = "TOTAL_CNT", nullable = false)
+ private Long totalCnt = 0L;
+
+ @Column(name = "SUCCESS_CNT", nullable = false)
+ private Long successCnt = 0L;
+
+ @Column(name = "TIMEOUT_CNT", nullable = false)
+ private Long timeoutCnt = 0L;
+
+ @Column(name = "SYSTEM_ERR_CNT", nullable = false)
+ private Long systemErrCnt = 0L;
+
+ @Column(name = "BIZ_ERR_CNT", nullable = false)
+ private Long bizErrCnt = 0L;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
new file mode 100644
index 0000000..0c7b93c
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
@@ -0,0 +1,25 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * API 통계 월별 복합키
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiStatsMonthId implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private String statTime; // YYYYMM format
+ private String apiName;
+ private String gwInstanceId;
+ private String bizDivCode;
+ private String clientId;
+ private String inboundAdapter;
+ private String outboundAdapter;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
new file mode 100644
index 0000000..b9313c2
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
@@ -0,0 +1,36 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import lombok.Data;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.time.LocalDateTime;
+
+/**
+ * API 상태 모니터링 결과 (AGWAPP.API_STATUS).
+ *
+ * eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
+ * 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.
+ */
+@Entity
+@Table(name = "API_STATUS")
+@Data
+public class GwApiStatus {
+
+ /** EAI 서비스명 */
+ @Id
+ @Column(name = "EAISVCNAME", length = 30)
+ private String eaisvcname;
+
+ /** N 정상 / C 점검 / D 지연 / E 장애 */
+ @Column(name = "STATUS_CODE", length = 1)
+ private String statusCode;
+
+ @Column(name = "MODIFIED_BY", length = 20)
+ private String modifiedBy;
+
+ @Column(name = "MODIFIED_DATE")
+ private LocalDateTime modifiedDate;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
new file mode 100644
index 0000000..d2df93c
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
@@ -0,0 +1,41 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.Table;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * GW 인증 Client 정보 (AGWAPP.TSEAIAU01) — 읽기 전용.
+ *
+ * API 통계의 org 스코핑에 사용한다: 통계 테이블에는 org_id 가 없으므로
+ * TSEAIAU01.ORGID 로 org 의 CLIENTID 목록을 구해 API_STATS_*.CLIENT_ID 와 매칭한다.
+ * (admin ApiUseStatsService 도 API_STATS.CLIENT_ID = TSEAIAU01.CLIENTID 로 조인)
+ *
+ * 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "TSEAIAU01")
+public class GwAuthClient implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "CLIENTID", nullable = false, length = 256)
+ private String clientId;
+
+ @Column(name = "CLIENTNAME", length = 150)
+ private String clientName;
+
+ @Column(name = "ORGID", length = 38)
+ private String orgId;
+
+ @Column(name = "APPSTATUS", length = 1)
+ private String appStatus;
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
new file mode 100644
index 0000000..63ad96f
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
@@ -0,0 +1,33 @@
+package com.eactive.apim.gateway.data.statistics.entity;
+
+import java.io.Serializable;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.Table;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * GW 인터페이스(서비스) 정보 (AGWAPP.TSEAIHE01) — 읽기 전용.
+ *
+ * API_STATS_*.API_NAME 은 인터페이스 ID(=EAISVCNAME)이며, 화면 표시용 실제 명칭은 EAISVCDESC 다.
+ * (admin ApiUseStatsService 도 API_STATS.API_NAME = TSEAIHE01.EAISVCNAME 으로 조인)
+ * 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "TSEAIHE01")
+public class GwSvcInfo implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @Column(name = "EAISVCNAME", nullable = false, length = 30)
+ private String svcName; // 인터페이스 ID (= API_STATS.API_NAME)
+
+ @Column(name = "EAISVCDESC", length = 400)
+ private String svcDesc; // 인터페이스 표시 명칭
+}
diff --git a/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java b/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
new file mode 100644
index 0000000..66c095f
--- /dev/null
+++ b/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
@@ -0,0 +1,66 @@
+package com.eactive.apim.gateway.data.statistics.repository;
+
+import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
+import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
+import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
+import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
+import java.time.LocalDate;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+/**
+ * API_STATS_DAY(AGWAPP) 조회 리포지토리.
+ * 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
+ */
+public interface ApiStatsDayRepository extends JpaRepository {
+
+ /**
+ * 전체 요약 (일자 범위, 지정 clientId 집합).
+ */
+ @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
+ "COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
+ "COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
+ "FROM ApiStatsDay s " +
+ "WHERE s.clientId IN :clientIds " +
+ "AND s.statTime BETWEEN :startDate AND :endDate")
+ ApiStatisticsSummaryDto findSummary(
+ @Param("clientIds") List clientIds,
+ @Param("startDate") LocalDate startDate,
+ @Param("endDate") LocalDate endDate);
+
+ /**
+ * API별 통계 (API_NAME 단위 집계).
+ */
+ @Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
+ "s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
+ "COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
+ "FROM ApiStatsDay s " +
+ "WHERE s.clientId IN :clientIds " +
+ "AND s.statTime BETWEEN :startDate AND :endDate " +
+ "GROUP BY s.apiName " +
+ "ORDER BY SUM(s.totalCnt) DESC")
+ List findDetail(
+ @Param("clientIds") List clientIds,
+ @Param("startDate") LocalDate startDate,
+ @Param("endDate") LocalDate endDate);
+
+ /**
+ * 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
+ * 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
+ * row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
+ */
+ @Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
+ "COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
+ "COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
+ "FROM ApiStatsDay s " +
+ "WHERE s.clientId IN :clientIds " +
+ "AND s.statTime BETWEEN :startDate AND :endDate " +
+ "GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
+ "ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
+ List