Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b6ee10439 | |||
| 1a95f015dc | |||
| 92589530d1 | |||
| be40973aa3 | |||
| 1e21b85de1 | |||
| 7b8e0575c5 | |||
| 7c0cd371f0 | |||
| eb2cce8002 | |||
| 634c97c7ca | |||
| e32acfa215 | |||
| 8356fc3029 | |||
| d6186de009 | |||
| 2a9980ab16 | |||
| 919b4a0b06 | |||
| b0ecb26a9d | |||
| 0ad5bffa4f | |||
| 766341540d | |||
| 67ec6d2103 | |||
| b024f8e86f | |||
| fd16300553 | |||
| 815c329e3c | |||
| 7fdd70dbc7 | |||
| 3dd9edc849 | |||
| 6cff96f40a | |||
| a97609d533 |
@@ -83,6 +83,9 @@ file_upload/
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
# SonarScanner 작업 디렉터리
|
||||
.scannerwork/
|
||||
|
||||
|
||||
|
||||
build_knbank.sh
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// SonarQube 정적 보안 분석 전용 파이프라인.
|
||||
//
|
||||
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록의 SONAR_* 만 고치면 된다)
|
||||
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
|
||||
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록
|
||||
// (SONAR_SCANNER_TOOL 이 비어 있으면 흔한 이름을 탐색하고, 없으면 PATH 의 sonar-scanner 사용)
|
||||
// 3) Quality Gate 대기를 쓰려면 SonarQube 서버에
|
||||
// Administration > Configuration > Webhooks 에 <JENKINS_URL>/sonarqube-webhook/ 등록
|
||||
// 4) Job 의 Git 설정에서 shallow clone 을 끄고 tags/history 를 받아와야 blame 기반 "새 코드" 판정이 정확하다.
|
||||
//
|
||||
// 빌드는 JDK 8, 스캐너 실행은 JDK 17 로 분리한다.
|
||||
// (SonarScanner CLI 5.x / SonarQube 10.x 이상은 JRE 17 필수. JDK 8 로 실행하면 UnsupportedClassVersionError)
|
||||
pipeline {
|
||||
agent { label 'djb-vm' }
|
||||
|
||||
triggers {
|
||||
pollSCM('H/30 * * * *')
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_TESTS',
|
||||
defaultValue: false,
|
||||
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
|
||||
// Manage Jenkins > System > SonarQube servers 의 Name
|
||||
SONAR_ENV_NAME = 'SonarQube-Community'
|
||||
// Manage Jenkins > Tools > SonarQube Scanner 에 등록한 이름.
|
||||
// 비워두면 아래 후보 이름들을 순서대로 탐색하고, 그래도 없으면 PATH 의 sonar-scanner 를 쓴다.
|
||||
SONAR_SCANNER_TOOL = ''
|
||||
|
||||
// --- 툴체인 ---
|
||||
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
|
||||
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner 실행용
|
||||
GRADLE_HOME = '/apps/opts/gradle-8.7'
|
||||
GRADLE_USER_HOME = '/apps/opts/gradle-home'
|
||||
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/bin:${env.PATH}"
|
||||
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
|
||||
// 이 둘이 없으면 컴파일 자체가 불가능하고, 컴파일 산출물이 없으면 Sonar Java 분석이 무력화된다.
|
||||
stage('Checkout dependencies') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
cd "$WORKSPACE/.."
|
||||
|
||||
if [ ! -d elink-portal-common/.git ]; then
|
||||
rm -rf elink-portal-common
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
|
||||
elink-portal-common
|
||||
else
|
||||
git -C elink-portal-common fetch --depth=1 origin master
|
||||
git -C elink-portal-common reset --hard origin/master
|
||||
git -C elink-portal-common clean -fdx
|
||||
fi
|
||||
|
||||
mkdir -p eapim-online
|
||||
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
|
||||
rm -rf eapim-online/elink-online-core-jpa
|
||||
git clone --depth=1 --branch master \
|
||||
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
|
||||
eapim-online/elink-online-core-jpa
|
||||
else
|
||||
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
|
||||
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
|
||||
git -C eapim-online/elink-online-core-jpa clean -fdx
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify toolchain') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
echo "--- build JDK ---"
|
||||
java -version
|
||||
gradle --version
|
||||
|
||||
echo "--- scanner JDK ---"
|
||||
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
|
||||
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
|
||||
echo "SonarScanner 는 JRE 17 이상을 요구한다. 노드에 설치하거나 JAVA_HOME_SCANNER 를 수정할 것."
|
||||
exit 1
|
||||
fi
|
||||
"$JAVA_HOME_SCANNER/bin/java" -version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
// build/generated 잔여 산출물이 남으면 MapStruct Impl / QueryDSL Q클래스가
|
||||
// 중복 생성되어 컴파일이 깨진다. clean 이 파일 잠금 등으로 실패하는 경우가 있어
|
||||
// 생성 소스 디렉터리는 별도로 먼저 지운다.
|
||||
stage('Compile') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
rm -rf build/generated build/classes
|
||||
gradle clean classes testClasses --no-daemon
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
when { expression { return params.RUN_TESTS } }
|
||||
steps {
|
||||
sh 'gradle test --no-daemon'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sonar.java.libraries 용 클래스패스 덤프.
|
||||
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
|
||||
stage('Export analysis classpath') {
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube analysis') {
|
||||
steps {
|
||||
script {
|
||||
// 스캐너 위치 결정: 지정 이름 > 흔한 등록 이름 후보 > PATH 의 sonar-scanner
|
||||
def candidates = env.SONAR_SCANNER_TOOL?.trim()
|
||||
? [env.SONAR_SCANNER_TOOL.trim()]
|
||||
: ['SonarScanner', 'SonarQube Scanner', 'sonar-scanner', 'SonarScanner CLI', 'sonarqube-scanner']
|
||||
|
||||
def scannerHome = null
|
||||
for (name in candidates) {
|
||||
try {
|
||||
scannerHome = tool name: name, type: 'hudson.plugins.sonar.SonarRunnerInstallation'
|
||||
echo "SonarScanner tool: '${name}' -> ${scannerHome}"
|
||||
break
|
||||
} catch (ignored) {
|
||||
// 등록되지 않은 이름은 건너뛴다
|
||||
}
|
||||
}
|
||||
|
||||
if (scannerHome == null) {
|
||||
echo 'Jenkins Tools 에 등록된 SonarQube Scanner 를 찾지 못했다. PATH 의 sonar-scanner 로 진행한다.'
|
||||
echo '(Manage Jenkins > Tools 에 등록한 뒤 environment 의 SONAR_SCANNER_TOOL 에 그 이름을 넣으면 확실하다.)'
|
||||
}
|
||||
|
||||
withSonarQubeEnv(env.SONAR_ENV_NAME) {
|
||||
withEnv(["SONAR_SCANNER_HOME=${scannerHome ?: ''}"]) {
|
||||
sh '''
|
||||
set -eu
|
||||
|
||||
# 스캐너는 JDK 17 로 실행한다 (빌드용 JDK 8 과 분리)
|
||||
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# 토큰은 커맨드라인(-Dsonar.token)에 노출시키지 않고 환경변수로만 넘긴다.
|
||||
# 플러그인 버전에 따라 SONAR_AUTH_TOKEN 만 주입되는 경우가 있다.
|
||||
if [ -z "${SONAR_TOKEN:-}" ] && [ -n "${SONAR_AUTH_TOKEN:-}" ]; then
|
||||
export SONAR_TOKEN="$SONAR_AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
LIBS=""
|
||||
if [ -f build/sonar/java-libraries.txt ]; then
|
||||
LIBS=$(cat build/sonar/java-libraries.txt)
|
||||
else
|
||||
echo "WARN: build/sonar/java-libraries.txt 없음 - 타입 해석 정확도 저하"
|
||||
fi
|
||||
|
||||
TEST_LIBS=""
|
||||
if [ -f build/sonar/java-test-libraries.txt ]; then
|
||||
TEST_LIBS=$(cat build/sonar/java-test-libraries.txt)
|
||||
fi
|
||||
|
||||
JUNIT_ARG=""
|
||||
if [ -d build/test-results/test ]; then
|
||||
JUNIT_ARG="-Dsonar.junit.reportPaths=build/test-results/test"
|
||||
fi
|
||||
|
||||
# Tools 에 등록된 스캐너가 없으면 PATH 에서 찾는다
|
||||
if [ -n "${SONAR_SCANNER_HOME:-}" ] && [ -x "$SONAR_SCANNER_HOME/bin/sonar-scanner" ]; then
|
||||
SCANNER="$SONAR_SCANNER_HOME/bin/sonar-scanner"
|
||||
elif command -v sonar-scanner >/dev/null 2>&1; then
|
||||
SCANNER="$(command -v sonar-scanner)"
|
||||
else
|
||||
echo "sonar-scanner 실행 파일을 찾지 못했다."
|
||||
echo "Manage Jenkins > Tools > SonarQube Scanner 에 등록하거나 노드 PATH 에 설치할 것."
|
||||
exit 1
|
||||
fi
|
||||
echo "scanner: $SCANNER"
|
||||
|
||||
"$SCANNER" \
|
||||
-Dsonar.projectVersion="${BUILD_NUMBER}" \
|
||||
-Dsonar.java.libraries="$LIBS" \
|
||||
-Dsonar.java.test.libraries="$TEST_LIBS" \
|
||||
$JUNIT_ARG
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
|
||||
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
|
||||
stage('Quality Gate') {
|
||||
steps {
|
||||
script {
|
||||
def qg = null
|
||||
try {
|
||||
timeout(time: 15, unit: 'MINUTES') {
|
||||
qg = waitForQualityGate abortPipeline: false
|
||||
}
|
||||
} catch (err) {
|
||||
unstable("Quality Gate 결과 대기 실패/타임아웃: ${err}")
|
||||
return
|
||||
}
|
||||
|
||||
if (qg == null) {
|
||||
unstable('Quality Gate 결과를 받지 못했다. SonarQube 웹훅 설정을 확인할 것.')
|
||||
} else if (qg.status != 'OK') {
|
||||
unstable("Quality Gate ${qg.status}")
|
||||
} else {
|
||||
echo 'Quality Gate OK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: '.scannerwork/report-task.txt'
|
||||
script {
|
||||
def report = '.scannerwork/report-task.txt'
|
||||
if (fileExists(report)) {
|
||||
def url = readFile(report).readLines().find { it.startsWith('dashboardUrl=') }
|
||||
if (url) {
|
||||
echo "SonarQube: ${url.substring('dashboardUrl='.length())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,24 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 전용 라이브러리 — WebLogic 배포 산출물(war/bootWar)에서 제외한다.
|
||||
// 정보보호 점검에서 Actuator/모니터링 라이브러리가 배포본에 실려 있으면 불필요하게 탐지되므로
|
||||
// 로컬(bootRun/IDE)에서만 classpath 에 오르게 한다. 제외 로직은 아래 war/bootWar 블록.
|
||||
//
|
||||
// ※ Spring Boot 의 developmentOnly 를 쓰지 않는 이유
|
||||
// developmentOnly 는 bootJar/bootWar 에서만 제외된다. 표준 war task 는 runtimeClasspath 를
|
||||
// 그대로 쓰므로 산출물에 실린다 (실측: devtools 가 eapim-portal.war 에 포함되어 있었음).
|
||||
// ※ 별도 configuration(localOnly)을 쓰지 않는 이유
|
||||
// runtimeClasspath 에서 빠지면 IntelliJ 가 모듈 classpath 를 구성할 때도 빠져
|
||||
// IDE 로 기동할 때 Actuator/SBA 가 동작하지 않는다.
|
||||
def localOnlyLibPrefixes = [
|
||||
'spring-boot-devtools',
|
||||
'spring-boot-starter-actuator',
|
||||
'spring-boot-actuator', // spring-boot-actuator, -autoconfigure 모두 매칭
|
||||
'micrometer-', // actuator 전용(runtimeClasspath 상 다른 출처 없음 — 확인함)
|
||||
'spring-boot-admin-',
|
||||
]
|
||||
|
||||
dependencies {
|
||||
annotationProcessor "org.projectlombok:lombok:1.18.28"
|
||||
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final"
|
||||
@@ -45,6 +63,23 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
|
||||
implementation('org.springframework.boot:spring-boot-starter-web')
|
||||
implementation('org.springframework.boot:spring-boot-starter-validation')
|
||||
|
||||
// ↓ 로컬 전용. war/bootWar 산출물에서는 localOnlyLibPrefixes 로 제외된다.
|
||||
// runtimeOnly 인 이유: compileClasspath 에서 빠지므로 자바 코드가 이 API 를 참조하면
|
||||
// 컴파일 단계에서 막힌다. implementation 이면 참조가 컴파일에 통과해버리고,
|
||||
// 배포본(actuator/SBA 제외됨)에서 NoClassDefFoundError 로 터진다.
|
||||
// IntelliJ / bootRun 은 runtimeClasspath 기준이라 로컬 기동에는 정상 포함된다.
|
||||
// 설정(application-local*.yml)으로만 사용한다.
|
||||
runtimeOnly('org.springframework.boot:spring-boot-starter-actuator')
|
||||
// Spring Boot Admin client. 2.7.16 = Spring Boot 2.7.x 대응 마지막 계열(Java 8 호환).
|
||||
runtimeOnly('de.codecentric:spring-boot-admin-starter-client:2.7.16')
|
||||
// 위 runtimeOnly 원칙의 유일한 예외. InterceptorsEndpointConfig 가 actuator API
|
||||
// (@Endpoint / InfoContributor)를 참조해야 하므로 compileClasspath 에만 올린다.
|
||||
// compileOnly 는 runtimeClasspath 에 포함되지 않으므로 war/bootWar 산출물에는 영향이 없다.
|
||||
// 해당 클래스는 @ConditionalOnClass 로 actuator 부재 시 로드되지 않으니
|
||||
// 배포본(actuator 제외)에서 NoClassDefFoundError 가 나지 않는다.
|
||||
compileOnly('org.springframework.boot:spring-boot-actuator')
|
||||
|
||||
implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
|
||||
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
|
||||
|
||||
@@ -129,7 +164,16 @@ bootRun {
|
||||
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
|
||||
|
||||
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
|
||||
args = ["--spring.profiles.active=dev"]
|
||||
|
||||
// 로컬 기동 기본 프로파일.
|
||||
// dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가
|
||||
// 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는
|
||||
// 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰).
|
||||
// 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev
|
||||
// ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어
|
||||
// findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.)
|
||||
def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString()
|
||||
args = ["--spring.profiles.active=" + bootRunProfile]
|
||||
}
|
||||
|
||||
|
||||
@@ -181,9 +225,21 @@ test {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
// 로컬 전용 설정 파일. 배포 산출물(WAR)에 실리면 Actuator/SBA 설정이 그대로 노출되어
|
||||
// 정보보호 점검에 불필요하게 걸린다. processResources 는 건드리지 않는다
|
||||
// (bootRun 이 build/resources/main 을 그대로 쓰므로 로컬 기동이 깨진다).
|
||||
def localOnlyResources = ['**/application-local*.yml']
|
||||
|
||||
// 배포 산출물에서 로컬 전용 라이브러리를 걸러낸다. localOnlyLibPrefixes 는 파일 상단 정의.
|
||||
def excludeLocalOnlyLibs = { org.gradle.api.file.FileCollection cp ->
|
||||
cp.filter { f -> !localOnlyLibPrefixes.any { p -> f.name.startsWith(p) } }
|
||||
}
|
||||
|
||||
bootWar {
|
||||
archiveFileName = "eapim-portal-boot.war"
|
||||
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||
rootSpec.exclude(localOnlyResources)
|
||||
classpath = excludeLocalOnlyLibs(classpath)
|
||||
}
|
||||
|
||||
war {
|
||||
@@ -192,6 +248,8 @@ war {
|
||||
from('src/main/resources/jeus-web-dd.xml') { into 'WEB-INF' }
|
||||
from('src/main/resources/weblogic.xml') { into 'WEB-INF' }
|
||||
|
||||
rootSpec.exclude(localOnlyResources)
|
||||
classpath = excludeLocalOnlyLibs(classpath)
|
||||
}
|
||||
|
||||
task printSourceSets {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SonarScanner 의 sonar.java.libraries / sonar.java.test.libraries 로 넘길
|
||||
// 컴파일 클래스패스를 파일로 덤프한다.
|
||||
//
|
||||
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
|
||||
// gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon
|
||||
//
|
||||
// 출력(콤마 구분, 절대경로):
|
||||
// build/sonar/java-libraries.txt main 컴파일 클래스패스
|
||||
// build/sonar/java-test-libraries.txt main + test 컴파일 클래스패스
|
||||
//
|
||||
// 이 파일이 없으면 Sonar Java 분석기가 타입을 해석하지 못해
|
||||
// 보안 룰(SQL Injection, XSS 등) 상당수가 침묵한다. 선택 사항이 아니다.
|
||||
|
||||
rootProject { project ->
|
||||
project.plugins.withId('java') {
|
||||
project.tasks.register('exportSonarClasspath') {
|
||||
group = 'verification'
|
||||
description = 'SonarScanner 용 컴파일 클래스패스를 build/sonar 에 덤프한다'
|
||||
|
||||
doLast {
|
||||
def outDir = new File(project.layout.buildDirectory.get().asFile, 'sonar')
|
||||
outDir.mkdirs()
|
||||
|
||||
def toLine = { files ->
|
||||
files.findAll { it.exists() }
|
||||
.collect { it.absolutePath }
|
||||
.unique()
|
||||
.join(',')
|
||||
}
|
||||
|
||||
def mainCp = project.sourceSets.main.compileClasspath.files
|
||||
def testCp = project.sourceSets.test.compileClasspath.files
|
||||
|
||||
new File(outDir, 'java-libraries.txt').text = toLine(mainCp)
|
||||
new File(outDir, 'java-test-libraries.txt').text = toLine(mainCp + testCp)
|
||||
|
||||
logger.lifecycle("sonar classpath dumped: ${outDir}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>DJBank API Portal 이메일 인증</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;width:100%;background-color:#eef0f3;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<!-- 미리보기 텍스트 (받은편지함 목록에 노출, 본문에서는 숨김) -->
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#eef0f3;">DJBank 개발자포탈 이메일 인증번호 안내입니다.</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef0f3;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 12px;">
|
||||
|
||||
<!-- ============ 카드 (흰 배경 · 테두리 #dfdfdf · 라운드 20) ============ -->
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #dfdfdf;border-radius:20px;">
|
||||
|
||||
<!-- ===== Header : DJ Bank 로고 + API Portal ===== -->
|
||||
<tr>
|
||||
<td style="padding:26px 27px 0 27px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="20" style="width:20px;font-size:0;line-height:0;"> </td>
|
||||
<td valign="middle" style="line-height:0;">
|
||||
<img src="images/logo-djbank.png" width="144" height="40" alt="DJ Bank" style="display:block;width:144px;height:40px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
<td width="16" style="width:16px;font-size:0;line-height:0;"> </td>
|
||||
<td valign="middle" style="line-height:0;">
|
||||
<img src="images/logo-apiportal.png" width="124" height="21" alt="API Portal" style="display:block;width:124px;height:21px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ===== 본문 : 상단 파란선(#2A69DE) + 그림자 ===== -->
|
||||
<tr>
|
||||
<td style="padding:7px 27px 0 27px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:3px solid #2a69de;box-shadow:0px 4px 12px 0px rgba(85,95,108,0.25);">
|
||||
<tr>
|
||||
<td style="padding:26px 29px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<!-- 제목 (subject) : ##변수## 치환 토큰 -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:15px;line-height:25px;color:#000000;font-weight:600;padding-bottom:20px;">##subject:이메일인증##</td>
|
||||
</tr>
|
||||
|
||||
<!-- 인증번호 박스 (#ECF1FF · 라운드 10) -->
|
||||
<tr>
|
||||
<td style="padding-bottom:20px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#ecf1ff;border-radius:10px;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ecf1ff" style="background-color:#ecf1ff;border-radius:10px;padding:16px 20px;font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:14px;line-height:24px;color:#000000;font-weight:700;text-align:center;letter-spacing:2px;"><!-- 동적값: 인증번호 -->123456</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 안내 문구 -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#000000;padding-bottom:20px;">
|
||||
안녕하세요, <!-- 동적값: 회원명 -->김철수 님.<br>
|
||||
DJBank 개발자포탈 회원가입 시 입력하신 이메일을 인증해 주세요.<br>
|
||||
아래 인증번호를 회원가입 화면에 입력하시면 인증이 완료됩니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 경고 문구 (#FF2727) -->
|
||||
<tr>
|
||||
<td style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;font-size:12px;line-height:25px;color:#ff2727;font-weight:600;">※ 본 메일을 요청하지 않으셨다면 이 메일을 무시해 주시기 바랍니다.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- ===== Footer : 제주은행 로고 + 안내 (#F1F1ED) ===== -->
|
||||
<tr>
|
||||
<td style="padding:7px 27px 26px 27px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f1f1ed" style="background-color:#f1f1ed;border-radius:4px;">
|
||||
<tr>
|
||||
<td style="padding:12px 14px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle" width="86" style="width:86px;padding-right:16px;line-height:0;">
|
||||
<img src="images/logo-jejubank.png" width="86" height="24" alt="제주은행" style="display:block;width:86px;height:24px;border:0;outline:none;-ms-interpolation-mode:bicubic;">
|
||||
</td>
|
||||
<td valign="middle" style="font-family:'Apple SD Gothic Neo','Malgun Gothic','맑은 고딕','Noto Sans KR',sans-serif;color:#0d0e11;">
|
||||
<p style="margin:0;font-size:10px;line-height:20px;color:#0d0e11;">본 메일은 발신 전용으로 회신되지 않습니다.</p>
|
||||
<p style="margin:0 0 9px 0;font-size:10px;line-height:20px;color:#0d0e11;">관련 문의사항은 고객센터 (????-????) 또는 홈페이지를 이용하시기 바랍니다.</p>
|
||||
<p style="margin:0 0 9px 0;font-size:10px;line-height:16px;color:#0d0e11;">제주 제주시 1100로 3351 (노형동)<span style="color:#c2c2bc;"> | </span>고객센터 : ????-?????<span style="color:#c2c2bc;"> | </span>사업자 등록번호 : 616-81-00615</p>
|
||||
<p style="margin:0;font-size:10px;line-height:16px;color:#454545;font-weight:500;">Copyright JEJUBANK. ALL Rights Reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- ============ /카드 ============ -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,112 @@
|
||||
# DJB 커스텀 소스 납품본 수집
|
||||
|
||||
발주사 납품용으로, 기준 시점 이후 **신규로 추가된 커스텀 소스 파일**만 모아
|
||||
디렉터리 구조를 유지한 채 한곳에 복사하고 `MANIFEST.csv` / `SUMMARY.md` / zip 을 만든다.
|
||||
|
||||
## 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `export-custom.sh` | macOS / Linux 실행 스크립트 |
|
||||
| `export-custom.ps1` | Windows PowerShell 실행 스크립트 (실제 로직) |
|
||||
| `export-custom.bat` | Windows cmd 래퍼 — 인자를 `.ps1` 로 그대로 전달 |
|
||||
| `modules.conf` | 대상 모듈 / 소스 경로 / 수집 확장자 |
|
||||
| `export-filter.rules` | 포함·제외 규칙 (`.gitignore` 유사 문법) |
|
||||
| `filelist.txt` | 마지막으로 뽑은 수집 대상 목록 (검토용, 재생성 가능) |
|
||||
| `filelist-no-djb-marker.txt` | 그중 `djb` / `custom` 마커가 **없는** 파일 — 업그레이드 유입분 검토 대상 |
|
||||
|
||||
## 수집 방식
|
||||
|
||||
1. 모듈별로 **컷오프 날짜 직전의 마지막 커밋**을 base 로 잡는다.
|
||||
`git rev-list -1 --before=<CUTOFF> HEAD`
|
||||
컷오프 이전 커밋이 아예 없으면 저장소 전체를 신규로 본다.
|
||||
2. `git diff --name-only --diff-filter=A -M <base> HEAD -- <소스경로>` 로
|
||||
**신규 추가(A)** 파일만 뽑는다. `-M` 이라 단순 rename 은 신규로 잡히지 않는다.
|
||||
3. `modules.conf` 의 확장자 allowlist 로 거른다. (확장자 없는 파일은 제외)
|
||||
4. `export-filter.rules` 로 포함·제외를 최종 결정한다.
|
||||
5. 남은 파일을 `<out>/src/<모듈>/<원래 경로>` 로 복사한다.
|
||||
|
||||
## 사용법
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
./export-custom.sh --dry-run > filelist.txt # 목록만 뽑기 (로그는 stderr)
|
||||
./export-custom.sh --show-excluded # 규칙에 걸려 빠진 목록 + 걸린 규칙
|
||||
./export-custom.sh # 실제 수집
|
||||
./export-custom.sh --zip # 수집 + zip
|
||||
./export-custom.sh -c 2026-05-01 -m eapim-portal
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\export-custom.ps1 -DryRun > filelist.txt
|
||||
.\export-custom.ps1 -ShowExcluded
|
||||
.\export-custom.ps1 -Zip
|
||||
.\export-custom.ps1 -Cutoff 2026-05-01 -Module eapim-portal,eapim-admin
|
||||
|
||||
# cmd 에서
|
||||
export-custom.bat -DryRun > filelist.txt
|
||||
```
|
||||
|
||||
### 주요 옵션
|
||||
|
||||
| sh | ps1 | 설명 |
|
||||
|---|---|---|
|
||||
| `-c, --cutoff` | `-Cutoff` | 기준 날짜 (기본 `2026-05-01`) |
|
||||
| `-r, --root` | `-Root` | 저장소 루트. 미지정 시 스크립트 위치 기준 자동 탐지 |
|
||||
| `-o, --out` | `-Out` | 출력 디렉터리 (기본 `<root>/build/djb-custom-export`) |
|
||||
| `-m, --module` | `-Module` | 특정 모듈만 |
|
||||
| `--zip` | `-Zip` | zip 생성 |
|
||||
| `--include-untracked` | `-IncludeUntracked` | 미커밋 신규 파일도 포함 |
|
||||
| `--dry-run` | `-DryRun` | 복사 없이 목록만 |
|
||||
| `--show-excluded` | `-ShowExcluded` | 규칙에 걸려 빠진 목록 |
|
||||
|
||||
## `export-filter.rules` 문법
|
||||
|
||||
`.gitignore` 와 비슷하되 더 단순하다.
|
||||
|
||||
- 한 줄에 패턴 하나. `#` 주석, 빈 줄 무시
|
||||
- 기본은 **모두 포함**. 패턴에 걸리면 제외
|
||||
- `!` 로 시작하면 예외(다시 포함)
|
||||
- **뒤에 오는 규칙이 앞 규칙을 덮는다** — 순서가 중요하다
|
||||
- 패턴에 `/` 가 있으면 `<모듈>/<경로>` 전체와 매칭, 없으면 파일명만 매칭
|
||||
- `/` 로 끝나면 그 디렉터리 하위 전체
|
||||
- `*` 는 `/` 도 포함해 매칭한다 (단순 glob, `**` 없음)
|
||||
|
||||
매칭 대상 경로 예:
|
||||
|
||||
```
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
|
||||
```
|
||||
|
||||
규칙 파일은 세 구역으로 나뉜다.
|
||||
|
||||
1. **업그레이드 유입분 제외** — `filelist-no-djb-marker.txt` 를 검토하며 채운다
|
||||
2. **`!*djb*` 예외** — djb 마커가 붙은 파일은 커스텀이므로 되살린다
|
||||
3. **무조건 제외** — 빌드 산출물·서드파티. 2)보다 뒤에 있어 최종 승리한다
|
||||
|
||||
## 신규지만 커스텀이 아닌 파일 걸러내기
|
||||
|
||||
신규 파일이라도 제품 업그레이드로 딸려 온 것일 수 있다. 판별 순서:
|
||||
|
||||
```bash
|
||||
# 1) 전체 목록
|
||||
./export-custom.sh --dry-run > filelist.txt
|
||||
|
||||
# 2) djb / custom 마커가 없는 것 = 검토 대상
|
||||
grep -vi djb filelist.txt | grep -v '/custom/' > filelist-no-djb-marker.txt
|
||||
|
||||
# 3) 검토 후 업그레이드 유입분을 export-filter.rules 의 1) 구역에 추가
|
||||
|
||||
# 4) 반영 확인
|
||||
./export-custom.sh --show-excluded
|
||||
```
|
||||
|
||||
## 주의
|
||||
|
||||
- 컷오프 이전 커밋이 없으면 base 가 **빈 트리**가 되어 저장소 전체가 신규로 잡힌다.
|
||||
`SUMMARY.md` 의 base 컬럼에 `(컷오프 이전 커밋 없음 …)` 으로 표시된다.
|
||||
- 모듈에 컷오프 직전 커밋이 드문드문 있으면 base 가 컷오프보다 훨씬 이전으로 잡힐 수 있다.
|
||||
실행 로그의 `base:` 날짜를 항상 확인할 것.
|
||||
- 추가된 뒤 삭제·이동된 파일은 현재 트리에 없으므로 건너뛰고, 건수를 `SUMMARY.md` 에 남긴다.
|
||||
@@ -0,0 +1,29 @@
|
||||
@echo off
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM DJB 커스텀 소스 납품본 수집 (Windows cmd 래퍼)
|
||||
REM
|
||||
REM 실제 로직은 export-custom.ps1 에 있다. 이 배치는 PowerShell 을 띄우고
|
||||
REM 전달받은 인자를 그대로 넘기기만 한다.
|
||||
REM
|
||||
REM 사용 예:
|
||||
REM export-custom.bat
|
||||
REM export-custom.bat -Cutoff 2026-05-01 -Zip
|
||||
REM export-custom.bat -DryRun > filelist.txt
|
||||
REM export-custom.bat -ShowExcluded
|
||||
REM export-custom.bat -Module eapim-portal,eapim-admin
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
|
||||
REM 한글 경로/출력이 깨지지 않도록 코드페이지를 UTF-8 로 전환
|
||||
chcp 65001 >nul
|
||||
|
||||
where powershell >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [오류] PowerShell 을 찾을 수 없습니다. 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0export-custom.ps1" %*
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
endlocal & exit /b %RC%
|
||||
@@ -0,0 +1,278 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DJB 커스텀 소스 납품본 수집 스크립트 (Windows / PowerShell)
|
||||
|
||||
.DESCRIPTION
|
||||
기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
|
||||
모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
|
||||
MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
|
||||
|
||||
.EXAMPLE
|
||||
.\export-custom.ps1
|
||||
.\export-custom.ps1 -Cutoff 2026-05-01 -Zip
|
||||
.\export-custom.ps1 -DryRun
|
||||
.\export-custom.ps1 -Module eapim-portal,eapim-admin
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Cutoff = '2026-05-01',
|
||||
[string] $Root,
|
||||
[string] $Out,
|
||||
[string[]] $Module,
|
||||
[switch] $Zip,
|
||||
[switch] $IncludeUntracked,
|
||||
[switch] $DryRun,
|
||||
[switch] $ShowExcluded
|
||||
)
|
||||
|
||||
if ($ShowExcluded) { $DryRun = $true }
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# git 출력의 한글이 깨지지 않도록 콘솔 인코딩을 UTF-8로 고정한다.
|
||||
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
|
||||
$OutputEncoding = [Text.Encoding]::UTF8
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ConfFile = Join-Path $ScriptDir 'modules.conf'
|
||||
$RulesFile = Join-Path $ScriptDir 'export-filter.rules'
|
||||
|
||||
# 저장소 루트: 스크립트는 <root>\eapim-portal\script\djb-custom-export\ 에 있다.
|
||||
if (-not $Root) { $Root = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path }
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
|
||||
throw "저장소 루트를 찾을 수 없다: $Root"
|
||||
}
|
||||
if (-not $Out) { $Out = Join-Path $Root 'build\djb-custom-export' }
|
||||
if (-not (Test-Path -LiteralPath $ConfFile)) { throw "설정 파일 없음: $ConfFile" }
|
||||
|
||||
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
|
||||
$EmptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
||||
|
||||
# ---- 설정 로드 ------------------------------------------------------------
|
||||
$Modules = @()
|
||||
$AllowExt = @()
|
||||
|
||||
foreach ($line in (Get-Content -LiteralPath $ConfFile -Encoding UTF8)) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||
$idx = $t.IndexOf('|')
|
||||
if ($idx -lt 0) { continue }
|
||||
$key = $t.Substring(0, $idx)
|
||||
$rest = $t.Substring($idx + 1)
|
||||
|
||||
switch ($key) {
|
||||
'MODULE' {
|
||||
$i2 = $rest.IndexOf('|')
|
||||
if ($i2 -lt 0) { continue }
|
||||
$Modules += [pscustomobject]@{
|
||||
Name = $rest.Substring(0, $i2)
|
||||
Paths = $rest.Substring($i2 + 1).Split(' ') | Where-Object { $_ -ne '' }
|
||||
}
|
||||
}
|
||||
'EXT' { $AllowExt = $rest.Split(',') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -ne '' } }
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 포함/제외 규칙 로드 (.gitignore 유사) --------------------------------
|
||||
$Rules = @()
|
||||
if (Test-Path -LiteralPath $RulesFile) {
|
||||
foreach ($line in (Get-Content -LiteralPath $RulesFile -Encoding UTF8)) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||
$Rules += $t
|
||||
}
|
||||
}
|
||||
|
||||
# 마지막으로 매칭된 규칙이 이긴다. 기본 포함, '!' 는 예외(다시 포함).
|
||||
# 반환: @{ Keep = $true/$false; Rule = '<제외시킨 규칙>' }
|
||||
function Test-RuleKeep([string]$RelPath) {
|
||||
$leaf = Split-Path $RelPath -Leaf
|
||||
$keep = $true
|
||||
$matched = ''
|
||||
foreach ($raw in $Rules) {
|
||||
$rule = $raw
|
||||
$neg = $false
|
||||
if ($rule.StartsWith('!')) { $neg = $true; $rule = $rule.Substring(1) }
|
||||
if ($rule.EndsWith('/')) { $rule = $rule + '*' } # 디렉터리 규칙
|
||||
$target = if ($rule.Contains('/')) { $RelPath } else { $leaf }
|
||||
|
||||
if ($target -like $rule) {
|
||||
if ($neg) { $keep = $true; $matched = '' }
|
||||
else { $keep = $false; $matched = $rule }
|
||||
}
|
||||
}
|
||||
return @{ Keep = $keep; Rule = $matched }
|
||||
}
|
||||
|
||||
function Test-AllowedExt([string]$RelPath) {
|
||||
$leaf = Split-Path $RelPath -Leaf
|
||||
if ($leaf -notmatch '\.') { return $false } # 확장자 없는 파일 제외
|
||||
$ext = $leaf.Substring($leaf.LastIndexOf('.') + 1).ToLower()
|
||||
return $AllowExt -contains $ext
|
||||
}
|
||||
|
||||
function Invoke-Git([string]$Dir, [string[]]$GitArgs) {
|
||||
$all = @('-C', $Dir, '-c', 'core.quotepath=false') + $GitArgs
|
||||
$res = & git @all 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { return @() }
|
||||
return @($res)
|
||||
}
|
||||
|
||||
# ---- 준비 -----------------------------------------------------------------
|
||||
$Manifest = Join-Path $Out 'MANIFEST.csv'
|
||||
$Summary = Join-Path $Out 'SUMMARY.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if (Test-Path -LiteralPath $Out) { Remove-Item -LiteralPath $Out -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path (Join-Path $Out 'src') -Force | Out-Null
|
||||
# Excel 한글용 UTF-8 BOM
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[IO.File]::WriteAllText($Manifest, "module,path,ext,added_commit,added_date,author`r`n", $utf8Bom)
|
||||
}
|
||||
|
||||
Write-Host "저장소 루트 : $Root"
|
||||
Write-Host "기준 날짜 : $Cutoff (이 날짜 직전 마지막 커밋이 base)"
|
||||
Write-Host "출력 경로 : $Out"
|
||||
if ($DryRun) { Write-Host '모드 : DRY-RUN (복사 안 함)' }
|
||||
Write-Host ''
|
||||
|
||||
$Rows = New-Object System.Collections.Generic.List[string]
|
||||
$SummaryRs = New-Object System.Collections.Generic.List[object]
|
||||
$Total = 0
|
||||
$Missing = 0
|
||||
$Excluded = 0
|
||||
|
||||
# ---- 모듈 순회 ------------------------------------------------------------
|
||||
foreach ($m in $Modules) {
|
||||
if ($Module -and ($Module -notcontains $m.Name)) { continue }
|
||||
|
||||
$modDir = Join-Path $Root $m.Name
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $modDir '.git'))) {
|
||||
Write-Host "[건너뜀] $($m.Name) — git 저장소 아님 ($modDir)"
|
||||
continue
|
||||
}
|
||||
|
||||
# base 커밋 결정
|
||||
$base = (Invoke-Git $modDir @('rev-list', '-1', "--before=$Cutoff", 'HEAD') | Select-Object -First 1)
|
||||
if ([string]::IsNullOrWhiteSpace($base)) {
|
||||
$base = $EmptyTree
|
||||
$baseDesc = '(컷오프 이전 커밋 없음 → 전체를 신규로 간주)'
|
||||
} else {
|
||||
$d = (Invoke-Git $modDir @('log', '-1', '--format=%h %ad %s', '--date=short', $base) | Select-Object -First 1)
|
||||
$baseDesc = if ($d.Length -gt 80) { $d.Substring(0, 80) } else { $d }
|
||||
}
|
||||
|
||||
# 실제 존재하는 소스 경로만 pathspec 으로 사용
|
||||
$spec = @()
|
||||
foreach ($p in $m.Paths) {
|
||||
if (Test-Path -LiteralPath (Join-Path $modDir ($p -replace '/', '\')) -PathType Container) { $spec += $p }
|
||||
}
|
||||
if ($spec.Count -eq 0) {
|
||||
Write-Host "[건너뜀] $($m.Name) — 설정된 소스 경로가 존재하지 않음"
|
||||
continue
|
||||
}
|
||||
|
||||
# 신규(Added) 파일만. -M 으로 rename 은 신규에서 제외한다.
|
||||
$files = @(Invoke-Git $modDir (@('diff', '--name-only', '--diff-filter=A', '-M', $base, 'HEAD', '--') + $spec))
|
||||
|
||||
if ($IncludeUntracked) {
|
||||
$files += @(Invoke-Git $modDir (@('ls-files', '--others', '--exclude-standard', '--') + $spec))
|
||||
}
|
||||
|
||||
$files = $files | Where-Object { $_ -ne '' } | Sort-Object -Unique
|
||||
|
||||
$count = 0
|
||||
$miss = 0
|
||||
foreach ($f in $files) {
|
||||
if (-not (Test-AllowedExt $f)) { continue }
|
||||
|
||||
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
|
||||
$rk = Test-RuleKeep "$($m.Name)/$f"
|
||||
if (-not $rk.Keep) {
|
||||
$Excluded++
|
||||
if ($ShowExcluded) { Write-Output "$($m.Name)/$f`t# $($rk.Rule)" }
|
||||
continue
|
||||
}
|
||||
if ($ShowExcluded) { continue }
|
||||
|
||||
$src = Join-Path $modDir ($f -replace '/', '\')
|
||||
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
|
||||
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
|
||||
Write-Warning " [없음] $($m.Name)/$f"
|
||||
$miss++
|
||||
continue
|
||||
}
|
||||
|
||||
$count++
|
||||
# 목록은 Write-Output(성공 스트림) 으로 — 리다이렉트하면 목록만 파일로 떨어진다.
|
||||
if ($DryRun) { Write-Output "$($m.Name)/$f"; continue }
|
||||
|
||||
$dst = Join-Path (Join-Path $Out 'src') (Join-Path $m.Name ($f -replace '/', '\'))
|
||||
$dstDir = Split-Path -Parent $dst
|
||||
if (-not (Test-Path -LiteralPath $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
|
||||
$meta = (Invoke-Git $modDir @('log', '-1', '--diff-filter=A', '--format=%h|%ad|%an', '--date=short', '--', $f) | Select-Object -First 1)
|
||||
$cHash = ''; $cDate = ''; $cAuth = ''
|
||||
if ($meta) {
|
||||
$parts = $meta.Split('|')
|
||||
if ($parts.Count -ge 3) { $cHash = $parts[0]; $cDate = $parts[1]; $cAuth = $parts[2] }
|
||||
}
|
||||
$leaf = Split-Path $f -Leaf
|
||||
$ext = if ($leaf -match '\.') { $leaf.Substring($leaf.LastIndexOf('.') + 1) } else { '' }
|
||||
|
||||
$esc = { param($s) '"' + ($s -replace '"', '""') + '"' }
|
||||
$Rows.Add((@(
|
||||
(& $esc $m.Name), (& $esc $f), (& $esc $ext),
|
||||
(& $esc $cHash), (& $esc $cDate), (& $esc $cAuth)
|
||||
) -join ','))
|
||||
}
|
||||
|
||||
$Total += $count
|
||||
$Missing += $miss
|
||||
$SummaryRs.Add([pscustomobject]@{ Module = $m.Name; Count = $count; Base = $baseDesc })
|
||||
Write-Host ("[수집] {0,-22} {1,5} 개 base: {2}" -f $m.Name, $count, $baseDesc)
|
||||
}
|
||||
|
||||
# ---- 출력 ------------------------------------------------------------------
|
||||
if (-not $DryRun) {
|
||||
if ($Rows.Count -gt 0) {
|
||||
[IO.File]::AppendAllText($Manifest, (($Rows -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.AppendLine('# DJB 커스텀 소스 납품본')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine("- 생성 일시: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||
[void]$sb.AppendLine("- 기준 날짜: ``$Cutoff`` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)")
|
||||
[void]$sb.AppendLine('- 수집 규칙: `git diff --diff-filter=A -M <base> HEAD` / 확장자 allowlist / `export-filter.rules` 필터')
|
||||
[void]$sb.AppendLine("- 대상 확장자: ``$($AllowExt -join ',')``")
|
||||
[void]$sb.AppendLine("- 규칙 파일: ``$(Split-Path $RulesFile -Leaf)`` ($($Rules.Count) 개 규칙, 제외 $Excluded 건)")
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## 모듈별 수집 결과')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('| 모듈 | 파일 수 | base 커밋 |')
|
||||
[void]$sb.AppendLine('|---|---:|---|')
|
||||
foreach ($r in $SummaryRs) { [void]$sb.AppendLine("| $($r.Module) | $($r.Count) | $($r.Base) |") }
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine("**합계: $Total 개**")
|
||||
if ($Missing -gt 0) { [void]$sb.AppendLine("> 추가 후 삭제/이동되어 현재 트리에 없는 파일 $Missing 개는 제외됨.") }
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('## 파일 목록')
|
||||
[void]$sb.AppendLine('')
|
||||
[void]$sb.AppendLine('`MANIFEST.csv` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자).')
|
||||
|
||||
[IO.File]::WriteAllText($Summary, $sb.ToString(), (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "MANIFEST : $Manifest"
|
||||
Write-Host "SUMMARY : $Summary"
|
||||
|
||||
if ($Zip) {
|
||||
$zipName = "djb-custom-export-$(Get-Date -Format 'yyyyMMdd').zip"
|
||||
$zipPath = Join-Path (Split-Path -Parent $Out) $zipName
|
||||
if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
|
||||
Compress-Archive -Path (Join-Path $Out '*') -DestinationPath $zipPath
|
||||
Write-Host "ZIP : $zipPath"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# DJB 커스텀 소스 납품본 수집 스크립트 (macOS / Linux)
|
||||
#
|
||||
# 기준 커밋(컷오프 직전 마지막 커밋) 이후 "신규로 추가된" 소스 파일만 모아
|
||||
# 모듈별 디렉터리 구조를 유지한 채 출력 디렉터리에 복사하고,
|
||||
# MANIFEST.csv / SUMMARY.md / (선택) zip 아카이브를 생성한다.
|
||||
#
|
||||
# 사용법:
|
||||
# ./export-custom.sh # 기본값으로 실행
|
||||
# ./export-custom.sh -c 2026-05-01 --zip
|
||||
# ./export-custom.sh --dry-run
|
||||
# ./export-custom.sh -m eapim-portal -m eapim-admin
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONF_FILE="$SCRIPT_DIR/modules.conf"
|
||||
RULES_FILE="$SCRIPT_DIR/export-filter.rules"
|
||||
|
||||
# ---- 기본값 ---------------------------------------------------------------
|
||||
CUTOFF="2026-05-01"
|
||||
REPO_ROOT=""
|
||||
OUT_DIR=""
|
||||
MAKE_ZIP=0
|
||||
DRY_RUN=0
|
||||
INCLUDE_UNTRACKED=0
|
||||
ONLY_MODULES=""
|
||||
SHOW_EXCLUDED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
DJB 커스텀 소스 납품본 수집
|
||||
|
||||
옵션:
|
||||
-c, --cutoff <YYYY-MM-DD> 기준 날짜. 이 날짜 직전 마지막 커밋을 base로 삼는다.
|
||||
(기본: 2026-05-01)
|
||||
-r, --root <DIR> 저장소 루트(djb-eapim). 미지정 시 스크립트 위치에서 자동 탐지.
|
||||
-o, --out <DIR> 출력 디렉터리. (기본: <root>/build/djb-custom-export)
|
||||
-m, --module <NAME> 특정 모듈만 수집. 여러 번 지정 가능.
|
||||
--zip 수집 후 zip 아카이브 생성.
|
||||
--include-untracked git에 아직 커밋되지 않은 신규 파일도 포함.
|
||||
--dry-run 복사하지 않고 대상 목록만 stdout 으로 출력(로그는 stderr).
|
||||
--show-excluded --dry-run 과 함께. 규칙에 걸려 제외된 목록을 대신 출력.
|
||||
-h, --help 도움말.
|
||||
|
||||
포함/제외 규칙은 export-filter.rules (.gitignore 유사 문법) 에서 관리한다.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-c|--cutoff) CUTOFF="$2"; shift 2 ;;
|
||||
-r|--root) REPO_ROOT="$2"; shift 2 ;;
|
||||
-o|--out) OUT_DIR="$2"; shift 2 ;;
|
||||
-m|--module) ONLY_MODULES="$ONLY_MODULES $2"; shift 2 ;;
|
||||
--zip) MAKE_ZIP=1; shift ;;
|
||||
--include-untracked) INCLUDE_UNTRACKED=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--show-excluded) SHOW_EXCLUDED=1; DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 저장소 루트 탐지 -----------------------------------------------------
|
||||
# 스크립트는 <root>/eapim-portal/script/djb-custom-export/ 에 있다.
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
fi
|
||||
if [ ! -d "$REPO_ROOT" ]; then
|
||||
echo "저장소 루트를 찾을 수 없다: $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$OUT_DIR" ] && OUT_DIR="$REPO_ROOT/build/djb-custom-export"
|
||||
|
||||
if [ ! -f "$CONF_FILE" ]; then
|
||||
echo "설정 파일 없음: $CONF_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 설정 로드 ------------------------------------------------------------
|
||||
ALLOW_EXT=""
|
||||
EXCLUDE_PAT=""
|
||||
MODULE_LINES=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
key="${line%%|*}"
|
||||
rest="${line#*|}"
|
||||
case "$key" in
|
||||
MODULE) MODULE_LINES="$MODULE_LINES
|
||||
$rest" ;;
|
||||
EXT) ALLOW_EXT="$rest" ;;
|
||||
EXCLUDE) EXCLUDE_PAT="$rest" ;;
|
||||
esac
|
||||
done < "$CONF_FILE"
|
||||
|
||||
# 빈 트리 해시 — base 커밋이 없을 때(= 모든 이력이 컷오프 이후) 사용한다.
|
||||
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
||||
|
||||
is_allowed_ext() {
|
||||
_f="$1"
|
||||
_base="${_f##*/}"
|
||||
case "$_base" in
|
||||
*.*) _ext="${_base##*.}" ;;
|
||||
*) return 1 ;; # 확장자 없는 파일 제외
|
||||
esac
|
||||
# 소문자 변환 (bash 3.2 호환)
|
||||
_ext="$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')"
|
||||
case ",$ALLOW_EXT," in
|
||||
*",$_ext,"*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- 포함/제외 규칙 (.gitignore 유사) --------------------------------------
|
||||
RULES=()
|
||||
if [ -f "$RULES_FILE" ]; then
|
||||
while IFS= read -r r || [ -n "$r" ]; do
|
||||
# 앞뒤 공백 제거
|
||||
r="${r#"${r%%[![:space:]]*}"}"
|
||||
r="${r%"${r##*[![:space:]]}"}"
|
||||
case "$r" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
RULES[${#RULES[@]}]="$r"
|
||||
done < "$RULES_FILE"
|
||||
fi
|
||||
|
||||
# 경로 하나에 규칙을 순서대로 적용한다. 마지막으로 매칭된 규칙이 이긴다.
|
||||
# 기본은 포함(0), '!' 규칙은 다시 포함, 일반 규칙은 제외(1).
|
||||
# 반환: 0 = 포함, 1 = 제외. 제외시킨 규칙은 MATCHED_RULE 에 담긴다.
|
||||
MATCHED_RULE=""
|
||||
rule_keep() {
|
||||
_path="$1"
|
||||
_leaf="${_path##*/}"
|
||||
_keep=0
|
||||
MATCHED_RULE=""
|
||||
_i=0
|
||||
while [ $_i -lt ${#RULES[@]} ]; do
|
||||
_rule="${RULES[$_i]}"
|
||||
_i=$((_i + 1))
|
||||
|
||||
_neg=0
|
||||
case "$_rule" in
|
||||
'!'*) _neg=1; _rule="${_rule#!}" ;;
|
||||
esac
|
||||
# 디렉터리 규칙: 하위 전체
|
||||
case "$_rule" in
|
||||
*/) _rule="${_rule}*" ;;
|
||||
esac
|
||||
# '/' 가 있으면 전체 경로, 없으면 파일명과 매칭
|
||||
case "$_rule" in
|
||||
*/*) _target="$_path" ;;
|
||||
*) _target="$_leaf" ;;
|
||||
esac
|
||||
|
||||
# shellcheck disable=SC2254 # 의도적으로 unquoted — glob 패턴으로 쓴다
|
||||
case "$_target" in
|
||||
$_rule)
|
||||
if [ $_neg -eq 1 ]; then
|
||||
_keep=0; MATCHED_RULE=""
|
||||
else
|
||||
_keep=1; MATCHED_RULE="$_rule"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return $_keep
|
||||
}
|
||||
|
||||
csv_escape() {
|
||||
printf '"%s"' "$(printf '%s' "$1" | sed 's/"/""/g')"
|
||||
}
|
||||
|
||||
# ---- 준비 -----------------------------------------------------------------
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR/src"
|
||||
fi
|
||||
|
||||
MANIFEST="$OUT_DIR/MANIFEST.csv"
|
||||
SUMMARY="$OUT_DIR/SUMMARY.md"
|
||||
TMP_SUMMARY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_SUMMARY"' EXIT
|
||||
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
printf '\357\273\277' > "$MANIFEST" # Excel 한글용 UTF-8 BOM
|
||||
echo 'module,path,ext,added_commit,added_date,author' >> "$MANIFEST"
|
||||
fi
|
||||
|
||||
# dry-run(목록 뽑기) 모드에서는 파일 목록만 stdout으로 내보내고
|
||||
# 진행 로그는 전부 stderr로 보낸다. 그래야 파이프/리다이렉트로 목록을 바로 가공할 수 있다.
|
||||
if [ "$DRY_RUN" -eq 1 ]; then LOG=2; else LOG=1; fi
|
||||
|
||||
{
|
||||
echo "저장소 루트 : $REPO_ROOT"
|
||||
echo "기준 날짜 : $CUTOFF (이 날짜 직전 마지막 커밋이 base)"
|
||||
echo "출력 경로 : $OUT_DIR"
|
||||
[ "$DRY_RUN" -eq 1 ] && echo "모드 : DRY-RUN (목록만 출력, 복사 안 함)"
|
||||
echo
|
||||
} >&$LOG
|
||||
|
||||
TOTAL=0
|
||||
MISSING=0
|
||||
EXCLUDED=0
|
||||
|
||||
# ---- 모듈 순회 ------------------------------------------------------------
|
||||
# 파이프 대신 process substitution 을 써서 집계 변수가 서브셸에 갇히지 않게 한다.
|
||||
while IFS= read -r mline; do
|
||||
[ -z "$mline" ] && continue
|
||||
MOD="${mline%%|*}"
|
||||
PATHS="${mline#*|}"
|
||||
|
||||
if [ -n "$ONLY_MODULES" ]; then
|
||||
case " $ONLY_MODULES " in
|
||||
*" $MOD "*) : ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
MOD_DIR="$REPO_ROOT/$MOD"
|
||||
if [ ! -d "$MOD_DIR/.git" ]; then
|
||||
echo "[건너뜀] $MOD — git 저장소 아님 ($MOD_DIR)" >&$LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# base 커밋 결정
|
||||
BASE="$(git -C "$MOD_DIR" -c core.quotepath=false rev-list -1 --before="$CUTOFF" HEAD 2>/dev/null || true)"
|
||||
if [ -z "$BASE" ]; then
|
||||
BASE="$EMPTY_TREE"
|
||||
BASE_DESC="(컷오프 이전 커밋 없음 → 전체를 신규로 간주)"
|
||||
else
|
||||
BASE_DESC="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --format='%h %ad %s' --date=short "$BASE" | cut -c1-80)"
|
||||
fi
|
||||
|
||||
# 실제 존재하는 소스 경로만 pathspec으로 사용
|
||||
SPEC=""
|
||||
for p in $PATHS; do
|
||||
[ -d "$MOD_DIR/$p" ] && SPEC="$SPEC $p"
|
||||
done
|
||||
if [ -z "$SPEC" ]; then
|
||||
echo "[건너뜀] $MOD — 설정된 소스 경로가 존재하지 않음" >&$LOG
|
||||
continue
|
||||
fi
|
||||
|
||||
# 신규(Added) 파일만. -M 으로 rename은 신규에서 제외한다.
|
||||
FILES="$(git -C "$MOD_DIR" -c core.quotepath=false diff --name-only --diff-filter=A -M "$BASE" HEAD -- $SPEC 2>/dev/null || true)"
|
||||
|
||||
if [ "$INCLUDE_UNTRACKED" -eq 1 ]; then
|
||||
UNTRACKED="$(git -C "$MOD_DIR" -c core.quotepath=false ls-files --others --exclude-standard -- $SPEC 2>/dev/null || true)"
|
||||
FILES="$FILES
|
||||
$UNTRACKED"
|
||||
fi
|
||||
|
||||
COUNT=0
|
||||
MISS=0
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
is_allowed_ext "$f" || continue
|
||||
|
||||
# 규칙 매칭은 <모듈>/<경로> 전체 문자열 기준
|
||||
if ! rule_keep "$MOD/$f"; then
|
||||
EXCLUDED=$((EXCLUDED + 1))
|
||||
[ "$SHOW_EXCLUDED" -eq 1 ] && printf '%s\t# %s\n' "$MOD/$f" "$MATCHED_RULE"
|
||||
continue
|
||||
fi
|
||||
[ "$SHOW_EXCLUDED" -eq 1 ] && continue
|
||||
|
||||
SRC="$MOD_DIR/$f"
|
||||
if [ ! -f "$SRC" ]; then
|
||||
# 추가된 뒤 삭제/이동된 파일 — 납품 대상 아님
|
||||
echo " [없음] $MOD/$f" >&2
|
||||
MISS=$((MISS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "$MOD/$f" # 목록은 stdout으로 (가공하기 쉽게 접두 공백 없음)
|
||||
continue
|
||||
fi
|
||||
|
||||
DST="$OUT_DIR/src/$MOD/$f"
|
||||
mkdir -p "$(dirname "$DST")"
|
||||
cp -p "$SRC" "$DST"
|
||||
|
||||
META="$(git -C "$MOD_DIR" -c core.quotepath=false log -1 --diff-filter=A --format='%h|%ad|%an' --date=short -- "$f" 2>/dev/null || true)"
|
||||
C_HASH="${META%%|*}"; _r="${META#*|}"
|
||||
C_DATE="${_r%%|*}"
|
||||
C_AUTH="${_r#*|}"
|
||||
BASE_F="${f##*/}"
|
||||
case "$BASE_F" in *.*) EXT="${BASE_F##*.}" ;; *) EXT="" ;; esac
|
||||
|
||||
{
|
||||
csv_escape "$MOD"; printf ','
|
||||
csv_escape "$f"; printf ','
|
||||
csv_escape "$EXT"; printf ','
|
||||
csv_escape "$C_HASH"; printf ','
|
||||
csv_escape "$C_DATE"; printf ','
|
||||
csv_escape "$C_AUTH"; printf '\n'
|
||||
} >> "$MANIFEST"
|
||||
done < <(printf '%s\n' "$FILES" | sort -u)
|
||||
|
||||
TOTAL=$((TOTAL + COUNT))
|
||||
MISSING=$((MISSING + MISS))
|
||||
printf '%s|%s|%s\n' "$MOD" "$COUNT" "$BASE_DESC" >> "$TMP_SUMMARY"
|
||||
printf '[수집] %-22s %5s 개 base: %s\n' "$MOD" "$COUNT" "$BASE_DESC" >&$LOG
|
||||
done < <(printf '%s\n' "$MODULE_LINES")
|
||||
|
||||
# ---- 요약 ------------------------------------------------------------------
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
{
|
||||
echo "# DJB 커스텀 소스 납품본"
|
||||
echo
|
||||
echo "- 생성 일시: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "- 기준 날짜: \`$CUTOFF\` (이 날짜 직전 마지막 커밋을 base로 삼아, 이후 **신규 추가된** 파일만 수집)"
|
||||
echo "- 수집 규칙: \`git diff --diff-filter=A -M <base> HEAD\` / 확장자 allowlist / \`export-filter.rules\` 필터"
|
||||
echo "- 대상 확장자: \`$ALLOW_EXT\`"
|
||||
echo "- 규칙 파일: \`$(basename "$RULES_FILE")\` (${#RULES[@]} 개 규칙, 제외 ${EXCLUDED} 건)"
|
||||
echo
|
||||
echo "## 모듈별 수집 결과"
|
||||
echo
|
||||
echo "| 모듈 | 파일 수 | base 커밋 |"
|
||||
echo "|---|---:|---|"
|
||||
while IFS='|' read -r m n d; do
|
||||
[ -z "$m" ] && continue
|
||||
echo "| $m | $n | $d |"
|
||||
done < "$TMP_SUMMARY"
|
||||
echo
|
||||
echo "**합계: ${TOTAL} 개**"
|
||||
[ "$MISSING" -gt 0 ] && echo "> 추가 후 삭제/이동되어 현재 트리에 없는 파일 ${MISSING} 개는 제외됨."
|
||||
echo
|
||||
echo "## 파일 목록"
|
||||
echo
|
||||
echo "\`MANIFEST.csv\` 참조 (module, path, ext, 최초 추가 커밋/일자/작성자)."
|
||||
} > "$SUMMARY"
|
||||
|
||||
echo
|
||||
echo "MANIFEST : $MANIFEST"
|
||||
echo "SUMMARY : $SUMMARY"
|
||||
|
||||
if [ "$MAKE_ZIP" -eq 1 ]; then
|
||||
ZIP_NAME="djb-custom-export-$(date '+%Y%m%d').zip"
|
||||
ZIP_PATH="$(dirname "$OUT_DIR")/$ZIP_NAME"
|
||||
rm -f "$ZIP_PATH"
|
||||
( cd "$OUT_DIR" && zip -qr "$ZIP_PATH" . )
|
||||
echo "ZIP : $ZIP_PATH"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
# DJB 커스텀 소스 납품 - 포함/제외 규칙
|
||||
#
|
||||
# .gitignore 와 비슷하게 동작한다.
|
||||
# - 한 줄에 패턴 하나. '#' 주석, 빈 줄 무시.
|
||||
# - 기본은 "모두 포함". 패턴에 걸리면 제외된다.
|
||||
# - '!' 로 시작하면 예외(다시 포함).
|
||||
# - 뒤에 오는 규칙이 앞 규칙을 덮는다 (마지막 매칭 승리). → 순서가 중요하다.
|
||||
# - 패턴에 '/' 가 있으면 <모듈>/<경로> 전체와 glob 매칭,
|
||||
# 없으면 파일명(basename)과만 매칭한다.
|
||||
# - '/' 로 끝나면 그 디렉터리 하위 전체를 뜻한다.
|
||||
# - '*' 는 '/' 도 포함해 매칭한다(단순 glob).
|
||||
#
|
||||
# 매칭 대상 경로 예:
|
||||
# eapim-portal/src/main/java/com/eactive/apim/portal/djb/webhook/WebhookService.java
|
||||
# eapim-admin/WebContent/jsp/onl/apim/webhook/webhookList.jsp
|
||||
#
|
||||
# 확인: ./export-custom.sh --dry-run (남는 목록)
|
||||
# ./export-custom.sh --dry-run --show-excluded (규칙에 걸려 빠진 목록)
|
||||
|
||||
# ===========================================================================
|
||||
# 1) 제품 업그레이드로 유입된 신규 파일 — 커스텀 아니므로 제외
|
||||
# filelist-no-djb-marker.txt(djb/custom 마커 없는 신규 파일)를 검토하며 채운다.
|
||||
# ===========================================================================
|
||||
# 예시)
|
||||
# eapim-online/src/main/java/com/eactive/eai/agent/inflow/
|
||||
# eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/
|
||||
|
||||
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/
|
||||
eapim-portal/src/test/
|
||||
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2) 위 제외에 걸렸더라도 되살릴 예외
|
||||
# djb 마커가 붙은 파일은 어떤 경우에도 커스텀이다.
|
||||
# ※ 아래 3)의 빌드 산출물 제외보다 반드시 앞에 와야 한다.
|
||||
# ===========================================================================
|
||||
!*djb*
|
||||
!*DJB*
|
||||
!*Djb*
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3) 무조건 제외 (최종 승리) — 소스가 아니거나 서드파티
|
||||
# ===========================================================================
|
||||
*/build/
|
||||
*/out/
|
||||
*/bin/
|
||||
*/.gradle/
|
||||
*/.metadata/
|
||||
*/node_modules/
|
||||
*/generated/
|
||||
*/design-backup/
|
||||
*/WebContent/plugins/
|
||||
|
||||
# SafeDB(damo) 벤더 API stub — 컴파일용 껍데기라 납품 대상 아님
|
||||
damo-manager/src/stub/
|
||||
|
||||
# 서드파티 폴더 안에 섞여 있는 우리 커스텀 파일은 되살린다
|
||||
# (예: WebContent/plugins/swaggerUI/djb-swagger-i18n.js)
|
||||
!*/WebContent/plugins/*djb*
|
||||
@@ -0,0 +1,157 @@
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDay.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsDayId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHour.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsHourId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonth.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/ApiStatsMonthId.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwApiStatus.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwAuthClient.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/entity/GwSvcInfo.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsDayRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsHourRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/ApiStatsMonthRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwApiStatusRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwAuthClientRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/gateway/data/statistics/repository/GwSvcInfoRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/AuthNoticeProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorChannel.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorInfoResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorSendResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/dto/TwoFactorVerifyResponse.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpAuthInterceptor.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpProtectedPaths.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorCleanupScheduler.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorContext.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorProperties.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/TwoFactorService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/notice/dto/IncidentAffectedApiDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/community/partnership/dto/PartnershipApplicationSummaryDTO.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginFailureReason.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/constants/LoginType.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/controller/DuplicateLoginController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/DuplicateLoginService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/login/service/LoginFinalizer.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/ReadinessController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/controller/SessionApiController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/entity/UserSession.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/filter/SessionValidationFilter.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/repository/UserSessionRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/dto/ApiStatisticsPeriodDto.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/entity/JobInfo.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/statistics/repository/JobInfoRepository.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/apps/user/service/UserRoleHistoryService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/ClientGuardService.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/common/security/LoginLockPolicy.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordChangeEnforcementInterceptor.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PasswordEnforcementPolicy.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/PortalPropertyDuplicateChecker.java
|
||||
eapim-portal/src/main/java/com/eactive/apim/portal/config/StartupInfoPrinter.java
|
||||
eapim-portal/src/main/resources/menu.yml
|
||||
eapim-portal/src/main/resources/roles.yml
|
||||
eapim-portal/src/main/resources/static/js/api-selector.js
|
||||
eapim-portal/src/main/resources/static/js/password-policy.js
|
||||
eapim-portal/src/main/resources/static/js/popup/two-factor-auth.js
|
||||
eapim-portal/src/main/resources/static/sass/components/_board-common.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_password-policy.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_session-timer.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_toast.scss
|
||||
eapim-portal/src/main/resources/static/sass/components/_two-factor.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_api-status.scss
|
||||
eapim-portal/src/main/resources/static/sass/pages/_webhook.scss
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/stepupPassword.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/auth/twoFactorChallenge.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/community/mainFaqDetail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/register/signupVerificationEmail.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/oauth2-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookEmpty.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookList.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookModifyStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep2.html
|
||||
eapim-portal/src/main/resources/templates/views/apps/webhook/webhookRegisterStep3.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/api_selector.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/cancelInvitationPopup.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html
|
||||
eapim-portal/src/main/resources/templates/views/fragment/popup/twoFactorAuthPopup.html
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/PhoneNumberUtilTest.java
|
||||
eapim-portal/src/test/java/com/eactive/apim/portal/common/util/StringMaskingUtilTest.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/SitemapServiceImpl.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/common/acl/sitemap/ui/SitemapNode.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestEmsRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/messagerequest/MessageRequestUISearch.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/portalinquiry/PortalInquiryCommentService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigDataServiceImpl.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/data/entity/onl/security/CryptoModuleConfigRepository.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/authserver/ClientBlockApiController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtils.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryClosingService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalInquiry/PortalInquiryCommentUIMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuUIMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentAffectedApiUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManController.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigManService.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUI.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/crypto/CryptoModuleConfigUiMapper.java
|
||||
eapim-admin/src/main/java/com/eactive/eai/rms/onl/manage/inflow/inflow/InflowClientControlManController.java
|
||||
eapim-admin/src/main/resources/apistatus-draft.yml
|
||||
eapim-admin/src/main/resources/logback-rinjae_ma.xml
|
||||
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MaskingUtilsTest.java
|
||||
eapim-admin/src/test/java/com/eactive/eai/rms/onl/apim/masking/MessagePatternMaskingUtilsTest.java
|
||||
eapim-admin/WebContent/damo.jsp
|
||||
eapim-admin/WebContent/jsp/common/acl/sitemap/sitemapMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/inflow/inflowClientControlManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/rule/transform2/transform2ManApiPopup.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/admin/security/cryptoModuleManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/messagerequest/messageRequestManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogMan.jsp
|
||||
eapim-admin/WebContent/jsp/onl/apim/webhook/webhookSendLogManDetail.jsp
|
||||
eapim-admin/WebContent/jsp/onl/kjb/statistics/apiUseStatsMan.jsp
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/apprequest/entity/GwAction.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/IpAddressMatcher.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/common/util/PhoneNumberUtil.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/PersonalDataEncryptConverter.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/jpa/UuidV7Generator.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuItem.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalMenuPlacement.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRole.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthority.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/entity/PortalRoleAuthorityId.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuItemRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalMenuPlacementRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleAuthorityRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/repository/PortalRoleRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/menu/service/PortalMenuDataService.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/entity/UserRoleHistory.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/portaluser/repository/UserRoleHistoryRepository.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/InquiryComment.java
|
||||
elink-portal-common/src/main/java/com/eactive/apim/portal/qna/entity/VisibilityScope.java
|
||||
elink-portal-common/src/test/java/com/eactive/apim/portal/common/util/IpAddressMatcherTest.java
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
# SonarQube 분석 설정 (SonarScanner CLI 용)
|
||||
#
|
||||
# Jenkinsfile.sonar 가 이 파일을 그대로 읽고, 아래 항목만 실행 시점에 -D 로 덮어쓴다.
|
||||
# sonar.projectVersion : 빌드 번호
|
||||
# sonar.java.libraries : ci/sonar-classpath.gradle 이 덤프한 컴파일 클래스패스
|
||||
# sonar.java.test.libraries : 위 + 테스트 클래스패스
|
||||
# sonar.junit.reportPaths : 테스트를 실행한 경우에만
|
||||
#
|
||||
# 로컬에서 수동 분석 시(스캐너는 JDK 17 필요):
|
||||
# gradle clean classes testClasses
|
||||
# gradle -I ci/sonar-classpath.gradle exportSonarClasspath
|
||||
# sonar-scanner -Dsonar.host.url=... -Dsonar.token=... \
|
||||
# -Dsonar.java.libraries="$(cat build/sonar/java-libraries.txt)"
|
||||
|
||||
sonar.projectKey=djb-eapim-portal
|
||||
sonar.projectName=DJB eAPIM Portal
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# --- 분석 대상 -------------------------------------------------------------
|
||||
# src/main/resources 를 포함시켜 Thymeleaf 템플릿(XSS), yml/properties(하드코딩 시크릿)까지 검사한다.
|
||||
sonar.sources=src/main/java,src/main/resources
|
||||
sonar.tests=src/test/java
|
||||
|
||||
# --- Java ------------------------------------------------------------------
|
||||
# 소스는 Java 8. 스캐너 실행 JRE 는 별개로 17 이상이어야 한다(Jenkinsfile.sonar 참고).
|
||||
sonar.java.source=8
|
||||
sonar.java.binaries=build/classes/java/main
|
||||
sonar.java.test.binaries=build/classes/java/test
|
||||
|
||||
# --- 제외 대상 -------------------------------------------------------------
|
||||
# 서드파티 번들/컴파일 산출물/바이너리. 분석해도 우리가 고칠 수 없는 코드는 뺀다.
|
||||
sonar.exclusions=\
|
||||
**/build/**,\
|
||||
**/node_modules/**,\
|
||||
**/lib/**,\
|
||||
**/libs/**,\
|
||||
**/local_jars/**,\
|
||||
src/main/resources/static/plugins/**,\
|
||||
src/main/resources/static/js/egovframework/**,\
|
||||
src/main/resources/static/js/**/*.min.js,\
|
||||
src/main/resources/static/js/lodash.js,\
|
||||
src/main/resources/static/js/popper.js,\
|
||||
src/main/resources/static/js/daterangepicker.js,\
|
||||
src/main/resources/static/js/json-lint.js,\
|
||||
src/main/resources/static/html/**,\
|
||||
src/main/resources/static/css/**,\
|
||||
src/main/resources/static/sass/**,\
|
||||
src/main/resources/static/font/**,\
|
||||
src/main/resources/static/webfonts/**,\
|
||||
src/main/resources/static/img/**
|
||||
|
||||
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
|
||||
|
||||
# --- SCM -------------------------------------------------------------------
|
||||
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
|
||||
sonar.scm.provider=git
|
||||
|
||||
# --- 브랜치 ----------------------------------------------------------------
|
||||
# sonar.branch.name 은 SonarQube Developer Edition 이상에서만 동작한다.
|
||||
# Community Edition 에서 지정하면 분석이 실패하므로 기본적으로 설정하지 않는다.
|
||||
# sonar.branch.name=feats/ci-test
|
||||
@@ -44,13 +44,14 @@ public class TestbedSpecController {
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
/** default 토큰 spec 또는 저장 spec(서버 → GW 주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
try {
|
||||
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
|
||||
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read default token api spec file", e);
|
||||
return null;
|
||||
@@ -61,6 +62,6 @@ public class TestbedSpecController {
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -22,6 +25,57 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
/** 응답 charset 을 Content-Type 에서 얻지 못할 때 사용할 기본값. */
|
||||
private static final String DEFAULT_CHARSET = "UTF-8";
|
||||
|
||||
/**
|
||||
* 프록시 대상(GW/mock) 응답을 상태코드까지 포함해 전달하기 위한 홀더.
|
||||
*
|
||||
* <p>본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로
|
||||
* 상태코드와 Content-Type 을 함께 담는다.</p>
|
||||
*/
|
||||
public static class ApiResponse {
|
||||
|
||||
private final int status;
|
||||
private final String body;
|
||||
private final String contentType;
|
||||
private final Map<String, List<String>> headers;
|
||||
|
||||
public ApiResponse(int status, String body, String contentType, Map<String, List<String>> headers) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.contentType = contentType;
|
||||
this.headers = headers == null
|
||||
? Collections.<String, List<String>>emptyMap()
|
||||
: Collections.unmodifiableMap(headers);
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 대상 응답의 Content-Type 원본 (없으면 null). */
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/** 대상 응답 헤더 (상태줄 의사헤더인 null 키는 제외). 값은 헤더당 복수 가능. */
|
||||
public Map<String, List<String>> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ApiResponse{status=" + status + ", contentType=" + contentType
|
||||
+ ", bodyLen=" + (body == null ? 0 : body.length())
|
||||
+ ", headers=" + headers.keySet() + "}";
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
@@ -36,7 +90,7 @@ public class APISender {
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
public ApiResponse requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||
@@ -45,11 +99,11 @@ public class APISender {
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -75,7 +129,7 @@ public class APISender {
|
||||
return uriBuilder.toString();
|
||||
}
|
||||
|
||||
public String requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
|
||||
public ApiResponse requestGet(String uri, Map<String, String> headers, Map<String, String[]> params) throws IOException {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
@@ -95,16 +149,16 @@ public class APISender {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
|
||||
logger.debug("APISender GET 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public String requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
|
||||
public ApiResponse requestPost(String uri, Map<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
@@ -132,30 +186,67 @@ public class APISender {
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
ApiResponse response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
|
||||
logger.debug("APISender POST 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
private static String getResponse(HttpURLConnection connection) throws IOException {
|
||||
/**
|
||||
* 대상 응답을 상태코드·Content-Type·본문으로 읽는다.
|
||||
*
|
||||
* <p>4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고,
|
||||
* 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.</p>
|
||||
*/
|
||||
private static ApiResponse getResponse(HttpURLConnection connection) throws IOException {
|
||||
int responseCode = connection.getResponseCode();
|
||||
String contentType = connection.getContentType();
|
||||
StringBuilder response = new StringBuilder();
|
||||
|
||||
try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream);
|
||||
BufferedReader reader = new BufferedReader(isr)) {
|
||||
InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream();
|
||||
if (stream != null) {
|
||||
try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType));
|
||||
BufferedReader reader = new BufferedReader(isr)) {
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.toString();
|
||||
return new ApiResponse(responseCode, response.toString(), contentType, copyHeaders(connection));
|
||||
}
|
||||
|
||||
/** 대상 응답 헤더 복사. {@code getHeaderFields()} 의 null 키(상태줄)는 제외. */
|
||||
private static Map<String, List<String>> copyHeaders(HttpURLConnection connection) {
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Content-Type 의 charset 파라미터를 파싱. 없거나 인식 불가면 UTF-8. */
|
||||
private static String charsetOf(String contentType) {
|
||||
if (contentType != null) {
|
||||
for (String part : contentType.split(";")) {
|
||||
String token = part.trim();
|
||||
if (token.toLowerCase().startsWith("charset=")) {
|
||||
String charset = token.substring("charset=".length()).replace("\"", "").trim();
|
||||
if (!charset.isEmpty() && java.nio.charset.Charset.isSupported(charset)) {
|
||||
return charset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return DEFAULT_CHARSET;
|
||||
}
|
||||
|
||||
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
|
||||
@@ -11,9 +11,13 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
@@ -32,6 +36,13 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiTesterFilter.class);
|
||||
|
||||
/** 대상 응답에서 클라이언트로 되돌리지 않는 헤더 (소문자 비교). */
|
||||
private static final Set<String> BLOCKED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList(
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailer", "transfer-encoding", "upgrade",
|
||||
"content-length", "content-encoding", "content-type",
|
||||
"set-cookie", "set-cookie2"));
|
||||
|
||||
@Override
|
||||
public void init(FilterConfig filterConfig) throws ServletException {
|
||||
logger.debug("ApiTesterFilter initialized");
|
||||
@@ -141,14 +152,16 @@ public class ApiTesterFilter implements Filter {
|
||||
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||
}
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(tokenResponse);
|
||||
writeUpstream(response, tokenResponse);
|
||||
}
|
||||
|
||||
} else {
|
||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||
// 스펙 조회는 original-api-id 헤더(Swagger UI 가 x-original-api-id 확장에서 전달) 우선.
|
||||
// mock/gw 응답유형은 서버주소가 mockUrl·GW base 로 치환돼 original-url 의 path 가 저장된
|
||||
// api_url 과 일치하지 않으므로 URL 매칭만으로는 스펙을 찾지 못한다.
|
||||
ApiSpecInfoDto apiSpecInfoDto = selectSpec(apiSpecInfoDtoService, httpServletRequest, url);
|
||||
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
@@ -190,11 +203,12 @@ public class ApiTesterFilter implements Filter {
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
|
||||
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = url;
|
||||
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
@@ -212,19 +226,18 @@ public class ApiTesterFilter implements Filter {
|
||||
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
|
||||
}
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
APISender.ApiResponse upstream;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
upstream = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri,
|
||||
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
|
||||
logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri, upstream.getStatus(),
|
||||
upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody()));
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
writeUpstream(response, upstream);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
@@ -305,6 +318,25 @@ public class ApiTesterFilter implements Filter {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 호출 대상 API 명세를 찾는다. {@code original-api-id} 헤더가 있으면 API ID 로, 없으면 기존처럼
|
||||
* {@code original-url} 의 path + 메서드로 조회한다. 없으면 null.
|
||||
*
|
||||
* <p>API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 <b>포탈 게시(display_yn='Y')</b> 인
|
||||
* 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.</p>
|
||||
*/
|
||||
private ApiSpecInfoDto selectSpec(ApiService apiService, HttpServletRequest request, String url) {
|
||||
String apiId = request.getHeader("original-api-id");
|
||||
if (apiId != null && !apiId.trim().isEmpty()) {
|
||||
ApiSpecInfoDto dto = apiService.selectDetail(apiId.trim());
|
||||
if (dto != null && "Y".equalsIgnoreCase(dto.getDisplayYn())) {
|
||||
return dto;
|
||||
}
|
||||
logger.debug("original-api-id 로 게시된 스펙을 찾지 못해 URL 매칭으로 폴백 - apiId={}", apiId);
|
||||
}
|
||||
return apiService.selectDetailByURLAndMethod(parseUri(url), request.getMethod());
|
||||
}
|
||||
|
||||
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
@@ -320,7 +352,53 @@ public class ApiTesterFilter implements Filter {
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
/**
|
||||
* 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다.
|
||||
*
|
||||
* <p>대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다.
|
||||
* 단 아래 헤더는 전달하지 않는다.</p>
|
||||
* <ul>
|
||||
* <li>hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님</li>
|
||||
* <li>content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음</li>
|
||||
* <li>set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException {
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
httpResponse.setStatus(upstream.getStatus());
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : upstream.getHeaders().entrySet()) {
|
||||
String name = entry.getKey();
|
||||
if (isBlockedResponseHeader(name) || entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean first = true;
|
||||
for (String value : entry.getValue()) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
httpResponse.setHeader(name, value);
|
||||
first = false;
|
||||
} else {
|
||||
httpResponse.addHeader(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type 은 응답 문자셋까지 결정하므로 헤더 복사와 별개로 마지막에 확정한다.
|
||||
String contentType = upstream.getContentType();
|
||||
response.setContentType(contentType == null || contentType.trim().isEmpty()
|
||||
? "application/json" : contentType);
|
||||
|
||||
response.getWriter().println(upstream.getBody() == null ? "" : upstream.getBody());
|
||||
}
|
||||
|
||||
/** 클라이언트로 되돌리면 안 되는 응답 헤더인지. */
|
||||
private boolean isBlockedResponseHeader(String name) {
|
||||
return name == null || BLOCKED_RESPONSE_HEADERS.contains(name.toLowerCase());
|
||||
}
|
||||
|
||||
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
|
||||
private String maskHeaders(Map<String, String> headers) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
|
||||
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
|
||||
|
||||
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
|
||||
|
||||
/**
|
||||
* 조회 결과가 없으면 {@code null} 을 반환한다.
|
||||
*
|
||||
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
|
||||
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
|
||||
*/
|
||||
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto());
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -102,8 +104,28 @@ public class MyAppController {
|
||||
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
|
||||
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(user.getPortalOrg());
|
||||
|
||||
model.addAttribute("appRequests", appRequests);
|
||||
// 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다.
|
||||
// (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문)
|
||||
// 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다.
|
||||
Set<String> ownedClientIds = apikeys.stream()
|
||||
.map(ClientDTO::getClientid)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<String, AppRequest> openRequestByClient = new java.util.HashMap<>();
|
||||
List<AppRequest> standaloneRequests = new java.util.ArrayList<>();
|
||||
for (AppRequest request : appRequests) {
|
||||
String clientId = request.getClientId();
|
||||
if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId)
|
||||
&& openRequestByClient.putIfAbsent(clientId, request) == null) {
|
||||
continue;
|
||||
}
|
||||
standaloneRequests.add(request);
|
||||
}
|
||||
|
||||
model.addAttribute("appRequests", standaloneRequests);
|
||||
model.addAttribute("apiKeys", apikeys);
|
||||
model.addAttribute("openRequestByClient", openRequestByClient);
|
||||
return new ModelAndView(API_KEY_LIST);
|
||||
}
|
||||
|
||||
@@ -184,6 +206,10 @@ public class MyAppController {
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
|
||||
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
|
||||
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
|
||||
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -247,6 +273,36 @@ public class MyAppController {
|
||||
* @param requestData 요청 데이터 (clientId, reason)
|
||||
* @return 성공/실패 결과를 담은 Map
|
||||
*/
|
||||
/**
|
||||
* 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다.
|
||||
*
|
||||
* <p>step-up 2FA 가 걸린 {@code /clients/api_key_delete} <b>이전</b>에 호출해,
|
||||
* 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다).
|
||||
* 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.</p>
|
||||
*/
|
||||
@GetMapping("/credential/delete-precheck")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
public Map<String, Object> deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지)
|
||||
if (clientId == null || clientId.trim().isEmpty()
|
||||
|| appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("blocked", true);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId);
|
||||
result.put("blocked", blockReason != null);
|
||||
result.put("msg", blockReason);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/api_key_delete")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
|
||||
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
private byte[] appIconData;
|
||||
private String appIconContentType;
|
||||
|
||||
@NotBlank(message = "앱 이름을 입력해주세요.")
|
||||
@Length(max = 100, message = "앱 이름은 100자를 초과할 수 없습니다.")
|
||||
@NotBlank(message = "클라이언트 이름을 입력해주세요.")
|
||||
@Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
|
||||
private String appName;
|
||||
|
||||
@NotBlank(message = "앱 설명을 입력해주세요.")
|
||||
@Length(max = 500, message = "앱 설명은 500자를 초과할 수 없습니다.")
|
||||
@NotBlank(message = "클라이언트 설명을 입력해주세요.")
|
||||
@Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
|
||||
private String appDescription;
|
||||
|
||||
private String callbackUrl;
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||
import com.eactive.apim.portal.approval.entity.Approval;
|
||||
import com.eactive.apim.portal.approval.statemachine.CreatedState;
|
||||
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
|
||||
import com.eactive.apim.portal.approval.statemachine.RequestedState;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
@@ -169,21 +170,9 @@ public class AppServiceFacade {
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||
if (!inProgress) {
|
||||
continue;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||
}
|
||||
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||
String blockReason = resolveDeleteBlockReason(clientId);
|
||||
if (blockReason != null) {
|
||||
throw new IllegalStateException(blockReason);
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
@@ -203,6 +192,58 @@ public class AppServiceFacade {
|
||||
beginApproval(saved.getApproval().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다.
|
||||
*
|
||||
* <p>미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와
|
||||
* 화면/사전검사가 <b>같은 판정</b>({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다.
|
||||
* (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고,
|
||||
* 재시도할 때마다 2FA 가 반복된다.)</p>
|
||||
*/
|
||||
public String resolveDeleteBlockReason(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
if (open == null) {
|
||||
return null;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(open.getType())) {
|
||||
return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.";
|
||||
}
|
||||
return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 인증키에 걸려 있는 <b>미완료</b> 변경/해지 신청 1건. 없으면 {@code null}.
|
||||
* 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다.
|
||||
*/
|
||||
public AppRequestDTO findOpenRequest(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
return open == null ? null : appRequestMapper.toVo(open);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건.
|
||||
*
|
||||
* <p>결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에
|
||||
* 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다.
|
||||
* 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.</p>
|
||||
*/
|
||||
private AppRequest findOpenRequestEntity(String clientId) {
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
return r;
|
||||
}
|
||||
Object status = r.getApproval().getApprovalStatus();
|
||||
if (status instanceof CreatedState
|
||||
|| status instanceof RequestedState
|
||||
|| status instanceof ProcessingState) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
|
||||
@@ -41,8 +41,9 @@ public class TwoFactorController {
|
||||
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||
@RequestParam(required = false) String purpose,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.send(session, channel, purpose, force);
|
||||
return twoFactorService.send(request, session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
|
||||
@@ -115,7 +115,8 @@ public class TwoFactorService {
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||
public TwoFactorSendResponse send(HttpServletRequest request, HttpSession session, String channel,
|
||||
String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
@@ -156,7 +157,7 @@ public class TwoFactorService {
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||
discardContext(request, session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
@@ -280,7 +281,7 @@ public class TwoFactorService {
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED);
|
||||
LoginFailureReason.ACCOUNT_DISABLED, request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
@@ -316,7 +317,8 @@ public class TwoFactorService {
|
||||
|
||||
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason,
|
||||
request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
@@ -456,10 +458,11 @@ public class TwoFactorService {
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||
private void discardContext(HttpServletRequest request, HttpSession session, TwoFactorContext ctx) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.TWO_FACTOR_CANCELLED, request.getHeader("User-Agent"));
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
@@ -472,7 +475,8 @@ public class TwoFactorService {
|
||||
LoginFailureReason reason, HttpServletRequest request) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason,
|
||||
request.getHeader("User-Agent"));
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
|
||||
@@ -44,6 +44,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
FileTypeContext.setFileType("business");
|
||||
file = fileService.createFile(partnershipApplicationDTO.getFiles());
|
||||
} finally {
|
||||
FileTypeContext.clear();
|
||||
FileService.clearInternalUserContext();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,12 @@ public class LoginFinalizer {
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
|
||||
// 다른 기기/브라우저의 활성 세션이 남아 있는 상태의 로그인인지(아래 forceLogoutOtherSessions 로
|
||||
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
|
||||
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
|
||||
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
|
||||
duplicate);
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
|
||||
@@ -12,17 +12,22 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
public class PortalUserLogService {
|
||||
|
||||
/** PTL_USER_LOG.USER_AGENT 컬럼 길이. 초과분은 잘라서 저장한다(초과 시 ORA-12899 로 로그인 실패). */
|
||||
private static final int USER_AGENT_MAX_LENGTH = 500;
|
||||
|
||||
private final UserLogRepository userLogRepository;
|
||||
|
||||
public PortalUserLogService(UserLogRepository userLogRepository) {
|
||||
this.userLogRepository = userLogRepository;
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId) {
|
||||
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
|
||||
/**
|
||||
* 로그인 성공 기록.
|
||||
*
|
||||
* @param duplicate 로그인 시점에 다른 활성 세션이 남아 있어 기존 세션이 강제 해제된 건이면 true
|
||||
*/
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType, String userAgent,
|
||||
boolean duplicate) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
@@ -30,15 +35,13 @@ public class PortalUserLogService {
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setLoginType(loginType != null ? loginType.name() : null);
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
log.setDuplicate(duplicate);
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId) {
|
||||
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason, String userAgent) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
@@ -46,19 +49,29 @@ public class PortalUserLogService {
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(false);
|
||||
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void logLogout(String userId, String ip, String sessionId) {
|
||||
public void logLogout(String userId, String ip, String sessionId, String userAgent) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLogoutTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setUserAgent(truncateUserAgent(userAgent));
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
private String truncateUserAgent(String userAgent) {
|
||||
if (userAgent == null) {
|
||||
return null;
|
||||
}
|
||||
return userAgent.length() <= USER_AGENT_MAX_LENGTH
|
||||
? userAgent : userAgent.substring(0, USER_AGENT_MAX_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -40,6 +41,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private RelatedSiteService relatedSiteService;
|
||||
|
||||
@Autowired
|
||||
private GuideProperty guideProperty;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@@ -133,4 +137,19 @@ public class GlobalControllerAdvice {
|
||||
public List<RelatedSite> relatedSites() {
|
||||
return relatedSiteService.getSites();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발가이드 안내페이지에 표기할 게이트웨이 base URL.
|
||||
* PortalProperty(Portal/djb.guide.gw-url)에서 조회하며, 환경별로 값이 달라진다.
|
||||
*
|
||||
* <p>가이드 페이지(/service/*-guide)에서만 필요하므로 다른 요청에서는 DB 조회 없이 null 을 반환한다.
|
||||
*/
|
||||
@ModelAttribute("guideGwUrl")
|
||||
public String guideGwUrl(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null || !uri.endsWith("-guide")) {
|
||||
return null;
|
||||
}
|
||||
return guideProperty.gatewayUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,18 @@ package com.eactive.apim.portal.common.util;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 사용자 유형 판별 유틸리티
|
||||
* 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공
|
||||
*
|
||||
* 내부 사용자로 인정할 이메일 도메인은 application.yml 의
|
||||
* {@code portal.internal-user.email-domains} 로 관리한다.
|
||||
* 설정이 없으면 {@link #DEFAULT_INTERNAL_EMAIL_DOMAINS} 를 사용한다.
|
||||
*/
|
||||
public final class UserTypeUtil {
|
||||
|
||||
@@ -13,7 +22,47 @@ public final class UserTypeUtil {
|
||||
throw new IllegalStateException("Utility class");
|
||||
}
|
||||
|
||||
private static final String INTERNAL_EMAIL_DOMAIN = "@kjbank.com";
|
||||
/** 설정 미주입 시 사용할 기본 내부 도메인 */
|
||||
private static final List<String> DEFAULT_INTERNAL_EMAIL_DOMAINS =
|
||||
Collections.unmodifiableList(Arrays.asList("@djbank.com", "@unit-test.com"));
|
||||
|
||||
private static volatile List<String> internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
|
||||
|
||||
/**
|
||||
* 내부 사용자 이메일 도메인 목록 설정.
|
||||
* {@code UserTypeConfigurer} 가 기동 시 application.yml 값으로 주입한다.
|
||||
*
|
||||
* @param domains 도메인 목록 ("@" 접두사 유무 무관, 대소문자 무관)
|
||||
*/
|
||||
public static void setInternalEmailDomains(List<String> domains) {
|
||||
if (domains == null || domains.isEmpty()) {
|
||||
internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
|
||||
return;
|
||||
}
|
||||
List<String> normalized = new ArrayList<>();
|
||||
for (String domain : domains) {
|
||||
if (StringUtils.isBlank(domain)) {
|
||||
continue;
|
||||
}
|
||||
String value = domain.trim().toLowerCase();
|
||||
if (!value.startsWith("@")) {
|
||||
value = "@" + value;
|
||||
}
|
||||
if (!normalized.contains(value)) {
|
||||
normalized.add(value);
|
||||
}
|
||||
}
|
||||
internalEmailDomains = normalized.isEmpty()
|
||||
? DEFAULT_INTERNAL_EMAIL_DOMAINS
|
||||
: Collections.unmodifiableList(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 적용 중인 내부 사용자 이메일 도메인 목록
|
||||
*/
|
||||
public static List<String> getInternalEmailDomains() {
|
||||
return internalEmailDomains;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 내부 운영자인지 확인
|
||||
@@ -21,10 +70,28 @@ public final class UserTypeUtil {
|
||||
* @return 내부 운영자 여부
|
||||
*/
|
||||
public static boolean isInternalUser(PortalAuthenticatedUser user) {
|
||||
if (user == null || StringUtils.isEmpty(user.getEmailAddr())) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
return user.getEmailAddr().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN);
|
||||
return isInternalEmail(user.getEmailAddr());
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 주소의 호스트(도메인)로 내부 운영자 여부 판별
|
||||
* @param emailAddr 이메일 주소
|
||||
* @return 내부 운영자 여부
|
||||
*/
|
||||
public static boolean isInternalEmail(String emailAddr) {
|
||||
if (StringUtils.isEmpty(emailAddr)) {
|
||||
return false;
|
||||
}
|
||||
String email = emailAddr.trim().toLowerCase();
|
||||
for (String domain : internalEmailDomains) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.info.Info;
|
||||
import org.springframework.boot.actuate.info.InfoContributor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.handler.AbstractHandlerMapping;
|
||||
import org.springframework.web.servlet.handler.MappedInterceptor;
|
||||
|
||||
/**
|
||||
* HandlerInterceptor 목록을 Actuator 로 노출한다.
|
||||
*
|
||||
* <p>배경: Actuator 의 {@code /actuator/mappings} 는 servlet filter / servlet / handler mapping 만
|
||||
* 보여주고 interceptor 는 항목 자체가 없다. {@code /actuator/beans} 로도 안 보이는데,
|
||||
* {@link PortalConfigWebDispatcherServlet#addInterceptors} 가 {@code new} 로 직접 생성해 등록하므로
|
||||
* 스프링 빈이 아니기 때문이다. 그래서 HandlerMapping 내부 목록을 직접 읽어서 노출한다.
|
||||
*
|
||||
* <p>노출 경로 2가지:
|
||||
* <ul>
|
||||
* <li>{@code GET /actuator/interceptors} — 전용 엔드포인트</li>
|
||||
* <li>{@code GET /actuator/info} 의 {@code interceptors} 항목 — Spring Boot Admin 은 커스텀
|
||||
* 엔드포인트용 화면이 없으므로, SBA UI(Details 탭의 Info 카드)에서 보려면 이쪽이 필요하다.
|
||||
* {@code management.info.env.enabled} 와 무관하게 InfoContributor 는 항상 동작한다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>이 설정은 actuator 가 classpath 에 있을 때만 활성화된다. actuator 는 build.gradle 에서
|
||||
* 로컬 전용(runtimeOnly + war 산출물 제외)이므로 WebLogic 배포본(dev/stage/prod)에서는
|
||||
* 조건이 거짓이 되어 이 클래스 자체가 로드되지 않는다.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
|
||||
public class InterceptorsEndpointConfig {
|
||||
|
||||
@Bean
|
||||
public InterceptorsEndpoint interceptorsEndpoint(ApplicationContext applicationContext) {
|
||||
return new InterceptorsEndpoint(applicationContext);
|
||||
}
|
||||
|
||||
@Endpoint(id = "interceptors")
|
||||
public static class InterceptorsEndpoint implements InfoContributor {
|
||||
|
||||
/** AbstractHandlerMapping 이 실제로 실행하는 interceptor 목록(private final). */
|
||||
private static final Field ADAPTED_INTERCEPTORS =
|
||||
ReflectionUtils.findField(AbstractHandlerMapping.class, "adaptedInterceptors");
|
||||
|
||||
/** MappedInterceptor 의 제외 패턴(private final PatternAdapter[]). include 는 public getter 가 있다. */
|
||||
private static final Field EXCLUDE_PATTERNS =
|
||||
ReflectionUtils.findField(MappedInterceptor.class, "excludePatterns");
|
||||
|
||||
static {
|
||||
if (ADAPTED_INTERCEPTORS != null) {
|
||||
ReflectionUtils.makeAccessible(ADAPTED_INTERCEPTORS);
|
||||
}
|
||||
if (EXCLUDE_PATTERNS != null) {
|
||||
ReflectionUtils.makeAccessible(EXCLUDE_PATTERNS);
|
||||
}
|
||||
}
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
public InterceptorsEndpoint(ApplicationContext applicationContext) {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* HandlerMapping 빈 이름 → 등록 순서대로의 interceptor 목록.
|
||||
* 목록 순서가 곧 preHandle 실행 순서다(afterCompletion 은 역순).
|
||||
*/
|
||||
@ReadOperation
|
||||
public Map<String, List<Map<String, Object>>> interceptors() {
|
||||
Map<String, List<Map<String, Object>>> result = new TreeMap<>();
|
||||
Map<String, AbstractHandlerMapping> mappings =
|
||||
applicationContext.getBeansOfType(AbstractHandlerMapping.class);
|
||||
for (Map.Entry<String, AbstractHandlerMapping> entry : mappings.entrySet()) {
|
||||
List<Map<String, Object>> described = describe(entry.getValue());
|
||||
if (!described.isEmpty()) {
|
||||
result.put(entry.getKey(), described);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void contribute(Info.Builder builder) {
|
||||
builder.withDetail("interceptors", interceptors());
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> describe(AbstractHandlerMapping mapping) {
|
||||
if (ADAPTED_INTERCEPTORS == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Object raw = ReflectionUtils.getField(ADAPTED_INTERCEPTORS, mapping);
|
||||
if (!(raw instanceof List)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> described = new ArrayList<>();
|
||||
int order = 0;
|
||||
for (Object each : (List<?>) raw) {
|
||||
described.add(describe(order++, (HandlerInterceptor) each));
|
||||
}
|
||||
return described;
|
||||
}
|
||||
|
||||
private Map<String, Object> describe(int order, HandlerInterceptor interceptor) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("order", order);
|
||||
if (interceptor instanceof MappedInterceptor) {
|
||||
MappedInterceptor mapped = (MappedInterceptor) interceptor;
|
||||
row.put("interceptor", mapped.getInterceptor().getClass().getName());
|
||||
row.put("include", nullSafe(mapped.getPathPatterns()));
|
||||
row.put("exclude", excludePatterns(mapped));
|
||||
} else {
|
||||
// WebMvcConfigurationSupport 가 자동으로 넣는 것들(ConversionServiceExposing 등).
|
||||
// 경로 제한이 없어 모든 요청에 적용된다.
|
||||
row.put("interceptor", interceptor.getClass().getName());
|
||||
row.put("include", Collections.singletonList("/**"));
|
||||
row.put("exclude", Collections.emptyList());
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** PatternAdapter[] → 패턴 문자열 목록. PatternAdapter 는 package-private 이라 리플렉션으로 읽는다. */
|
||||
private List<String> excludePatterns(MappedInterceptor mapped) {
|
||||
if (EXCLUDE_PATTERNS == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Object adapters = ReflectionUtils.getField(EXCLUDE_PATTERNS, mapped);
|
||||
if (adapters == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int length = Array.getLength(adapters);
|
||||
List<String> patterns = new ArrayList<>(length);
|
||||
for (int i = 0; i < length; i++) {
|
||||
Object adapter = Array.get(adapters, i);
|
||||
Method getPatternString = ReflectionUtils.findMethod(adapter.getClass(), "getPatternString");
|
||||
if (getPatternString == null) {
|
||||
continue;
|
||||
}
|
||||
ReflectionUtils.makeAccessible(getPatternString);
|
||||
patterns.add((String) ReflectionUtils.invokeMethod(getPatternString, adapter));
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
private List<String> nullSafe(String[] values) {
|
||||
return values == null ? Collections.emptyList() : Arrays.asList(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
}
|
||||
}
|
||||
|
||||
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
|
||||
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception),
|
||||
request.getHeader("User-Agent"));
|
||||
|
||||
// 로그인 실패 시 세션 정보 로깅
|
||||
logLoginFailure(request, username, exception);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import javax.servlet.DispatcherType;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.filter.ForwardedHeaderFilter;
|
||||
|
||||
/**
|
||||
* 리버스프록시(OHS) 전달 헤더 처리 필터 등록.
|
||||
*
|
||||
* <p>{@code server.forward-headers-strategy=framework} 일 때만 활성화된다. Spring Boot 오토컨피그
|
||||
* ({@code ServletWebServerFactoryAutoConfiguration.ForwardedHeaderFilterConfiguration}) 가 동일 조건에서
|
||||
* 기본 {@link ForwardedHeaderFilter} 를 등록하지만, 여기서 직접 등록해 <b>{@code /internal/**} 를 필터 대상에서
|
||||
* 제외</b>한다. 오토컨피그는 해당 필터 빈이 이미 있으면 물러난다.
|
||||
*
|
||||
* <h3>왜 native 가 아니라 framework 인가</h3>
|
||||
* {@code native} 는 내장 Tomcat 의 {@code RemoteIpValve} 에 위임하는 전략이라 WAR 로 WebLogic 에 배포하면
|
||||
* 아무 동작도 하지 않는다({@link com.eactive.apim.portal.PortalApplication} 은 {@code SpringBootServletInitializer}
|
||||
* 상속). {@code framework} 는 평범한 서블릿 필터라 배포 형태와 무관하게 동작한다.
|
||||
*
|
||||
* <h3>왜 {@code /internal/**} 를 제외하는가</h3>
|
||||
* {@link ForwardedHeaderFilter} 는 (1) {@code getRemoteAddr()} 을 {@code X-Forwarded-For} 값으로 치환하고
|
||||
* (2) 처리한 forwarded 헤더를 요청에서 제거한다. 내부 API 는 이 두 값으로 접근을 통제하므로 그대로 두면 가드가 뒤집힌다.
|
||||
*
|
||||
* <pre>
|
||||
* 공격자: X-Forwarded-For: 127.0.0.1
|
||||
* → getRemoteAddr() = "127.0.0.1" (위조값이 허용 IP 검사를 통과)
|
||||
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
||||
* </pre>
|
||||
*
|
||||
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
|
||||
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
|
||||
* 검사되므로 기존 가드가 설계대로 동작한다.
|
||||
*
|
||||
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
||||
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
||||
*
|
||||
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "server.forward-headers-strategy", havingValue = "framework")
|
||||
public class PortalConfigForwardedHeader {
|
||||
|
||||
/** 프록시 헤더를 신뢰하지 않고 원 소켓 IP 로 판정해야 하는 경로. */
|
||||
private static final String INTERNAL_PREFIX = "/internal/";
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilterRegistration() {
|
||||
FilterRegistrationBean<ForwardedHeaderFilter> registration =
|
||||
new FilterRegistrationBean<>(new InternalExcludingForwardedHeaderFilter());
|
||||
// 다른 필터(XSS, Security 등)가 요청을 읽기 전에 scheme/host/port/remoteAddr 이 교정되어야 한다.
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR);
|
||||
log.info("ForwardedHeaderFilter 등록 - X-Forwarded-* 반영, 제외 경로: {}**", INTERNAL_PREFIX);
|
||||
return registration;
|
||||
}
|
||||
|
||||
/** {@code /internal/**} 요청은 원본 요청 그대로 통과시키는 {@link ForwardedHeaderFilter}. */
|
||||
static class InternalExcludingForwardedHeaderFilter extends ForwardedHeaderFilter {
|
||||
|
||||
/**
|
||||
* {@code super} 구현은 "forwarded 계열 헤더가 하나도 없으면 필터링하지 않는다"이다.
|
||||
* 그 최적화를 유지해야 프록시를 거치지 않는 환경(로컬·내부 직접 접속)의 동작이 기존과 동일하게 남으므로
|
||||
* 내부 경로 판정과 반드시 OR 로 결합한다.
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return isInternal(request) || super.shouldNotFilter(request);
|
||||
}
|
||||
|
||||
private boolean isInternal(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
// context-path 는 현재 "/" 이지만, 변경되더라도 경로 판정이 어긋나지 않게 접두를 걷어낸다.
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && contextPath.length() > 1 && uri.startsWith(contextPath)) {
|
||||
uri = uri.substring(contextPath.length());
|
||||
}
|
||||
return uri.startsWith(INTERNAL_PREFIX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@@ -25,6 +26,20 @@ public class PortalProperties {
|
||||
|
||||
private FileProperties file = new FileProperties();
|
||||
|
||||
private InternalUserProperties internalUser = new InternalUserProperties();
|
||||
|
||||
/**
|
||||
* 내부 사용자(운영자) 판별 설정
|
||||
*/
|
||||
@Data
|
||||
public static class InternalUserProperties {
|
||||
/**
|
||||
* 내부 사용자로 분류할 이메일 도메인 목록.
|
||||
* "@" 접두사는 있어도 되고 없어도 되며, 대소문자를 구분하지 않는다.
|
||||
*/
|
||||
private List<String> emailDomains = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 관련 설정
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* application.yml 의 {@code portal.internal-user.email-domains} 값을
|
||||
* 정적 유틸리티인 {@link UserTypeUtil} 에 주입한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class UserTypeConfigurer {
|
||||
|
||||
private final PortalProperties portalProperties;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
UserTypeUtil.setInternalEmailDomains(portalProperties.getInternalUser().getEmailDomains());
|
||||
log.info("내부 사용자 이메일 도메인: {}", UserTypeUtil.getInternalEmailDomains());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.eactive.apim.portal.djb.guide;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 개발가이드 안내페이지에 노출할 값을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.guide.})
|
||||
* 에서 읽는 얇은 접근자.
|
||||
*
|
||||
* <p>안내페이지에 표기되는 게이트웨이 주소는 서비스 환경(dev/stage/prod)마다 달라지므로 템플릿에 하드코딩하지 않고
|
||||
* DB 프로퍼티로 분리한다. 값 미존재 시 {@link PortalPropertyService#getOrCreateProperty} 가 기본값으로 INSERT 하며,
|
||||
* 공백 값은 {@link #resolve} 에서 기본값으로 폴백한다({@code getOrCreateProperty} 는 공백 보정을 하지 않음).
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GuideProperty {
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_GW_URL = "djb.guide.gw-url";
|
||||
|
||||
public static final String DEFAULT_GW_URL = "https://openapi.jejubank.co.kr";
|
||||
|
||||
/** 안내페이지(OAuth2 개발가이드 등)에 표기할 게이트웨이 base URL. 끝 슬래시는 제거한다. */
|
||||
public String gatewayUrl() {
|
||||
return stripTrailingSlash(resolve(KEY_GW_URL, DEFAULT_GW_URL,
|
||||
"개발가이드 안내페이지에 표기할 게이트웨이 base URL (예: https://openapi.jejubank.co.kr)"));
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
return StringUtils.hasText(value) ? value.trim() : defaultValue;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package com.eactive.apim.portal.djb.testbed.config;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -19,6 +22,7 @@ import org.springframework.util.StringUtils;
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedGatewayProperty {
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
@@ -33,8 +37,17 @@ public class DjbTestbedGatewayProperty {
|
||||
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
||||
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
||||
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
|
||||
public static final String KEY_SPEC_SERVER_URL = "djb.gateway.spec-server-url";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||
/**
|
||||
* {@link #KEY_SPEC_SERVER_URL} 미설정 표식. spec 서버 주소를 {@link #resolveApiBaseUrl} 기존 규칙
|
||||
* (GATEWAY→base-url, PortalMock→포탈 origin)으로 결정한다는 뜻.
|
||||
*
|
||||
* <p>빈 문자열을 기본값으로 쓰면 {@code PTL_PROPERTY.property_value} 가 NOT NULL 이라
|
||||
* Oracle 에서 빈 문자열=NULL 로 저장되며 제약 위반이 나므로, 비어 있지 않은 표식을 쓴다.</p>
|
||||
*/
|
||||
public static final String DEFAULT_SPEC_SERVER_URL = "auto";
|
||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||
public static final String DEFAULT_USE_PROXY = "true";
|
||||
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
|
||||
@@ -130,15 +143,42 @@ public class DjbTestbedGatewayProperty {
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 토큰 발급 URL.
|
||||
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
|
||||
* GATEWAY → {@code baseUrl + tokenPath}.
|
||||
* 브라우저에 내려주는 OAuth 토큰 발급 URL — spec 노출 주소({@link #resolveSpecServerUrl}) 기준.
|
||||
*
|
||||
* <p>PortalMock → {@code portalOrigin + /api/v1/oauth/token}(기존 mock 필터가 가로챔),
|
||||
* GATEWAY → {@code specServerUrl + tokenPath}.</p>
|
||||
*
|
||||
* <p>대외/대내 주소가 다른 환경에서 <b>직접호출 모드</b>({@code djb.gateway.token-use-proxy=false})는
|
||||
* 브라우저가 이 주소로 바로 나가므로 대외 주소여야 한다. 프록시 경유일 때는 이 값이
|
||||
* {@code original-url} 헤더로만 전달되고, 실제 forward 는 필터가
|
||||
* {@code baseUrl + tokenPath}(대내)로 수행한다.</p>
|
||||
*/
|
||||
public String resolveTokenUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
|
||||
}
|
||||
return stripTrailingSlash(baseUrl()) + tokenPath();
|
||||
return stripTrailingSlash(resolveSpecServerUrl(portalOrigin)) + tokenPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* gw 응답유형의 실제 forward 대상 URL. 브라우저가 spec 서버주소(대외)로 만든 {@code originalUrl} 에서
|
||||
* path/query 만 떼어 <b>대내 {@code base-url}</b> 에 재조립한다 — 포탈 서버는 대외 주소로 나갈 수 없다.
|
||||
*
|
||||
* <p>PortalMock 모드는 실 GW 가 없으므로 원본을 그대로 쓴다. URL 파싱 실패 시에도 원본 유지(멱등).</p>
|
||||
*/
|
||||
public String resolveGatewayCallUrl(String originalUrl) {
|
||||
if (originalUrl == null || resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return originalUrl;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(originalUrl);
|
||||
String path = StringUtils.hasText(uri.getRawPath()) ? uri.getRawPath() : "";
|
||||
String query = StringUtils.hasText(uri.getRawQuery()) ? "?" + uri.getRawQuery() : "";
|
||||
return stripTrailingSlash(baseUrl()) + path + query;
|
||||
} catch (URISyntaxException e) {
|
||||
log.warn("gw 호출 URL 파싱 실패, original-url 그대로 사용 - url={}, cause={}", originalUrl, e.getMessage());
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +193,30 @@ public class DjbTestbedGatewayProperty {
|
||||
return stripTrailingSlash(baseUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* spec({@code servers[0].url})에 노출할 주소. 응답유형과 무관하게 이 값 하나로 고정된다.
|
||||
*
|
||||
* <p>{@link #KEY_SPEC_SERVER_URL} 에 주소를 넣으면 그 값을 그대로 쓴다. PortalMock 모드처럼
|
||||
* {@code base-url} 이 실제 URL 이 아니어서 노출할 GW 주소를 코드가 알 수 없을 때 이 값으로 고정한다.
|
||||
* 대외 공개 도메인이 내부 GW 주소와 다른 경우에도 사용한다.</p>
|
||||
*
|
||||
* <p>{@value #DEFAULT_SPEC_SERVER_URL}(기본) 이면 {@link #resolveApiBaseUrl} 규칙을 그대로 따른다.</p>
|
||||
*/
|
||||
public String resolveSpecServerUrl(String portalOrigin) {
|
||||
String configured = specServerUrl();
|
||||
if (StringUtils.hasText(configured) && !DEFAULT_SPEC_SERVER_URL.equalsIgnoreCase(configured.trim())) {
|
||||
return stripTrailingSlash(configured);
|
||||
}
|
||||
return resolveApiBaseUrl(portalOrigin);
|
||||
}
|
||||
|
||||
/** spec servers 노출 주소. {@value #DEFAULT_SPEC_SERVER_URL} 이면 미설정(자동 결정). */
|
||||
public String specServerUrl() {
|
||||
return resolve(KEY_SPEC_SERVER_URL, DEFAULT_SPEC_SERVER_URL,
|
||||
"spec(servers)에 노출할 API 호출 주소. \"" + DEFAULT_SPEC_SERVER_URL
|
||||
+ "\" 이면 GW Base URL(PortalMock 이면 포탈 주소)로 자동 결정");
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
return StringUtils.hasText(value) ? value.trim() : defaultValue;
|
||||
|
||||
@@ -48,34 +48,37 @@ public class DjbTestbedSpecController {
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request, true);
|
||||
String json = buildSpecJson(id, request, false);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request, true);
|
||||
String json = buildSpecJson(id, request, false);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
|
||||
/**
|
||||
* Swagger UI 전용 spec. 서버 주소는 다운로드용과 동일하게 GW 기준이며, 여기에만
|
||||
* {@code x-original-api-id} 를 심어 UI 실행 시 프록시가 API ID 로 스펙을 찾게 한다.
|
||||
*/
|
||||
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request, false);
|
||||
String json = buildSpecJson(id, request, true);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
|
||||
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
|
||||
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 GW 치환)을 JSON 으로 반환. 없으면 null.
|
||||
* @param forUi true 면 Swagger UI 실행용으로 {@code x-original-api-id} 를 주입한다
|
||||
*/
|
||||
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
|
||||
private String buildSpecJson(String id, HttpServletRequest request, boolean forUi) throws IOException {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
// 클래스패스 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로
|
||||
// (PORTAL_MOCK_TOKEN_PATH)라 GW 호스트를 붙이면 실재하지 않는 주소가 되고, servers 를
|
||||
// 비워 두면 Swagger UI 가 문서 origin(포탈)을 사용해 프록시가 mock 토큰을 발급한다.
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return alwaysGateway
|
||||
? serverRewriter.rewriteServerToGateway(content, request)
|
||||
: serverRewriter.rewriteServer(content, null, request);
|
||||
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
@@ -85,8 +88,12 @@ public class DjbTestbedSpecController {
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||
return alwaysGateway
|
||||
? serverRewriter.rewriteServerToGateway(enriched, request)
|
||||
: serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||
String rewritten = serverRewriter.rewriteServerToGateway(enriched, request);
|
||||
if (!forUi) {
|
||||
return rewritten;
|
||||
}
|
||||
// UI 용 spec 에만 API ID 를 심는다. 서버주소가 GW 로 고정돼 mock 은 호출 URL 의 path 로 스펙을
|
||||
// 되찾을 수 없으므로, Swagger UI 가 original-api-id 헤더로 프록시에 API ID 를 전달하게 한다.
|
||||
return enricher.injectOriginalApiId(rewritten, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +32,11 @@ public class DjbTestbedContextDto {
|
||||
private String tokenUrl;
|
||||
private String apiKeyHeader;
|
||||
private String oauthTokenHeader;
|
||||
|
||||
/**
|
||||
* sample/mock 응답유형 API 에서 실제 토큰 발급 없이 주입할 고정 액세스 토큰.
|
||||
* (실호출이 없거나 mock 서버로만 나가므로 검증 대상이 아니다 — {@code ApiTesterFilter} 의 mock 토큰과 동일 값)
|
||||
*/
|
||||
private String mockAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ public class DjbSwaggerSpecEnricher {
|
||||
public static final String OAUTH_SCHEME = "djbOAuth";
|
||||
public static final String API_KEY_SCHEME = "djbApiKey";
|
||||
|
||||
/** operation 에 주입하는 API ID 확장 키. 템플릿 JS 가 {@code original-api-id} 헤더로 프록시에 전달한다. */
|
||||
public static final String API_ID_EXTENSION = "x-original-api-id";
|
||||
|
||||
/** OpenAPI path item 에서 operation 으로 취급하는 필드 (그 외 parameters/servers/summary 등은 제외). */
|
||||
private static final String[] HTTP_METHODS =
|
||||
{"get", "put", "post", "delete", "options", "head", "patch", "trace"};
|
||||
|
||||
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
@@ -71,6 +78,42 @@ public class DjbSwaggerSpecEnricher {
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* spec 의 모든 operation 에 {@value #API_ID_EXTENSION} 를 주입한다.
|
||||
*
|
||||
* <p>테스트베드 프록시({@code /api/call-api})는 스펙을 찾을 때 {@code original-url} 의 path 로 매칭했는데,
|
||||
* mock/gw 응답유형에서는 서버 주소가 mockUrl·GW base 로 치환되어 path 가 저장된 {@code api_url} 과
|
||||
* 일치하지 않는다. 템플릿 JS 가 이 확장값을 {@code original-api-id} 헤더로 보내면 프록시가 API ID 로
|
||||
* 정확히 스펙을 찾을 수 있다.</p>
|
||||
*
|
||||
* <p>파싱 불가·paths 부재 시 원본 유지(멱등).</p>
|
||||
*/
|
||||
public String injectOriginalApiId(String specJson, String apiId) throws JsonProcessingException {
|
||||
if (specJson == null || apiId == null || apiId.trim().isEmpty()) {
|
||||
return specJson;
|
||||
}
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
return specJson;
|
||||
}
|
||||
JsonNode paths = parsed.get("paths");
|
||||
if (paths == null || !paths.isObject()) {
|
||||
return specJson;
|
||||
}
|
||||
for (JsonNode pathItem : paths) {
|
||||
if (!pathItem.isObject()) {
|
||||
continue;
|
||||
}
|
||||
for (String method : HTTP_METHODS) {
|
||||
JsonNode operation = pathItem.get(method);
|
||||
if (operation != null && operation.isObject()) {
|
||||
((ObjectNode) operation).put(API_ID_EXTENSION, apiId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return objectMapper.writeValueAsString(parsed);
|
||||
}
|
||||
|
||||
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
|
||||
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
|
||||
String headerName = (authType == DjbAuthType.OAUTH)
|
||||
|
||||
@@ -119,6 +119,7 @@ public class DjbTestbedAuthService {
|
||||
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
|
||||
.apiKeyHeader(gatewayProperty.apiKeyHeader())
|
||||
.oauthTokenHeader(gatewayProperty.oauthHeader())
|
||||
.mockAccessToken(gatewayProperty.mockAccessToken())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -12,58 +14,61 @@ import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
|
||||
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
|
||||
* testbed spec(JSON)의 서버 주소를 제공 시점에 게이트웨이 주소로 치환한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
|
||||
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
|
||||
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
|
||||
* </ul>
|
||||
* <p><b>spec 의 {@code servers} 는 응답유형(sample/mock/gw)과 무관하게 항상 GW 기준이다.</b>
|
||||
* spec 은 "이 API 를 실제로 어디로 호출하는가"의 공개 계약이고 그 기준은 게이트웨이 하나다.
|
||||
* admin(app.js buildOpenApiSpec)도 같은 규칙으로 저장하며, 포탈은 제공 시점에 자기 환경의
|
||||
* {@link DjbTestbedGatewayProperty#resolveSpecServerUrl} 로 덮어써 저장 환경(dev)의 주소가 다른 환경까지
|
||||
* 따라오지 않게 한다. 노출 주소를 명시 고정해야 하면
|
||||
* {@code djb.gateway.spec-server-url}(PTL_PROPERTY) 에 넣는다.
|
||||
*
|
||||
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
|
||||
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
|
||||
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
|
||||
* <p>응답유형에 따른 차이는 <b>테스트베드 동작</b>에서만 난다 — Swagger UI 의 실행은
|
||||
* {@code /api/call-api} 프록시를 타고, {@code ApiTesterFilter} 가 응답유형별로
|
||||
* sample(저장 샘플 응답) / mock({@code mockUrl} forward) / gw(게이트웨이 forward) 를 가른다.
|
||||
* 즉 표시 주소는 GW 로 고정이고 호출 대상만 갈린다.
|
||||
*
|
||||
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
|
||||
* <p>치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다(문자열 치환 아님). 과거 admin 이 mock/포탈
|
||||
* 주소를 굳혀 저장한 spec 도 GW 기준으로 정정된다. 다만 그 시절 mock 저장본은 {@code paths} 키가 mock
|
||||
* 경로로 바뀌어 있어, 어댑터경로 복원은 admin 재저장이 필요하다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecServerRewriter {
|
||||
|
||||
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
|
||||
public static final String SERVER_SENTINEL = "http://swagger-server-url";
|
||||
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
|
||||
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
|
||||
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||
return specJson;
|
||||
}
|
||||
String base = stripTrailingSlash(resolveBase(spec, request));
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||
}
|
||||
return specJson.replace(SERVER_SENTINEL, base);
|
||||
}
|
||||
|
||||
/**
|
||||
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
|
||||
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
|
||||
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
|
||||
* spec 의 {@code servers} 를 GW 주소로 치환해 반환한다. UI 표시용·다운로드용 구분 없이 동일 기준.
|
||||
*
|
||||
* @return 치환된 JSON. 파싱 실패·형태 불일치 시 원본 유지(멱등).
|
||||
*/
|
||||
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
|
||||
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||
if (specJson == null) {
|
||||
return null;
|
||||
}
|
||||
String base = stripTrailingSlash(gatewayProperty.resolveSpecServerUrl(originOf(request)));
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
return specJson;
|
||||
}
|
||||
ObjectNode root = (ObjectNode) parsed;
|
||||
if (StringUtils.hasText(base)) {
|
||||
ArrayNode servers = objectMapper.createArrayNode();
|
||||
servers.add(objectMapper.createObjectNode().put("url", base));
|
||||
root.set("servers", servers);
|
||||
} else {
|
||||
// GW 주소 미확정 — servers 를 비워 Swagger UI 가 문서 origin(포탈)을 쓰게 한다.
|
||||
root.remove("servers");
|
||||
}
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
log.error("spec 서버 치환 실패, 원본 유지 - base={}", base, e);
|
||||
return specJson;
|
||||
}
|
||||
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||
}
|
||||
return specJson.replace(SERVER_SENTINEL, base);
|
||||
}
|
||||
|
||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||
@@ -80,20 +85,6 @@ public class DjbTestbedSpecServerRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
|
||||
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
|
||||
? "sample" : spec.getResponseType().trim();
|
||||
if ("gw".equalsIgnoreCase(rt)) {
|
||||
return gatewayProperty.resolveApiBaseUrl(originOf(request));
|
||||
}
|
||||
if ("mock".equalsIgnoreCase(rt)) {
|
||||
String mock = (spec == null) ? null : spec.getMockUrl();
|
||||
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
|
||||
}
|
||||
// sample(기본): 포탈 origin
|
||||
return originOf(request);
|
||||
}
|
||||
|
||||
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
|
||||
public static String originOf(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
|
||||
@@ -42,4 +42,10 @@ portal:
|
||||
# auth-virtual-code: 654321
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
hot-reload-pages: true
|
||||
hot-reload-pages: true
|
||||
|
||||
server:
|
||||
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
|
||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||
forward-headers-strategy: framework
|
||||
@@ -32,4 +32,10 @@ app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
|
||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||
forward-headers-strategy: framework
|
||||
@@ -47,3 +47,9 @@ gateway:
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
server:
|
||||
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
|
||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||
forward-headers-strategy: framework
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ server:
|
||||
whitelabel:
|
||||
enabled: false
|
||||
path: /error
|
||||
# 리버스프록시(OHS) 전달 헤더(X-Forwarded-*)를 요청에 반영하는 전략.
|
||||
# native = 내장 Tomcat RemoteIpValve 위임 → WAR(WebLogic) 배포에서는 동작하지 않는다.
|
||||
# framework = Spring ForwardedHeaderFilter(서블릿 필터) → 배포 방식과 무관하게 동작한다.
|
||||
# 기본값 native: 로컬 개발은 gradle bootRun(내장 Tomcat)으로 기동하므로 컨테이너 기본 처리에 맡긴다.
|
||||
# RemoteIpValve 는 internalProxies(사설/loopback 대역) 에서 온 요청의 헤더만 신뢰하므로 로컬에서 안전하다.
|
||||
# 프록시 뒤에 놓이는 WAR 배포 환경(dev/stage/prod)만 각 프로파일에서 framework 로 올린다.
|
||||
# ※ framework 로 올리는 환경은 OHS 에서 반드시 아래를 수행해야 한다(신뢰 프록시 목록이 없어 헤더를 무조건 신뢰).
|
||||
# RequestHeader unset X-Forwarded-For / -Proto / -Host / -Port / Forwarded (클라이언트 위조분 제거)
|
||||
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
|
||||
forward-headers-strategy: native
|
||||
|
||||
|
||||
@@ -82,6 +91,11 @@ security:
|
||||
basic:
|
||||
enabled: false
|
||||
|
||||
# ※ Actuator / Spring Boot Admin 설정은 이 파일에 두지 않는다.
|
||||
# application-local*.yml 에만 두고, 해당 파일과 관련 라이브러리는 war/bootWar 산출물에서
|
||||
# 제외된다(build.gradle 의 localOnly configuration + rootSpec.exclude 참고).
|
||||
# WebLogic 배포본(dev/stage/prod)에는 actuator 자체가 존재하지 않는다.
|
||||
|
||||
sample-code-path: classpath:/templates/sample_code
|
||||
|
||||
|
||||
@@ -113,6 +127,14 @@ portal:
|
||||
logging:
|
||||
log-path: /logs/eapim
|
||||
|
||||
# 내부 사용자(운영자) 판별 설정
|
||||
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
|
||||
# "@" 접두사 유무·대소문자 무관
|
||||
internal-user:
|
||||
email-domains:
|
||||
- djbank.com
|
||||
- unit-test.com
|
||||
|
||||
auth-ttl: 300
|
||||
auth:
|
||||
resend_limit_seconds: 30
|
||||
|
||||
@@ -689,13 +689,17 @@ hr {
|
||||
font-display: swap;
|
||||
src: local("Spoqa Han Sans Bold"), url("/font/spoqa-hans/SpoqaHanSansBold.woff2") format("woff2"), url("/font/spoqa-hans/SpoqaHanSansBold.woff") format("woff"), url("/font/spoqa-hans/SpoqaHanSansBold.ttf") format("truetype");
|
||||
}
|
||||
/* Noto Sans KR (Variable) */
|
||||
/* Noto Sans KR (Variable)
|
||||
- 본문 3순위 폴백이므로 로컬 설치본을 최우선 사용한다.
|
||||
- 웹폰트는 한글 음절 전체(U+AC00–D7A3)·라틴·기호만 남긴 woff2 서브셋(약 1.1MB).
|
||||
원본 VariableFont TTF(9.9MB)는 전송 중 abort로 broken pipe 로그를 유발해 제거했다.
|
||||
서브셋 재생성은 docs/font-subset.md 참고. */
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/font/noto-sans/NotoSansKR-VariableFont_wght.ttf") format("truetype");
|
||||
src: local("Noto Sans KR"), local("NotoSansKR"), local("Noto Sans CJK KR"), url("/font/noto-sans/NotoSansKR-subset.woff2") format("woff2");
|
||||
}
|
||||
:root {
|
||||
--primary-color: #0049b4;
|
||||
|
||||
@@ -87,11 +87,18 @@
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Noto Sans KR (Variable) */
|
||||
/* Noto Sans KR (Variable)
|
||||
- 본문 3순위 폴백이므로 로컬 설치본을 최우선 사용한다.
|
||||
- 웹폰트는 한글 음절 전체(U+AC00–D7A3)·라틴·기호만 남긴 woff2 서브셋(약 1.1MB).
|
||||
원본 VariableFont TTF(9.9MB)는 전송 중 abort로 broken pipe 로그를 유발해 제거했다.
|
||||
서브셋 재생성은 docs/font-subset.md 참고. */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype');
|
||||
src: local('Noto Sans KR'),
|
||||
local('NotoSansKR'),
|
||||
local('Noto Sans CJK KR'),
|
||||
url('/font/noto-sans/NotoSansKR-subset.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -647,16 +647,23 @@
|
||||
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
|
||||
}
|
||||
|
||||
// sample/mock 응답유형은 실제 인증이 성립하지 않는다.
|
||||
// - sample: ApiTesterFilter 가 저장된 샘플 응답을 그대로 반환(실호출 없음)
|
||||
// - mock : mock 서버로만 forward 되고 토큰도 고정값 즉시 발급(Secret 검증 없음)
|
||||
// → 토큰 발급 왕복·Secret 입력 없이 바로 인증 상태로 진행한다.
|
||||
// (responseType 미지정은 필터와 동일하게 sample 로 간주)
|
||||
function isNoAuthApi() {
|
||||
const type = (CURRENT_RESPONSE_TYPE || '').toLowerCase();
|
||||
return type === '' || type === 'sample' || type === 'mock';
|
||||
}
|
||||
|
||||
function authorizeApp(secret) {
|
||||
const gw = window.__djbGateway || {};
|
||||
if (!window.ui) return;
|
||||
|
||||
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내
|
||||
if (isMockApi()) {
|
||||
if (typeof djbToast === 'function') {
|
||||
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
|
||||
}
|
||||
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
|
||||
// sample/mock: 실제 인증 절차 생략하고 고정 토큰/키를 즉시 주입 — toast 안내
|
||||
if (isNoAuthApi()) {
|
||||
authorizeWithoutAuth(secret, gw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -669,6 +676,34 @@
|
||||
proceedAuthorize(secret, secret.clientSecret, gw);
|
||||
}
|
||||
|
||||
// sample/mock 전용 — 토큰 발급/Secret 입력 없이 Swagger authorize 에 바로 주입.
|
||||
// 토큰 값은 컨텍스트의 mockAccessToken(= ApiTesterFilter 가 발급하는 값과 동일).
|
||||
function authorizeWithoutAuth(secret, gw) {
|
||||
if (typeof djbToast === 'function') {
|
||||
djbToast(isMockApi()
|
||||
? 'Mock 응답 API 는 별도 인증 절차 없이 바로 호출됩니다.'
|
||||
: '샘플 응답 API 는 별도 인증 절차 없이 바로 호출됩니다.', { type: 'info', duration: 5000 });
|
||||
}
|
||||
if (secret.authType === 'OAUTH') {
|
||||
window.ui.authActions.authorize({
|
||||
djbOAuth: {
|
||||
name: 'djbOAuth',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.oauthTokenHeader },
|
||||
value: 'Bearer ' + (gw.mockAccessToken || 'sample-access-token')
|
||||
}
|
||||
});
|
||||
} else if (secret.authType === 'API_KEY') {
|
||||
window.ui.authActions.authorize({
|
||||
djbApiKey: {
|
||||
name: 'djbApiKey',
|
||||
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
|
||||
value: secret.clientSecret || gw.mockAccessToken || 'sample-api-key'
|
||||
}
|
||||
});
|
||||
}
|
||||
refreshSnippetPanel();
|
||||
}
|
||||
|
||||
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
|
||||
function promptClientSecret(secret, gw) {
|
||||
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
|
||||
|
||||
@@ -113,9 +113,12 @@
|
||||
<label for="file" class="file-upload-btn">
|
||||
파일 선택
|
||||
</label>
|
||||
<input type="file" id="file" name="file" style="display: none;" />
|
||||
<input type="file" id="file" name="files"
|
||||
th:accept="${isInternalUser ? '*' : '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.hwp,.gif,.jpg,.jpeg,.png'}"
|
||||
style="display: none;" />
|
||||
</div>
|
||||
<small class="form-help-text" style="margin-top: 8px; display: block; color: #8c959f;">
|
||||
<span th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요. '">첨부파일은 8MB 이내로 등록해 주세요. </span>
|
||||
문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.
|
||||
</small>
|
||||
</div>
|
||||
@@ -217,6 +220,14 @@
|
||||
}
|
||||
/*[/]*/
|
||||
|
||||
const maxSizeBytes = (window.PORTAL_CONFIG && window.PORTAL_CONFIG.file)
|
||||
? window.PORTAL_CONFIG.file.maxSizeBytes : 0;
|
||||
if (maxSizeBytes > 0 && file.size > maxSizeBytes) {
|
||||
customPopups.showAlert('첨부파일은 ' + window.PORTAL_CONFIG.file.maxSize + ' 이내로 등록해 주세요.');
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileDisplayText && fileInputDisplay && btnRemoveFile) {
|
||||
fileDisplayText.textContent = file.name;
|
||||
fileDisplayText.classList.add('has-file');
|
||||
|
||||
@@ -51,9 +51,14 @@
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="app-card-info">
|
||||
<div class="app-card-header">
|
||||
<div class="app-card-header"
|
||||
th:with="openReq=${openRequestByClient != null ? openRequestByClient.get(apikey.clientid) : null}">
|
||||
<!-- App Name -->
|
||||
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
|
||||
<!-- 진행 중인 해지/변경 신청 배지 — 클라이언트 자체 상태(승인)만으로는 결재 진행이 드러나지 않는다 -->
|
||||
<span class="app-card-badge badge-requested" th:if="${openReq != null}"
|
||||
th:text="|${openReq.type != null and openReq.type.name() == 'DELETE' ? '해지' : '변경'} 신청 ${openReq.approval != null and openReq.approval.approvalStatus != null ? openReq.approval.approvalStatus.description : '접수'}|">해지
|
||||
신청 진행중</span>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge"
|
||||
th:classappend="${apikey.appstatus == '1' ? 'badge-approved' : apikey.appstatus == '0' ? 'badge-inactive' : 'badge-pending'}"
|
||||
|
||||
@@ -90,18 +90,18 @@
|
||||
<!-- Hidden clientId -->
|
||||
<input type="hidden" th:field="*{clientId}"/>
|
||||
|
||||
<!-- 앱 이름 -->
|
||||
<!-- 클라이언트 이름 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
|
||||
<label class="s1-label">클라이언트 이름 <span class="s1-required">*</span></label>
|
||||
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
|
||||
placeholder="앱 이름을 입력하세요." required maxlength="100">
|
||||
placeholder="클라이언트 이름을 입력하세요." required maxlength="100">
|
||||
</div>
|
||||
|
||||
<!-- 앱 설명 -->
|
||||
<!-- 클라이언트 설명 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 설명 <span class="s1-required">*</span></label>
|
||||
<label class="s1-label">클라이언트 설명 <span class="s1-required">*</span></label>
|
||||
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
|
||||
placeholder="앱 설명을 입력하세요." required maxlength="500"></textarea>
|
||||
placeholder="클라이언트 설명을 입력하세요." required maxlength="500"></textarea>
|
||||
<div class="s1-counter-row">
|
||||
<span id="charCounter" class="s1-counter">0 / 500</span>
|
||||
</div>
|
||||
@@ -132,9 +132,9 @@
|
||||
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
|
||||
</div>
|
||||
|
||||
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
|
||||
<!-- 클라이언트 아이콘 (선택 옵션 — 최하단 배치, compact) -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 아이콘</label>
|
||||
<label class="s1-label">클라이언트 아이콘</label>
|
||||
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
|
||||
|
||||
<!-- 기존 파일 ID로 이미지 노출 -->
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
|
||||
<img th:src="@{/img/icon/img_icon.png}">
|
||||
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
|
||||
<p class="s1-upload-title">클라이언트 아이콘 이미지 파일을 업로드 하세요</p>
|
||||
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
|
||||
<button type="button" class="s1-btn-upload"
|
||||
onclick="document.getElementById('appIconFile').click()">
|
||||
@@ -364,14 +364,14 @@
|
||||
|
||||
if (!name) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 이름을 입력해주세요.');
|
||||
customPopups.showAlert('클라이언트 이름을 입력해주세요.');
|
||||
document.getElementById('appName').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 설명을 입력해주세요.');
|
||||
customPopups.showAlert('클라이언트 설명을 입력해주세요.');
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<!-- Progress Steps Card -->
|
||||
<div class="s1-progress-card">
|
||||
<div class="s1-steps">
|
||||
<!-- Step 1 Active: 앱 정보 입력 (document icon) -->
|
||||
<!-- Step 1 Active: 클라이언트 정보 입력 (document icon) -->
|
||||
<div class="s1-step s1-step--active">
|
||||
<div class="s1-step-circle">
|
||||
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||
@@ -44,7 +44,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="s1-step-num">1단계</span>
|
||||
<span class="s1-step-name">앱 정보 입력</span>
|
||||
<span class="s1-step-name">클라이언트 정보 입력</span>
|
||||
</div>
|
||||
|
||||
<div class="s1-step-line"></div>
|
||||
@@ -100,18 +100,18 @@
|
||||
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
|
||||
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
|
||||
|
||||
<!-- 앱 이름 -->
|
||||
<!-- 클라이언트 이름 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
|
||||
<label class="s1-label">클라이언트 이름 <span class="s1-required">*</span></label>
|
||||
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
|
||||
placeholder="앱 이름을 입력하세요." required maxlength="100">
|
||||
placeholder="클라이언트 이름을 입력하세요." required maxlength="100">
|
||||
</div>
|
||||
|
||||
<!-- 앱 설명 -->
|
||||
<!-- 클라이언트 설명 -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 설명 <span class="s1-required">*</span></label>
|
||||
<label class="s1-label">클라이언트 설명 <span class="s1-required">*</span></label>
|
||||
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
|
||||
placeholder="앱 설명을 입력하세요." required maxlength="500"></textarea>
|
||||
placeholder="클라이언트 설명을 입력하세요." required maxlength="500"></textarea>
|
||||
<div class="s1-counter-row">
|
||||
<span id="charCounter" class="s1-counter">0 / 200</span>
|
||||
</div>
|
||||
@@ -150,14 +150,14 @@
|
||||
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
|
||||
</div>
|
||||
|
||||
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
|
||||
<!-- 클라이언트 아이콘 (선택 옵션 — 최하단 배치, compact) -->
|
||||
<div class="s1-field">
|
||||
<label class="s1-label">앱 아이콘</label>
|
||||
<label class="s1-label">클라이언트 아이콘</label>
|
||||
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
|
||||
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
|
||||
<div id="iconPlaceholder" class="s1-upload-inner">
|
||||
<img th:src="@{/img/icon/img_icon.png}">
|
||||
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
|
||||
<p class="s1-upload-title">클라이언트 아이콘 이미지 파일을 업로드 하세요</p>
|
||||
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
|
||||
<button type="button" class="s1-btn-upload"
|
||||
onclick="document.getElementById('appIconFile').click()">
|
||||
@@ -382,14 +382,14 @@
|
||||
|
||||
if (!name) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 이름을 입력해주세요.');
|
||||
customPopups.showAlert('클라이언트 이름을 입력해주세요.');
|
||||
document.getElementById('appName').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 설명을 입력해주세요.');
|
||||
customPopups.showAlert('클라이언트 설명을 입력해주세요.');
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
<div class="s3-message-wrapper">
|
||||
<h1 class="s3-success-title">클라이언트 신청이 완료되었습니다.</h1>
|
||||
<p class="s3-success-desc">
|
||||
담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
|
||||
담당자의 클라이언트 승인 후 <span class="s3-highlight">[클라이언트 정보]</span> 화면에서
|
||||
<br>
|
||||
<span class="s3-highlight">Client ID와 Client Secret을 확인</span>할 수 있습니다.
|
||||
</p>
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 내부 결재 진행중: 취소 불가 안내 -->
|
||||
<!-- 심사중 안내. 결재자가 아직 아무도 승인하지 않았으면 취소되고, 승인 이력이 있으면 서버가 거절한다 -->
|
||||
<div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
||||
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
|
||||
@@ -218,7 +218,7 @@
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<p>내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.</p>
|
||||
<p>관리자가 심사 중인 신청입니다. 결재자가 이미 승인한 경우에는 취소되지 않으며, 이때는 관리자에게 문의해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Actions -->
|
||||
@@ -226,7 +226,8 @@
|
||||
<!-- Cancel Request Button (danger red) -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:if="${appRequest.approval == null or (appRequest.approval.approvalStatus != null and
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'))}"
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'
|
||||
or appRequest.approval.approvalStatus.toString() == 'CREATED' or appRequest.approval.approvalStatus.toString() == 'PROCESSING'))}"
|
||||
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
|
||||
신청 취소
|
||||
</button>
|
||||
|
||||
@@ -186,13 +186,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 걸려 있는 변경/해지 신청 안내. 결재가 개시되지 않아 관리자 결재함에 뜨지 않는 신청도 여기서 보인다 -->
|
||||
<div class="info-notice-box" th:if="${openRequest != null}" style="margin-top: 24px;">
|
||||
<p>
|
||||
<span
|
||||
th:text="|${openRequest.type != null and openRequest.type.name() == 'DELETE' ? '해지' : '변경'} 신청이 접수되어 있습니다. (상태: ${openRequest.approval == null ? '결재 미개시' : openRequest.approval.approvalStatus.description})|">신청이
|
||||
접수되어 있습니다.</span>
|
||||
<a th:href="@{/clients/app_request_detail(id=${openRequest.id})}"
|
||||
style="margin-left: 8px; text-decoration: underline;">신청 상세 보기</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Actions -->
|
||||
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
||||
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
|
||||
<!-- 진행 중 신청 취소. 결재 미개시 건도 대상이라 사용자가 직접 정리할 수 있다 -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:if="${openRequest != null}" th:data-request-id="${openRequest.id}"
|
||||
onclick="cancelOpenRequest(this)"
|
||||
th:text="${openRequest.type != null and openRequest.type.name() == 'DELETE'} ? '해지 신청 취소' : '변경 신청 취소'">해지
|
||||
신청 취소</button>
|
||||
<!-- 해지 신청 중이면 비활성(라벨로 상태 표시). 그 외 불가 사유(변경 결재 진행 중 등)는
|
||||
버튼을 살려두고 클릭 시 사전검사가 사유를 안내한다 — 눌러도 반응 없는 화면을 만들지 않기 위함 -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
|
||||
th:disabled="${pendingDeleteRequest}"
|
||||
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : 'API 이용 해지'">API 이용 해지</button>
|
||||
th:disabled="${pendingDeleteRequest}" th:title="${deleteBlockReason}"
|
||||
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : '이용 해지 신청'">이용 해지 신청</button>
|
||||
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:unless="${pendingDeleteRequest}" class="dt-btn-blue"
|
||||
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
|
||||
</div>
|
||||
@@ -297,19 +316,74 @@
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
|
||||
// API 이용 해지 신청 진입 - 사전검사(진행 중 결재 확인) 후 경고 + 사유 모달
|
||||
// (본인 확인은 step-up 2FA가 담당. 사전검사를 먼저 하는 이유: 어차피 거절될 요청으로
|
||||
// 2FA를 통과시킨 뒤 실패시키면, 재시도할 때마다 2FA가 반복된다)
|
||||
function deleteApiKeyFromButton(button) {
|
||||
var clientId = $(button).data('client-id');
|
||||
|
||||
customPopups.showTerminateRequest({
|
||||
onConfirm: function (reason) {
|
||||
doDeleteApiKey(clientId, reason);
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/credential/delete-precheck}]]*/ '/clients/credential/delete-precheck',
|
||||
type: 'GET',
|
||||
data: { clientId: clientId }
|
||||
}).done(function (res) {
|
||||
if (res && res.blocked) {
|
||||
customPopups.showAlert(res.msg || '현재 해지를 신청할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showTerminateRequest({
|
||||
onConfirm: function (reason) {
|
||||
doDeleteApiKey(clientId, reason);
|
||||
}
|
||||
});
|
||||
}).fail(function () {
|
||||
customPopups.showAlert('해지 가능 여부 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
});
|
||||
}
|
||||
|
||||
// 진행 중 변경/해지 신청 취소 — 결재가 개시되지 않아 관리자 결재함에 뜨지 않는 신청도 여기서 정리한다
|
||||
function cancelOpenRequest(button) {
|
||||
var requestId = $(button).data('request-id');
|
||||
|
||||
customPopups.showConfirm('진행 중인 신청을 취소하시겠습니까?', function (ok) {
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
$('.loading-overlay').show();
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/api_key_request/cancel}]]*/ '/clients/api_key_request/cancel',
|
||||
type: 'POST',
|
||||
data: { id: requestId },
|
||||
dataType: 'json',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success) {
|
||||
customPopups.showAlert(response.message || '신청이 취소되었습니다.', function () {
|
||||
window.location.reload();
|
||||
});
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert((response && response.message) || '신청 취소 중 오류가 발생했습니다.');
|
||||
}).fail(function () {
|
||||
customPopups.showAlert('신청 취소 중 오류가 발생했습니다.');
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 해지 신청 진행 중 플래그 — 중복 클릭 시 두 번째 요청이 통과권(1회용) 없이 나가
|
||||
// 2FA가 다시 뜨는 것을 막는다
|
||||
var deleteRequestInFlight = false;
|
||||
|
||||
// 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId, reason) {
|
||||
if (deleteRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
deleteRequestInFlight = true;
|
||||
$('.loading-overlay').show();
|
||||
|
||||
$.ajax({
|
||||
@@ -322,7 +396,10 @@
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
|
||||
// 실패 사유(결재 진행 중·승인라인 미등록 등)는 재시도해도 그대로다.
|
||||
// 모달을 열어둔 채 인라인 문구만 보여주면 사용자가 다시 확인을 눌러 2FA만 반복되므로 닫고 안내한다.
|
||||
customPopups.hideTerminateRequest();
|
||||
customPopups.showAlert(response.msg || '해지 신청에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.hideTerminateRequest();
|
||||
@@ -336,6 +413,7 @@
|
||||
}
|
||||
customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
deleteRequestInFlight = false;
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,8 +169,8 @@
|
||||
|
||||
<div class="oauth2-2legged__endpoint-box">
|
||||
<span class="oauth2-2legged__method">POST</span>
|
||||
<code
|
||||
class="oauth2-2legged__endpoint-path">https://openapi.djbank.co.kr/dj/oauth/token</code>
|
||||
<code class="oauth2-2legged__endpoint-path"
|
||||
th:text="|${guideGwUrl}/dj/oauth/token|">https://openapi.jejubank.co.kr/dj/oauth/token</code>
|
||||
<span class="oauth2-2legged__endpoint-content-type">x-www-form-urlencoded</span>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">cURL</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 액세스 토큰 발급</span>
|
||||
curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi.djbank.co.kr/dj/oauth/token'</span> \
|
||||
curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'[[${guideGwUrl}]]/dj/oauth/token'</span> \
|
||||
-H <span class="o2leg-c">'Content-Type: application/x-www-form-urlencoded'</span> \
|
||||
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
||||
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
||||
@@ -323,7 +323,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
||||
<span class="oauth2-2legged__code-tag">cURL</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
||||
curl -X <span class="o2leg-y">GET</span> \
|
||||
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
||||
<span class="o2leg-c">'[[${guideGwUrl}]]/api/v1/accounts'</span> \
|
||||
-H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
|
||||
-H <span class="o2leg-c">'Accept: application/json'</span>
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Event</code></td>
|
||||
<td>이벤트 코드 (예: CONTROL_START)</td>
|
||||
<td>이벤트 코드 (예: CHECK_START)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Timestamp</code></td>
|
||||
@@ -219,7 +219,7 @@
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
|
||||
<pre class="oauth2-2legged__code-block">{
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
|
||||
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
||||
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
||||
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
||||
@@ -341,11 +341,11 @@ valid = constantTimeEquals(received, expected)</pre>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>CONTROL_START</code></td>
|
||||
<td><code>CHECK_START</code></td>
|
||||
<td>점검 시작</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>CONTROL_END</code></td>
|
||||
<td><code>CHECK_END</code></td>
|
||||
<td>점검 종료</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -414,7 +414,6 @@ valid = constantTimeEquals(received, expected)</pre>
|
||||
</table>
|
||||
<p class="oauth2-2legged__warning">⚠ 5xx 응답·타임아웃과 네트워크 오류는 <strong>일정 간격을 두고
|
||||
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
|
||||
>>>>>>> 9981459691c836bfd33b6b81a8e6aa22ca12446a
|
||||
|
||||
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
|
||||
<ul class="oauth2-2legged__tips">
|
||||
@@ -434,7 +433,7 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"OK"</span>,
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>
|
||||
}
|
||||
|
||||
<span class="o2leg-cm"># 서명 검증 실패</span>
|
||||
|
||||