Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 | |||
| f1d6181210 | |||
| bac9a54dee | |||
| 603cba65a7 | |||
| 889d77b990 | |||
| bf2dad6ba7 | |||
| 3a1b0cb9c1 | |||
| a13279a777 | |||
| 5299352235 | |||
| 8b4bc425ec | |||
| 06dc148515 | |||
| 3cfd6b0b04 | |||
| 7a0f88bfb1 | |||
| 6d7b292b8a | |||
| 20ad6f8394 | |||
| 2bae0e350c | |||
| 45e8fa2903 |
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
|
|||||||
|
|
||||||
설정: `config/PortalDatasourceConfiguration.java`
|
설정: `config/PortalDatasourceConfiguration.java`
|
||||||
- 각 데이터베이스별 별도 EntityManager
|
- 각 데이터베이스별 별도 EntityManager
|
||||||
- Atomikos JTA를 통한 분산 트랜잭션
|
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
|
||||||
- 트랜잭션 로그: `/Log/eapim/portal/`
|
- `transactionManager` (@Primary) → EMS
|
||||||
|
- `gatewayTransactionManager` → Gateway
|
||||||
|
|
||||||
### 설정 프로파일
|
### 설정 프로파일
|
||||||
|
|
||||||
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
|
|||||||
적절한 propagation과 함께 `@Transactional` 사용:
|
적절한 propagation과 함께 `@Transactional` 사용:
|
||||||
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
|
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
|
||||||
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
|
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
|
||||||
- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리
|
- **다중 데이터베이스: 분산 트랜잭션(JTA/XA) 없음.** EMS·Gateway 각각 독립 로컬 트랜잭션이다.
|
||||||
|
- 무지정 `@Transactional` = EMS(`transactionManager`)
|
||||||
|
- **Gateway 엔티티(`com.eactive.apim.gateway.*`, `com.eactive.eai.data.entity.onl.*`)를 다루는 서비스는
|
||||||
|
`@Transactional("gatewayTransactionManager")` 를 명시**한다. 안 하면 게이트웨이 EntityManager 가
|
||||||
|
리포지토리 호출 단위로 닫혀 지연 로딩에서 `LazyInitializationException` 이 난다
|
||||||
|
(예: `ApiServiceService` — `ApiGroup.apiGroupApiList`).
|
||||||
|
- 두 DB 를 한 원자 단위로 묶어야 하는 작업은 만들지 않는다. 현재 Gateway 는 조회 전용이다.
|
||||||
|
|
||||||
### 에러 처리
|
### 에러 처리
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
// 보안 검사 통합 파이프라인 — OWASP Dependency-Check(SCA) + SonarQube(SAST).
|
||||||
|
//
|
||||||
|
// 두 검사는 보는 대상이 다르다. 하나만 돌리면 절반만 본다.
|
||||||
|
// SonarQube : 우리가 쓴 코드의 결함 (SQL Injection, XSS, 하드코딩 시크릿 …)
|
||||||
|
// Dependency-Check : 우리가 쓰는 라이브러리의 알려진 취약점 (CVE)
|
||||||
|
//
|
||||||
|
// 전제 (Jenkins 쪽 설정 이름이 다르면 environment 블록만 고치면 된다)
|
||||||
|
// 1) Manage Jenkins > System > SonarQube servers 에 서버 등록 (이름: SONAR_ENV_NAME)
|
||||||
|
// 2) Manage Jenkins > Tools > SonarQube Scanner 에 스캐너 등록 (없으면 PATH 탐색)
|
||||||
|
// 3) 노드에 Dependency-Check CLI 설치 (DC_HOME) — 설치 방법은 17-보안 가이드 문서 참고
|
||||||
|
// 4) NVD API Key 를 Secret text credential 로 등록 (기본 ID: nvd-api-key)
|
||||||
|
// 키가 없으면 NVD 갱신이 극심하게 느려진다(수 시간). 폐쇄망은 UPDATE_NVD=false 로 운영.
|
||||||
|
// 5) Quality Gate 대기를 쓰려면 SonarQube 에 <JENKINS_URL>/sonarqube-webhook/ 웹훅 등록
|
||||||
|
//
|
||||||
|
// JDK 분리: 빌드는 JDK 8, 스캐너/Dependency-Check 실행은 JDK 17.
|
||||||
|
// (SonarScanner CLI 5.x 는 JRE 17 필수, Dependency-Check 9.x 이상은 JRE 11 이상 필수)
|
||||||
|
pipeline {
|
||||||
|
agent { label 'djb-vm' }
|
||||||
|
|
||||||
|
triggers {
|
||||||
|
// 코드 변경 감지
|
||||||
|
pollSCM('H/30 * * * *')
|
||||||
|
// 코드가 그대로여도 새 CVE 는 계속 공개된다 → 야간 정기 재검사
|
||||||
|
cron('H 3 * * *')
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
booleanParam(
|
||||||
|
name: 'RUN_DEPENDENCY_CHECK',
|
||||||
|
defaultValue: true,
|
||||||
|
description: 'OWASP Dependency-Check(SCA) 실행'
|
||||||
|
)
|
||||||
|
booleanParam(
|
||||||
|
name: 'RUN_SONAR',
|
||||||
|
defaultValue: true,
|
||||||
|
description: 'SonarQube 정적 분석(SAST) 실행'
|
||||||
|
)
|
||||||
|
booleanParam(
|
||||||
|
name: 'RUN_TESTS',
|
||||||
|
defaultValue: false,
|
||||||
|
description: '단위 테스트를 함께 실행해 JUnit 결과를 Sonar 로 전송한다(분석 시간 증가).'
|
||||||
|
)
|
||||||
|
booleanParam(
|
||||||
|
name: 'UPDATE_NVD',
|
||||||
|
defaultValue: true,
|
||||||
|
description: 'NVD 취약점 DB 갱신. 폐쇄망(외부 인터넷 불가)이면 반드시 끈다 — 캐시된 DB 로만 검사한다.'
|
||||||
|
)
|
||||||
|
string(
|
||||||
|
name: 'FAIL_ON_CVSS',
|
||||||
|
defaultValue: '11',
|
||||||
|
description: '이 CVSS 점수 이상이면 검사 실패로 표시. 11 = 실패시키지 않음(리포트만). 예: 7 = High 이상'
|
||||||
|
)
|
||||||
|
booleanParam(
|
||||||
|
name: 'FAIL_BUILD_ON_FINDING',
|
||||||
|
defaultValue: false,
|
||||||
|
description: 'FAIL_ON_CVSS 위반 시 빌드를 FAILURE 로 만든다. 끄면 UNSTABLE 로만 표시.'
|
||||||
|
)
|
||||||
|
booleanParam(
|
||||||
|
name: 'SCAN_JS',
|
||||||
|
defaultValue: true,
|
||||||
|
description: '정적 JS 라이브러리(static/js, static/plugins)도 RetireJS 로 검사. 폐쇄망에서 오류나면 끈다.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// --- Jenkins 설정 이름 (환경에 맞게 수정) ---
|
||||||
|
SONAR_ENV_NAME = 'SonarQube-Community'
|
||||||
|
SONAR_SCANNER_TOOL = ''
|
||||||
|
// NVD API Key 를 담은 Secret text credential ID. 없으면 키 없이 진행한다(느림).
|
||||||
|
NVD_API_KEY_CRED = 'nvd-api-key'
|
||||||
|
|
||||||
|
// --- 툴체인 ---
|
||||||
|
JAVA_HOME = '/apps/opts/jdk8' // gradle 컴파일용 (프로젝트는 Java 8)
|
||||||
|
JAVA_HOME_SCANNER = '/apps/opts/jdk17' // sonar-scanner / dependency-check 실행용
|
||||||
|
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'
|
||||||
|
|
||||||
|
// --- Dependency-Check ---
|
||||||
|
// CLI 설치 경로. 비어 있거나 없으면 Jenkins Tools 등록분 > PATH 순으로 탐색한다.
|
||||||
|
DC_HOME = '/apps/opts/dependency-check'
|
||||||
|
// NVD 캐시(H2 DB). 워크스페이스 밖에 두어야 빌드마다 1GB 이상을 다시 받지 않는다.
|
||||||
|
// 여러 Job 이 공유하므로 disableConcurrentBuilds 를 켠 채로 쓴다.
|
||||||
|
DC_DATA = '/apps/opts/dependency-check-data'
|
||||||
|
DC_TOOL_NAME = ''
|
||||||
|
DC_REPORT_DIR = 'build/reports/dependency-check'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eapim-portal 은 elink-portal-common / elink-online-core-jpa 를 subproject 로 참조한다.
|
||||||
|
// 이 둘이 없으면 컴파일도 의존성 해석도 불가능하다.
|
||||||
|
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/dependency-check JDK ---"
|
||||||
|
if [ ! -x "$JAVA_HOME_SCANNER/bin/java" ]; then
|
||||||
|
echo "JDK 17 이 없다: $JAVA_HOME_SCANNER"
|
||||||
|
echo "SonarScanner 는 JRE 17, Dependency-Check 는 JRE 11 이상을 요구한다."
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모은다.
|
||||||
|
// 이게 없으면 Dependency-Check 가 스캔할 jar 가 없어 "취약점 0건" 이라는 거짓 안심을 준다.
|
||||||
|
stage('Export dependency jars') {
|
||||||
|
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||||
|
steps {
|
||||||
|
sh 'gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon'
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
COUNT=$(find build/dependency-check/libs -name '*.jar' -type f | wc -l | tr -d ' ')
|
||||||
|
echo "scan target jars: $COUNT"
|
||||||
|
if [ "$COUNT" -eq 0 ]; then
|
||||||
|
echo "수집된 jar 가 0개다. 의존성 해석이 실패했는지 확인할 것."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('OWASP Dependency-Check') {
|
||||||
|
when { expression { return params.RUN_DEPENDENCY_CHECK } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
// CLI 위치 결정: DC_HOME > Jenkins Tools 등록분 > PATH
|
||||||
|
def dcHome = null
|
||||||
|
if (env.DC_HOME?.trim() && fileExists("${env.DC_HOME}/bin/dependency-check.sh")) {
|
||||||
|
dcHome = env.DC_HOME.trim()
|
||||||
|
echo "Dependency-Check: ${dcHome} (DC_HOME)"
|
||||||
|
} else {
|
||||||
|
def candidates = env.DC_TOOL_NAME?.trim()
|
||||||
|
? [env.DC_TOOL_NAME.trim()]
|
||||||
|
: ['dependency-check', 'Dependency-Check', 'OWASP Dependency-Check', 'dependency-check-cli']
|
||||||
|
for (name in candidates) {
|
||||||
|
try {
|
||||||
|
dcHome = tool name: name,
|
||||||
|
type: 'org.jenkinsci.plugins.DependencyCheck.tools.DependencyCheckInstallation'
|
||||||
|
echo "Dependency-Check tool: '${name}' -> ${dcHome}"
|
||||||
|
break
|
||||||
|
} catch (ignored) {
|
||||||
|
// 등록되지 않은 이름은 건너뛴다
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dcHome == null) {
|
||||||
|
echo 'Jenkins Tools 에 등록된 Dependency-Check 가 없다. PATH 의 dependency-check.sh 로 진행한다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NVD API Key credential 이 있는지 먼저 확인한다.
|
||||||
|
// (없는 credential 로 withCredentials 를 감싸면 파이프라인 자체가 죽는다)
|
||||||
|
def hasNvdKey = true
|
||||||
|
try {
|
||||||
|
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_PROBE')]) {
|
||||||
|
// 존재 확인만 한다
|
||||||
|
}
|
||||||
|
} catch (ignored) {
|
||||||
|
hasNvdKey = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNvdKey) {
|
||||||
|
echo "NVD API Key credential('${env.NVD_API_KEY_CRED}') 이 없다. 키 없이 진행한다."
|
||||||
|
echo 'NVD 갱신이 매우 느려진다(수 시간). https://nvd.nist.gov/developers/request-an-api-key 에서 발급 권장.'
|
||||||
|
}
|
||||||
|
|
||||||
|
def dcScript = '''
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Dependency-Check 는 JRE 11 이상 필요 (빌드용 JDK 8 과 분리)
|
||||||
|
export JAVA_HOME="$JAVA_HOME_SCANNER"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
mkdir -p "$DC_DATA" "$DC_REPORT_DIR"
|
||||||
|
|
||||||
|
if [ -n "${DC_RESOLVED_HOME:-}" ] && [ -x "$DC_RESOLVED_HOME/bin/dependency-check.sh" ]; then
|
||||||
|
DC="$DC_RESOLVED_HOME/bin/dependency-check.sh"
|
||||||
|
elif command -v dependency-check.sh >/dev/null 2>&1; then
|
||||||
|
DC="$(command -v dependency-check.sh)"
|
||||||
|
else
|
||||||
|
echo "dependency-check.sh 를 찾지 못했다."
|
||||||
|
echo "노드에 CLI 를 설치하고 DC_HOME 을 맞추거나, Manage Jenkins > Tools 에 등록할 것."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dependency-check: $DC"
|
||||||
|
|
||||||
|
ARGS=""
|
||||||
|
|
||||||
|
# 폐쇄망/오프라인: 캐시된 NVD DB 로만 검사한다.
|
||||||
|
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||||
|
ARGS="$ARGS --noupdate"
|
||||||
|
if [ ! -d "$DC_DATA" ] || [ -z "$(ls -A "$DC_DATA" 2>/dev/null)" ]; then
|
||||||
|
echo "NVD 캐시가 비어 있는데 갱신이 꺼져 있다: $DC_DATA"
|
||||||
|
echo "인터넷 되는 곳에서 1회 적재한 data 디렉터리를 복사해 둘 것."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${NVD_API_KEY:-}" ]; then
|
||||||
|
ARGS="$ARGS --nvdApiKey ${NVD_API_KEY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# OSS Index 분석기는 외부 서비스(ossindex.sonatype.org)를 호출한다.
|
||||||
|
# 갱신을 끈 환경(=외부 통신 불가)에서는 같이 끈다.
|
||||||
|
if [ "${UPDATE_NVD_FLAG}" != "true" ]; then
|
||||||
|
ARGS="$ARGS --disableOssIndex"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 자바 프로젝트에 불필요한 분석기 — 실행 시간만 늘린다.
|
||||||
|
ARGS="$ARGS --disableAssembly --disableNodeAudit --disableNodeJS"
|
||||||
|
|
||||||
|
SCAN_ARGS="--scan build/dependency-check/libs"
|
||||||
|
if [ "${SCAN_JS_FLAG}" = "true" ]; then
|
||||||
|
# 번들된 JS 라이브러리(jQuery, summernote 등)의 알려진 취약점 — RetireJS
|
||||||
|
[ -d src/main/resources/static/js ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/js"
|
||||||
|
[ -d src/main/resources/static/plugins ] && SCAN_ARGS="$SCAN_ARGS --scan src/main/resources/static/plugins"
|
||||||
|
else
|
||||||
|
ARGS="$ARGS --disableRetireJS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUPPRESSION=""
|
||||||
|
if [ -f ci/dependency-check-suppressions.xml ]; then
|
||||||
|
SUPPRESSION="--suppression ci/dependency-check-suppressions.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 토큰/키가 콘솔에 남지 않도록 실행 명령 자체는 출력하지 않는다.
|
||||||
|
set +x
|
||||||
|
"$DC" \
|
||||||
|
--project "DJB eAPIM Portal" \
|
||||||
|
$SCAN_ARGS \
|
||||||
|
--out "$DC_REPORT_DIR" \
|
||||||
|
--format HTML --format XML --format JSON \
|
||||||
|
--data "$DC_DATA" \
|
||||||
|
--failOnCVSS "${FAIL_ON_CVSS_VALUE}" \
|
||||||
|
$SUPPRESSION \
|
||||||
|
$ARGS
|
||||||
|
'''
|
||||||
|
|
||||||
|
def dcEnv = [
|
||||||
|
"DC_RESOLVED_HOME=${dcHome ?: ''}",
|
||||||
|
"UPDATE_NVD_FLAG=${params.UPDATE_NVD}",
|
||||||
|
"SCAN_JS_FLAG=${params.SCAN_JS}",
|
||||||
|
"FAIL_ON_CVSS_VALUE=${params.FAIL_ON_CVSS}"
|
||||||
|
]
|
||||||
|
|
||||||
|
// 정책: 기본은 UNSTABLE 로만 표시하고 뒤의 Sonar 분석까지 마친다.
|
||||||
|
// FAIL_BUILD_ON_FINDING 을 켜면 임계치 위반이 빌드 실패가 된다.
|
||||||
|
def onFinding = params.FAIL_BUILD_ON_FINDING ? 'FAILURE' : 'UNSTABLE'
|
||||||
|
catchError(buildResult: onFinding, stageResult: 'FAILURE') {
|
||||||
|
withEnv(dcEnv) {
|
||||||
|
if (hasNvdKey) {
|
||||||
|
withCredentials([string(credentialsId: env.NVD_API_KEY_CRED, variable: 'NVD_API_KEY')]) {
|
||||||
|
sh dcScript
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sh dcScript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
archiveArtifacts allowEmptyArchive: true,
|
||||||
|
artifacts: 'build/reports/dependency-check/dependency-check-report.*'
|
||||||
|
script {
|
||||||
|
// OWASP Dependency-Check 플러그인이 설치돼 있으면 추이 그래프/경고를 남긴다.
|
||||||
|
// 없어도 리포트는 위에서 아카이브되므로 실패로 보지 않는다.
|
||||||
|
try {
|
||||||
|
dependencyCheckPublisher pattern: 'build/reports/dependency-check/dependency-check-report.xml'
|
||||||
|
} catch (ignored) {
|
||||||
|
echo 'OWASP Dependency-Check 플러그인 미설치 — 아카이브된 HTML 리포트로 확인할 것.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sonar.java.libraries 용 클래스패스 덤프.
|
||||||
|
// 없으면 타입 해석이 안 돼 보안 룰 다수가 침묵하므로 실패 시 UNSTABLE 로만 넘기고 분석은 계속한다.
|
||||||
|
stage('Export analysis classpath') {
|
||||||
|
when { expression { return params.RUN_SONAR } }
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||||
|
sh 'gradle -I ci/sonar-classpath.gradle exportSonarClasspath --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('SonarQube analysis') {
|
||||||
|
when { expression { return params.RUN_SONAR } }
|
||||||
|
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)에 노출시키지 않고 환경변수로만 넘긴다.
|
||||||
|
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
|
||||||
|
|
||||||
|
# SonarQube 에 Dependency-Check 플러그인이 설치돼 있으면 CVE 결과도 함께 올린다.
|
||||||
|
# 플러그인이 없으면 스캐너가 모르는 속성으로 무시한다(경고만).
|
||||||
|
DC_ARG=""
|
||||||
|
if [ -f "$DC_REPORT_DIR/dependency-check-report.json" ]; then
|
||||||
|
DC_ARG="-Dsonar.dependencyCheck.jsonReportPath=$DC_REPORT_DIR/dependency-check-report.json"
|
||||||
|
if [ -f "$DC_REPORT_DIR/dependency-check-report.html" ]; then
|
||||||
|
DC_ARG="$DC_ARG -Dsonar.dependencyCheck.htmlReportPath=$DC_REPORT_DIR/dependency-check-report.html"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 \
|
||||||
|
$DC_ARG
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SonarQube 웹훅으로 게이트 결과를 받아 판정한다.
|
||||||
|
// 정책: 게이트 실패는 UNSTABLE 로만 표시하고 빌드는 실패시키지 않는다.
|
||||||
|
stage('Quality Gate') {
|
||||||
|
when { expression { return params.RUN_SONAR } }
|
||||||
|
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())}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fileExists('build/reports/dependency-check/dependency-check-report.html')) {
|
||||||
|
echo "Dependency-Check 리포트: ${env.BUILD_URL}artifact/build/reports/dependency-check/dependency-check-report.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ dependencies {
|
|||||||
// implementation project(':kjb-safedb')
|
// implementation project(':kjb-safedb')
|
||||||
|
|
||||||
implementation('org.springframework.boot:spring-boot-starter')
|
implementation('org.springframework.boot:spring-boot-starter')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
|
|
||||||
implementation('org.springframework.boot:spring-boot-starter-web')
|
implementation('org.springframework.boot:spring-boot-starter-web')
|
||||||
implementation('org.springframework.boot:spring-boot-starter-validation')
|
implementation('org.springframework.boot:spring-boot-starter-validation')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// OWASP Dependency-Check 가 스캔할 서드파티 jar 를 한 디렉터리로 모은다.
|
||||||
|
//
|
||||||
|
// build.gradle 을 건드리지 않기 위해 init script(-I) 로만 주입한다.
|
||||||
|
// gradle -I ci/dependency-check-classpath.gradle exportDependencyJars --no-daemon
|
||||||
|
//
|
||||||
|
// 출력:
|
||||||
|
// build/dependency-check/libs/*.jar runtimeClasspath 의 외부 의존 jar (원본 파일명 유지)
|
||||||
|
// build/dependency-check/jars.txt 수집 목록(경로 1줄씩) — 진단용
|
||||||
|
//
|
||||||
|
// 왜 디렉터리로 모으는가
|
||||||
|
// Dependency-Check CLI 는 --scan <경로> 로 파일/디렉터리를 받는다. Gradle 캐시를 통째로 스캔하면
|
||||||
|
// 이 프로젝트가 쓰지 않는 버전까지 잡히고, 프로젝트 디렉터리만 스캔하면 의존 jar 가 아예 안 잡힌다.
|
||||||
|
// 실제 배포본에 실리는 것과 같은 목록(runtimeClasspath)만 모아야 결과가 배포 산출물과 일치한다.
|
||||||
|
//
|
||||||
|
// 파일명을 유지해야 하는 이유
|
||||||
|
// Dependency-Check 는 jar 안의 POM/MANIFEST 외에 파일명에서도 CPE(제품/버전)를 추론한다.
|
||||||
|
// 이름을 바꾸면 탐지 정확도가 떨어진다.
|
||||||
|
|
||||||
|
rootProject { project ->
|
||||||
|
project.plugins.withId('java') {
|
||||||
|
project.tasks.register('exportDependencyJars') {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Dependency-Check 스캔용 런타임 의존 jar 를 build/dependency-check/libs 에 모은다'
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
def outRoot = new File(project.layout.buildDirectory.get().asFile, 'dependency-check')
|
||||||
|
def outDir = new File(outRoot, 'libs')
|
||||||
|
project.delete(outDir)
|
||||||
|
outDir.mkdirs()
|
||||||
|
|
||||||
|
// 컴포지트/서브프로젝트의 build 디렉터리 산출물(우리가 만든 jar)은 제외한다.
|
||||||
|
// 자체 코드는 SonarQube 가 보는 영역이고, SCA 대상은 외부 라이브러리다.
|
||||||
|
def buildDirs = project.allprojects.collect {
|
||||||
|
it.layout.buildDirectory.get().asFile.absolutePath
|
||||||
|
}
|
||||||
|
def isOwnArtifact = { File f ->
|
||||||
|
buildDirs.any { f.absolutePath.startsWith(it + File.separator) }
|
||||||
|
}
|
||||||
|
|
||||||
|
def jars = project.configurations.runtimeClasspath.files
|
||||||
|
.findAll { it.isFile() && it.name.endsWith('.jar') && !isOwnArtifact(it) }
|
||||||
|
.unique()
|
||||||
|
.sort { it.name }
|
||||||
|
|
||||||
|
project.copy {
|
||||||
|
from jars
|
||||||
|
into outDir
|
||||||
|
}
|
||||||
|
|
||||||
|
new File(outRoot, 'jars.txt').text =
|
||||||
|
jars.collect { it.absolutePath }.join(System.lineSeparator()) + System.lineSeparator()
|
||||||
|
|
||||||
|
def totalMb = (jars.sum { it.length() } ?: 0L) / (1024 * 1024)
|
||||||
|
logger.lifecycle("dependency-check scan target: ${jars.size()} jars, ${totalMb as int} MB -> ${outDir}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
OWASP Dependency-Check 오탐(false positive) 억제 목록.
|
||||||
|
|
||||||
|
규칙
|
||||||
|
1. 억제는 "오탐"에만 쓴다. 진짜 취약점을 조용히 숨기는 용도로 쓰지 않는다.
|
||||||
|
실제 취약하지만 당장 못 올리는 경우는 억제 대신 만료일(until)을 넣어 재검토를 강제한다.
|
||||||
|
2. 항목마다 <notes> 에 판단 근거와 판단자/일자를 남긴다. 근거 없는 억제는 리뷰에서 거절한다.
|
||||||
|
3. 범위를 좁게 잡는다. cve 단건 + 특정 파일(sha1/packageUrl)이 기본이고,
|
||||||
|
cpe 나 정규식 filePath 로 넓게 억제하지 않는다.
|
||||||
|
|
||||||
|
작성법
|
||||||
|
Dependency-Check HTML 리포트의 각 취약점 옆 "Suppress" 버튼을 누르면 해당 항목의
|
||||||
|
<suppress> 블록이 그대로 생성된다. 그것을 이 파일에 붙여넣고 <notes> 만 채우면 된다.
|
||||||
|
|
||||||
|
참고: https://dependency-check.github.io/DependencyCheck/general/suppression.html
|
||||||
|
-->
|
||||||
|
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
|
||||||
|
|
||||||
|
<!-- 예시 (실제 억제 시 주석을 풀고 값 교체)
|
||||||
|
<suppress until="2026-12-31Z">
|
||||||
|
<notes><![CDATA[
|
||||||
|
오탐 근거: 해당 CVE 는 X 기능을 사용할 때만 성립하는데 이 앱은 해당 API 를 호출하지 않음.
|
||||||
|
확인: 홍길동 / 2026-08-13 / 호출부 grep 결과 0건.
|
||||||
|
]]></notes>
|
||||||
|
<packageUrl regex="true">^pkg:maven/org\.example/example-lib@.*$</packageUrl>
|
||||||
|
<cve>CVE-2026-00000</cve>
|
||||||
|
</suppress>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</suppressions>
|
||||||
@@ -51,6 +51,14 @@ sonar.exclusions=\
|
|||||||
|
|
||||||
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
|
# 자동 생성 코드(MapStruct Impl, QueryDSL Q클래스)는 build/ 아래라 이미 제외된다.
|
||||||
|
|
||||||
|
# Thymeleaf 인라인 표현식([[${...}]], /*[[${...}]]*/)은 JS 파서가 읽지 못해
|
||||||
|
# "Failed to parse file ... Unexpected token" 으로 분석이 중단된다.
|
||||||
|
# JS 분석에서만 빼고 HTML(web) 센서는 그대로 두어 템플릿 XSS 룰은 유지한다.
|
||||||
|
# 기본값이 **/node_modules/** 이므로 재정의 시 함께 명시해야 한다.
|
||||||
|
sonar.javascript.exclusions=\
|
||||||
|
**/node_modules/**,\
|
||||||
|
src/main/resources/templates/**
|
||||||
|
|
||||||
# --- SCM -------------------------------------------------------------------
|
# --- SCM -------------------------------------------------------------------
|
||||||
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
|
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
|
||||||
sonar.scm.provider=git
|
sonar.scm.provider=git
|
||||||
|
|||||||
+6
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
|
||||||
|
*/
|
||||||
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
|
||||||
|
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.web.PageableDefault;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.ui.ModelMap;
|
import org.springframework.ui.ModelMap;
|
||||||
@@ -79,12 +83,17 @@ public class ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
|
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
|
||||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||||
|
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
|
||||||
|
|
||||||
model.addAttribute("search", search);
|
model.addAttribute("search", search);
|
||||||
model.addAttribute("services", searchResult.get("services"));
|
model.addAttribute("services", searchResult.get("services"));
|
||||||
model.addAttribute("apis", searchResult.get("apis"));
|
model.addAttribute("apis", apiPage.getContent());
|
||||||
|
model.addAttribute("page", apiPage);
|
||||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||||
@@ -94,6 +103,19 @@ public class ApiController {
|
|||||||
return "apps/apis/mainApiList";
|
return "apps/apis/mainApiList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
|
||||||
|
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
|
||||||
|
*/
|
||||||
|
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||||
|
int total = apis == null ? 0 : apis.size();
|
||||||
|
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
||||||
|
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
||||||
|
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
|
return new PageImpl<>(content, pageable, total);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/testbed/api")
|
@GetMapping("/testbed/api")
|
||||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
|
|||||||
private String displayRoleCode;
|
private String displayRoleCode;
|
||||||
|
|
||||||
private String apiGroupName;
|
private String apiGroupName;
|
||||||
|
|
||||||
|
private String apiGroupId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
|
|||||||
filteredApis.forEach(api -> {
|
filteredApis.forEach(api -> {
|
||||||
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
api.setMainIcon(service.getMainIcon());
|
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
|
||||||
|
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
|
||||||
|
api.setApiGroupId(service.getId());
|
||||||
api.setApiGroupName(service.getGroupName());
|
api.setApiGroupName(service.getGroupName());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+37
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
|||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ApiServiceController {
|
public class ApiServiceController {
|
||||||
|
|
||||||
|
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
|
||||||
|
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
|
||||||
|
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
|
||||||
|
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
|
|
||||||
|
|
||||||
@@ -52,4 +65,28 @@ public class ApiServiceController {
|
|||||||
|
|
||||||
return "apps/apiservice/apiServiceDetail";
|
return "apps/apiservice/apiServiceDetail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
|
||||||
|
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
|
||||||
|
* 브라우저 캐시로 재사용시킨다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/icon")
|
||||||
|
public ResponseEntity<byte[]> icon(@PathVariable String id) {
|
||||||
|
String mainIcon = apiServiceService.getMainIcon(id);
|
||||||
|
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
|
||||||
|
|
||||||
|
if (matcher == null || !matcher.matches()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND)
|
||||||
|
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(matcher.group(1)))
|
||||||
|
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
|
||||||
|
.body(imageBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -22,8 +22,19 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 그룹(전시) 조회 서비스.
|
||||||
|
*
|
||||||
|
* <p>주 조회 대상 {@link ApiGroup} 은 <b>AGW(게이트웨이) 데이터소스</b> 엔티티라
|
||||||
|
* 트랜잭션 매니저를 {@code gatewayTransactionManager} 로 지정한다. 기본
|
||||||
|
* {@code transactionManager}(EMS) 를 쓰면 게이트웨이 EntityManager 가 리포지토리 호출 단위로
|
||||||
|
* 닫혀 {@code ApiGroup.apiGroupApiList} 지연 로딩에서 LazyInitializationException 이 난다.</p>
|
||||||
|
*
|
||||||
|
* <p>내부에서 호출하는 {@code ApiSpecInfoService} 는 EMS 쪽이며 자체 {@code @Transactional}
|
||||||
|
* (기본 매니저)로 독립 트랜잭션을 연다. {@code ApiSpecInfo} 에는 지연 연관이 없어 문제되지 않는다.</p>
|
||||||
|
*/
|
||||||
@Service("apiServiceService")
|
@Service("apiServiceService")
|
||||||
@Transactional
|
@Transactional("gatewayTransactionManager")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ApiServiceService {
|
public class ApiServiceService {
|
||||||
@@ -109,5 +120,14 @@ public class ApiServiceService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
|
||||||
|
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
|
||||||
|
*/
|
||||||
|
public String getMainIcon(String id) {
|
||||||
|
return apiServiceRepository.findById(id)
|
||||||
|
.map(ApiGroup::getMainIcon)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class AdminGatewayClient {
|
|||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||||
|
|
||||||
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
|
|||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base URL 끝의 {@code '/'} 를 모두 걷어낸다.
|
||||||
|
*
|
||||||
|
* <p>{@code replaceAll("/+$", "")} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 선형 스캔으로 대체했다. 동작은 동일하다.</p>
|
||||||
|
*/
|
||||||
|
private static String stripTrailingSlashes(String url) {
|
||||||
|
int end = url.length();
|
||||||
|
while (end > 0 && url.charAt(end - 1) == '/') {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
return url.substring(0, end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ public class ApprovalService {
|
|||||||
approval.setApprovalType(ApprovalType.APP);
|
approval.setApprovalType(ApprovalType.APP);
|
||||||
approval.setTargetId(request.getId());
|
approval.setTargetId(request.getId());
|
||||||
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
|
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
|
||||||
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
|
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
|
||||||
|
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
|
||||||
|
String clientName = request.getClientName();
|
||||||
|
String clientNamePart = clientName == null || clientName.trim().isEmpty()
|
||||||
|
? "" : " [" + clientName.trim() + "]";
|
||||||
|
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
|
||||||
|
+ " " + request.getType().getDescription() + " 승인");
|
||||||
|
|
||||||
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
|
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
|
||||||
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
|
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.apps.auth;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.core.env.Environment;
|
|
||||||
import org.springframework.core.env.Profiles;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,10 +10,18 @@ import org.springframework.stereotype.Component;
|
|||||||
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
||||||
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
||||||
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
||||||
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
|
* {@link com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties} 와 동일한
|
||||||
|
* {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||||
*
|
*
|
||||||
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
* <p><b>운영(prod)에서도 DB 값만으로 켤 수 있다.</b> 대신 종료일
|
||||||
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
* {@code auth.test-notice.prod-until}({@code yyyy-MM-dd})을 함께 지정해야 하며, 날짜가 지나면
|
||||||
|
* {@link TestNoticeWindow} 가 자동으로 닫는다. 종료일 미설정/형식 오류면 운영에서는 열리지 않는다.
|
||||||
|
* 운영 오픈 전 UMS 실발송 미연계 기간을 위한 한시 스위치다.</p>
|
||||||
|
*
|
||||||
|
* <p>이 판정은 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice} 의
|
||||||
|
* {@code @ModelAttribute} 에서 <b>모든 페이지 요청마다</b> 호출된다. PortalPropertyService 에 캐시가
|
||||||
|
* 없어 호출마다 DB 조회가 발생하므로 {@value #CACHE_TTL_MILLIS}ms 짧은 캐시를 둔다.
|
||||||
|
* PTL_PROPERTY 를 바꾸면 최대 그 시간만큼 반영이 늦다.</p>
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -23,21 +29,45 @@ public class AuthNoticeProperties {
|
|||||||
|
|
||||||
public static final String GROUP = "Portal";
|
public static final String GROUP = "Portal";
|
||||||
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||||
|
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "auth.test-notice.prod-until";
|
||||||
|
|
||||||
|
/** 판정 결과 캐시 유효시간(ms) */
|
||||||
|
static final long CACHE_TTL_MILLIS = 30_000L;
|
||||||
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
private final Environment environment;
|
private final TestNoticeWindow testNoticeWindow;
|
||||||
|
|
||||||
|
private volatile boolean cachedEnabled;
|
||||||
|
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
|
||||||
|
private volatile long cachedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||||
* prod 환경에서는 property 값과 무관하게 항상 false.
|
* 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
|
||||||
*/
|
*/
|
||||||
public boolean isTestNoticeEnabled() {
|
public boolean isTestNoticeEnabled() {
|
||||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
long now = System.currentTimeMillis();
|
||||||
|
long at = cachedAt;
|
||||||
|
if (at != 0L && now - at < CACHE_TTL_MILLIS) {
|
||||||
|
return cachedEnabled;
|
||||||
|
}
|
||||||
|
boolean enabled = resolveEnabled();
|
||||||
|
cachedEnabled = enabled;
|
||||||
|
cachedAt = now;
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean resolveEnabled() {
|
||||||
|
// 기본값 false - row 가 없는 환경(신규 운영 DB 등)에서 자동으로 켜지지 않도록 한다.
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, KEY_TEST_NOTICE_ENABLED, "false",
|
||||||
|
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||||
|
if (value == null || !"true".equalsIgnoreCase(value.trim())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String value = portalPropertyService.getOrCreateProperty(
|
String until = portalPropertyService.getOrCreateProperty(
|
||||||
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
"운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증번호 화면 노출(테스트 안내) 스위치의 <b>운영 환경 사용 기한</b> 판정기.
|
||||||
|
*
|
||||||
|
* <p>운영 오픈 전에는 UMS/EAI 실발송 연계가 끝나지 않아 인증번호가 실제로 도달하지 않을 수 있다.
|
||||||
|
* 이 기간에는 prod 에서도 인증번호를 화면에 노출해야 테스트가 가능하다. 다만 스위치 끄기를 잊으면
|
||||||
|
* 인증번호가 상시 노출되므로, prod 에서는 <b>종료일을 명시한 경우에만</b> 열어 주고 그 날짜가 지나면
|
||||||
|
* PTL_PROPERTY 값과 무관하게 자동으로 닫는다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>비운영(!prod) — 항상 열림. 종료일을 보지 않는다.</li>
|
||||||
|
* <li>운영(prod) — 종료일이 없거나 형식이 잘못되면 <b>닫힘</b>(fail-safe). 종료일 당일까지 열림.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>이메일/휴대폰 인증({@link AuthNoticeProperties})과 2차 인증
|
||||||
|
* ({@code TwoFactorProperties})이 같은 규칙을 쓰도록 판정을 이 클래스로 모은다.
|
||||||
|
* 종료일 프로퍼티 값은 {@code yyyy-MM-dd} 형식이며, 미설정을 뜻하는 기본값은 {@value #UNSET}
|
||||||
|
* ({@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 빈 문자열을 기본값으로 쓸 수 없다).</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class TestNoticeWindow {
|
||||||
|
|
||||||
|
/** 종료일 미설정을 뜻하는 기본값. prod 에서는 이 값이면 닫힘 */
|
||||||
|
public static final String UNSET = "none";
|
||||||
|
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
public TestNoticeWindow(Environment environment) {
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param untilValue 종료일 문자열({@code yyyy-MM-dd}). PTL_PROPERTY 값 원본
|
||||||
|
* @param subject 로그 식별용 스위치 이름
|
||||||
|
* @return 지금 인증번호를 화면에 노출해도 되는 기간인지 여부
|
||||||
|
*/
|
||||||
|
public boolean isOpen(String untilValue, String subject) {
|
||||||
|
if (!environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LocalDate until = parseDate(untilValue);
|
||||||
|
if (until == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (LocalDate.now().isAfter(until)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 운영에서 인증번호가 화면에 노출되는 상태 - 흔적을 남긴다.
|
||||||
|
log.warn("운영 환경 인증번호 노출 활성 상태 - {} (종료일 {})", subject, until);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(String value) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim());
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
log.warn("인증번호 노출 종료일 형식 오류(yyyy-MM-dd 필요) - {}", value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
private void validateResendTime(String recipientKey) {
|
private void validateResendTime(String recipientKey) {
|
||||||
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
|
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
|
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
|
||||||
.plusSeconds(resendLimitSeconds).isAfter(now)) {
|
.minusSeconds(authNumberExpirationTime)
|
||||||
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
|
.plusSeconds(resendLimitSeconds);
|
||||||
|
if (resendAvailableAt.isAfter(now)) {
|
||||||
|
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
|
||||||
|
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
|
||||||
|
throw new AuthNumberException(
|
||||||
|
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
|
||||||
|
remainingSeconds));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -1,9 +1,12 @@
|
|||||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,8 +33,14 @@ import javax.servlet.http.HttpSession;
|
|||||||
@RequestMapping("/auth/stepup")
|
@RequestMapping("/auth/stepup")
|
||||||
public class StepUpPasswordController {
|
public class StepUpPasswordController {
|
||||||
|
|
||||||
|
/** 비밀번호 재확인 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) */
|
||||||
|
private static final int MAX_FAIL_COUNT = 5;
|
||||||
|
/** 연속 실패 횟수 세션 attribute 키 */
|
||||||
|
private static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
|
||||||
|
|
||||||
private final UserFacade userFacade;
|
private final UserFacade userFacade;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
@GetMapping("/password")
|
@GetMapping("/password")
|
||||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||||
@@ -44,7 +55,8 @@ public class StepUpPasswordController {
|
|||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public String verify(@RequestParam String currentPassword,
|
public String verify(@RequestParam String currentPassword,
|
||||||
@RequestParam(required = false) String returnUrl,
|
@RequestParam(required = false) String returnUrl,
|
||||||
HttpSession session, Model model) {
|
HttpSession session, HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) {
|
||||||
String path = pathOf(returnUrl);
|
String path = pathOf(returnUrl);
|
||||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
@@ -52,16 +64,34 @@ public class StepUpPasswordController {
|
|||||||
|
|
||||||
String loginId = SecurityUtil.getCurrentLoginId();
|
String loginId = SecurityUtil.getCurrentLoginId();
|
||||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||||
|
session.removeAttribute(ATTR_FAIL_COUNT);
|
||||||
twoFactorService.grantStepUpPass(session, path);
|
twoFactorService.grantStepUpPass(session, path);
|
||||||
return "redirect:" + path;
|
return "redirect:" + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
// 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어).
|
||||||
|
int failCount = incrementFailCount(session);
|
||||||
|
if (failCount >= MAX_FAIL_COUNT) {
|
||||||
|
userSessionService.removeSession(session.getId());
|
||||||
|
new SecurityContextLogoutHandler().logout(request, response,
|
||||||
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
return "redirect:/login?pwFailExceeded=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("error",
|
||||||
|
"현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
|
||||||
model.addAttribute("returnUrl", path);
|
model.addAttribute("returnUrl", path);
|
||||||
return "apps/auth/stepupPassword";
|
return "apps/auth/stepupPassword";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int incrementFailCount(HttpSession session) {
|
||||||
|
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
|
||||||
|
int next = (count == null ? 0 : count) + 1;
|
||||||
|
session.setAttribute(ATTR_FAIL_COUNT, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||||
private static String pathOf(String url) {
|
private static String pathOf(String url) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.auth.TestNoticeWindow;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -25,9 +26,11 @@ public class TwoFactorProperties {
|
|||||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||||
|
public static final String KEY_TEST_NOTICE_PROD_UNTIL = "two-factor.test-notice.prod-until";
|
||||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||||
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final TestNoticeWindow testNoticeWindow;
|
||||||
|
|
||||||
/** 로그인 2FA 활성화 여부 */
|
/** 로그인 2FA 활성화 여부 */
|
||||||
public boolean isLoginEnabled() {
|
public boolean isLoginEnabled() {
|
||||||
@@ -91,9 +94,21 @@ public class TwoFactorProperties {
|
|||||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
/**
|
||||||
|
* 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용).
|
||||||
|
*
|
||||||
|
* <p>운영(prod)에서는 종료일 {@link #KEY_TEST_NOTICE_PROD_UNTIL}({@code yyyy-MM-dd})을 함께
|
||||||
|
* 지정한 경우에만 참이 되고, 날짜가 지나면 {@link TestNoticeWindow} 가 자동으로 닫는다.
|
||||||
|
* 종료일 미설정/형식 오류면 운영에서는 열리지 않는다. 이메일/휴대폰 인증
|
||||||
|
* ({@code auth.test-notice.*})과 동일한 규칙이다.</p>
|
||||||
|
*/
|
||||||
public boolean isTestNoticeEnabled() {
|
public boolean isTestNoticeEnabled() {
|
||||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
if (!parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String until = resolve(KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
|
||||||
|
"운영(prod)에서 2차 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
|
||||||
|
return testNoticeWindow.isOpen(until, KEY_TEST_NOTICE_ENABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolve(String key, String defaultValue, String description) {
|
private String resolve(String key, String defaultValue, String description) {
|
||||||
|
|||||||
+19
@@ -61,4 +61,23 @@ public class PartnershipApplicationController {
|
|||||||
return "redirect:/partnership";
|
return "redirect:/partnership";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인이 작성한 피드백/개선요청 1건 삭제.
|
||||||
|
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/delete")
|
||||||
|
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
|
return "redirect:/login?reason=auth&redirect=/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
partnershipApplicationFacade.deleteMyApplication(id);
|
||||||
|
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -3,9 +3,11 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
|
|||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
@@ -16,4 +18,20 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
|||||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
*/
|
*/
|
||||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
|
||||||
|
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
|
||||||
|
*/
|
||||||
|
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
|
||||||
|
|
||||||
|
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
|
||||||
|
@Transactional
|
||||||
|
long deleteByCreatedBy(String createdBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
|
||||||
|
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
|
||||||
|
*/
|
||||||
|
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
|||||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||||
*/
|
*/
|
||||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||||
|
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||||
|
*/
|
||||||
|
void deleteMyApplication(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -20,6 +20,7 @@ import java.util.Collections;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
||||||
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteMyApplication(String id) {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
if (user == null) {
|
||||||
|
throw new IllegalArgumentException("로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
|
||||||
|
PartnershipApplication target = partnershipApplicationService
|
||||||
|
.findOwnedByCreatedBy(id, user.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
|
||||||
|
|
||||||
|
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
|
||||||
|
if (StringUtils.isNotBlank(target.getFileId())) {
|
||||||
|
fileService.deleteFile(target.getFileId());
|
||||||
|
}
|
||||||
|
partnershipApplicationService.deletePartnershipApplication(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
|
|||||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
|
|||||||
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
||||||
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
|
||||||
|
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
|
||||||
|
partnershipApplicationRepository.delete(partnershipApplication);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -3,14 +3,22 @@ package com.eactive.apim.portal.apps.community.qna.repository;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
||||||
|
|
||||||
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquirer_Id(String inquirerId);
|
||||||
|
|
||||||
|
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
|
||||||
|
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
|
|||||||
* 인덱스 페이지 하단 통계 DTO
|
* 인덱스 페이지 하단 통계 DTO
|
||||||
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
|
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
|
||||||
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
|
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
|
||||||
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
|
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
|
|||||||
private int activeAppCount;
|
private int activeAppCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 이용 건수
|
* API 이용 건수 (월누적)
|
||||||
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총 수
|
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||||
*/
|
*/
|
||||||
private int totalApiCount;
|
private int totalApiCount;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-6
@@ -1,14 +1,20 @@
|
|||||||
package com.eactive.apim.portal.apps.main.service;
|
package com.eactive.apim.portal.apps.main.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.app.entity.Credential;
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||||
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
|
|||||||
|
|
||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
private final CredentialRepository credentialRepository;
|
private final CredentialRepository credentialRepository;
|
||||||
|
private final GwAuthClientRepository gwAuthClientRepository;
|
||||||
|
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||||
|
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
// 캐시된 통계 데이터
|
// 캐시된 통계 데이터
|
||||||
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
|
|||||||
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
|
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
|
||||||
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
|
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
|
||||||
|
|
||||||
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API 수
|
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건수
|
||||||
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
|
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
|
||||||
totalApiCount = activeApps.stream()
|
.filter(id -> id != null && !id.trim().isEmpty())
|
||||||
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
|
.collect(Collectors.toList());
|
||||||
.sum();
|
if (!clientIds.isEmpty()) {
|
||||||
|
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedStatistics = IndexStatisticsDTO.builder()
|
cachedStatistics = IndexStatisticsDTO.builder()
|
||||||
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
|
||||||
|
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
|
||||||
|
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
|
||||||
|
*/
|
||||||
|
private long getMonthlyApiCallCount(List<String> clientIds) {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate monthStart = today.withDayOfMonth(1);
|
||||||
|
LocalDate dayEnd = today.minusDays(1);
|
||||||
|
|
||||||
|
long total = 0L;
|
||||||
|
if (!monthStart.isAfter(dayEnd)) {
|
||||||
|
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
|
||||||
|
}
|
||||||
|
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
|
||||||
|
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
|
||||||
|
total += nz(todaySummary.getTotalCount());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nz(Long value) {
|
||||||
|
return value != null ? value : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PortalProperty에서 통계 노출 여부 조회
|
* PortalProperty에서 통계 노출 여부 조회
|
||||||
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
|
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/check_password_match")
|
@PostMapping("/check_password_match")
|
||||||
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
|
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
|
||||||
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
|
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register/confirm_password")
|
@PostMapping("/register/confirm_password")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
|||||||
|
|
||||||
|
|
||||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||||
@PasswordMatch(input = "password", confirm = "password2")
|
@PasswordMatch(input = "password", confirm = "confirmPassword")
|
||||||
@Data
|
@Data
|
||||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||||
public class PortalUserRegistrationDTO {
|
public class PortalUserRegistrationDTO {
|
||||||
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
|
|||||||
*/
|
*/
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
private String password2;
|
|
||||||
|
|
||||||
@CellPhone
|
@CellPhone
|
||||||
private String mobileNumber;
|
private String mobileNumber;
|
||||||
|
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
|
|||||||
public void withdrawUser(String userId, String withdrawalReason) {
|
public void withdrawUser(String userId, String withdrawalReason) {
|
||||||
PortalUser user = portalUserService.findById(userId);
|
PortalUser user = portalUserService.findById(userId);
|
||||||
|
|
||||||
// 법인 관리자 탈퇴 제한
|
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
|
||||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
throw new IllegalArgumentException(
|
||||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
|
||||||
}
|
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 정보 삭제
|
// 약관 동의 정보 삭제
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
|
|||||||
|
|
||||||
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
|
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
|
||||||
|
|
||||||
ValidationResponse checkPasswordMatch(String password, String password2);
|
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
|
||||||
|
|
||||||
ValidationResponse verifyPassword(String loginId, String confirmPassword);
|
ValidationResponse verifyPassword(String loginId, String confirmPassword);
|
||||||
|
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ValidationResponse checkPasswordMatch(String password, String password2) {
|
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
|
||||||
boolean isMatch = password.equals(password2);
|
boolean isMatch = password.equals(confirmPassword);
|
||||||
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
|
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
|
||||||
return new ValidationResponse(isMatch, message);
|
return new ValidationResponse(isMatch, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ public class PasswordService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||||
|
|
||||||
validatePasswordUpdate(user, newPassword, confirmPassword);
|
validatePasswordUpdate(user, newPassword, confirmPassword);
|
||||||
checkPasswordHistory(user.getLoginId(), newPassword);
|
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
|
||||||
|
checkPasswordHistory(user.getId(), newPassword);
|
||||||
|
|
||||||
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
|
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
|
||||||
if(histories.isEmpty()) {
|
if(histories.isEmpty()) {
|
||||||
@@ -85,6 +86,17 @@ public class PasswordService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
|
||||||
|
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
|
||||||
|
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
|
||||||
|
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
|
||||||
|
savePasswordHistory(userId, previousPasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
private void checkPasswordHistory(String userId, String newPassword) {
|
private void checkPasswordHistory(String userId, String newPassword) {
|
||||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
private final EncryptionUtil encryptionUtil;
|
||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
|
private final PasswordService passwordService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||||
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||||
return buildAuthenticatedUser(portalUser);
|
return buildAuthenticatedUser(portalUser);
|
||||||
} catch (UserNotFoundException e) {
|
} catch (UserNotFoundException e) {
|
||||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
|
||||||
|
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
|
||||||
|
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
|
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
|
||||||
|
|
||||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||||
|
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
|
||||||
|
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
|
||||||
|
// 것이 허용되는 보안 허점이 생긴다.
|
||||||
|
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
|
||||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ public class GlobalControllerAdvice {
|
|||||||
return clientGuardService.isDevtoolsGuardEnabled();
|
return clientGuardService.isDevtoolsGuardEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||||
|
*/
|
||||||
|
@ModelAttribute("activeProfileBadge")
|
||||||
|
public String activeProfileBadge() {
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] activeProfiles = environment.getActiveProfiles();
|
||||||
|
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
||||||
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
||||||
|
|||||||
-258
@@ -1,258 +0,0 @@
|
|||||||
package com.eactive.apim.portal.common.migration;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
|
||||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
|
||||||
*
|
|
||||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
|
||||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
|
||||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
|
||||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
|
||||||
*
|
|
||||||
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
|
||||||
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
|
||||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
|
||||||
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
|
||||||
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
|
||||||
* 작업 완료 후 이 클래스는 제거한다.</p>
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* # 미리보기(변경 안 함)
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
|
||||||
* # 실제 실행 (PII 컬럼)
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
|
||||||
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/internal/migration")
|
|
||||||
public class LegacyEncryptionMigrationController {
|
|
||||||
|
|
||||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
|
||||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
|
||||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
|
||||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
|
||||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
|
||||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
|
||||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
|
||||||
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
|
||||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
|
||||||
"PTL_USER", "ptl_faq", "ptl_org",
|
|
||||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
|
||||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
|
||||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
|
||||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
|
||||||
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
|
||||||
);
|
|
||||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
|
||||||
|
|
||||||
static final String PROP_GROUP = "Portal";
|
|
||||||
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
|
||||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
|
||||||
|
|
||||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
|
||||||
PortalPropertyService portalPropertyService) {
|
|
||||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
|
||||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
|
||||||
this.portalPropertyService = portalPropertyService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/encrypt-legacy")
|
|
||||||
@Transactional("transactionManager")
|
|
||||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
|
||||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
|
||||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
|
||||||
assertAllowedIp(request);
|
|
||||||
assertNotBypass();
|
|
||||||
|
|
||||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
|
||||||
if (includeAudit) {
|
|
||||||
for (String table : AUDIT_TABLES) {
|
|
||||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
|
||||||
int totalChanged = 0;
|
|
||||||
int totalSkipped = 0;
|
|
||||||
for (TargetTable target : targets) {
|
|
||||||
for (String column : target.columns) {
|
|
||||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
|
||||||
results.add(r);
|
|
||||||
totalChanged += (int) r.get("changed");
|
|
||||||
totalSkipped += (int) r.get("skipped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
|
||||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
|
||||||
response.put("damoMode", resolveDamoMode());
|
|
||||||
response.put("includeAudit", includeAudit);
|
|
||||||
response.put("totalChanged", totalChanged);
|
|
||||||
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
|
||||||
response.put("totalSkipped", totalSkipped);
|
|
||||||
response.put("results", results);
|
|
||||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
|
||||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveDamoMode() {
|
|
||||||
if (converter.isBypassMode()) {
|
|
||||||
return "BYPASS";
|
|
||||||
}
|
|
||||||
return converter.isFakeMode() ? "FAKE" : "REAL";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
|
||||||
Map<String, Object> r = new LinkedHashMap<>();
|
|
||||||
r.put("table", table);
|
|
||||||
r.put("column", column);
|
|
||||||
|
|
||||||
List<String> values;
|
|
||||||
try {
|
|
||||||
values = jdbcTemplate.queryForList(
|
|
||||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
|
||||||
String.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
|
||||||
r.put("distinct", 0);
|
|
||||||
r.put("changed", 0);
|
|
||||||
r.put("skipped", 0);
|
|
||||||
r.put("error", e.getMessage());
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
int changed = 0;
|
|
||||||
int skipped = 0;
|
|
||||||
for (String value : values) {
|
|
||||||
String normalized;
|
|
||||||
try {
|
|
||||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
|
||||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
|
||||||
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
|
||||||
skipped++;
|
|
||||||
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
|
||||||
table, column, value.length());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized != null && !normalized.equals(value)) {
|
|
||||||
if (!dryRun) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
|
||||||
normalized, value);
|
|
||||||
}
|
|
||||||
changed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.put("distinct", values.size());
|
|
||||||
r.put("changed", changed);
|
|
||||||
r.put("skipped", skipped);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
|
||||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
|
||||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
|
||||||
*/
|
|
||||||
private void assertNotBypass() {
|
|
||||||
if (converter.isBypassMode()) {
|
|
||||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
|
||||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
|
||||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
|
||||||
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
|
||||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
|
||||||
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
|
||||||
*/
|
|
||||||
private void assertAllowedIp(HttpServletRequest request) {
|
|
||||||
String remote = canonicalize(request.getRemoteAddr());
|
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
|
||||||
|
|
||||||
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(ip -> !ip.isEmpty())
|
|
||||||
.map(LegacyEncryptionMigrationController::canonicalize)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
if (viaProxy || !allowed.contains(remote)) {
|
|
||||||
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
|
||||||
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
|
||||||
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
|
||||||
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveAllowIps() {
|
|
||||||
try {
|
|
||||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
|
||||||
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
|
||||||
return DEFAULT_ALLOW_IPS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IPv6 loopback 표기 통일 */
|
|
||||||
private static String canonicalize(String ip) {
|
|
||||||
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class TargetTable {
|
|
||||||
final String table;
|
|
||||||
final List<String> columns;
|
|
||||||
|
|
||||||
TargetTable(String table, List<String> columns) {
|
|
||||||
this.table = table;
|
|
||||||
this.columns = columns;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import javax.naming.NamingException;
|
import javax.naming.NamingException;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
@@ -58,11 +59,11 @@ public class BaseDatasourceConfiguration {
|
|||||||
//public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고
|
//public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고
|
||||||
properties.put("hibernate.dialect", prop.getHibernateDialect());
|
properties.put("hibernate.dialect", prop.getHibernateDialect());
|
||||||
properties.put("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION");
|
properties.put("hibernate.connection.handling_mode", "DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION");
|
||||||
properties.put("hibernate.transaction.jta.platform", "org.hibernate.engine.transaction.jta.platform.internal.AtomikosJtaPlatform");
|
|
||||||
properties.put("hibernate.physical_naming_strategy", prop.getHibernatePhysicalNamingStrategy());
|
properties.put("hibernate.physical_naming_strategy", prop.getHibernatePhysicalNamingStrategy());
|
||||||
properties.put("hibernate.default_schema", prop.getSchema());
|
properties.put("hibernate.default_schema", prop.getSchema());
|
||||||
properties.put("javax.persistence.validation.mode", "none");
|
properties.put("javax.persistence.validation.mode", "none");
|
||||||
properties.put("javax.persistence.transactionType", "JTA");
|
// JTA/XA 미사용. EMF 별 로컬 트랜잭션(JpaTransactionManager)으로 처리한다 - PortalConfigTransaction 참고
|
||||||
|
properties.put("javax.persistence.transactionType", "RESOURCE_LOCAL");
|
||||||
properties.put("hibernate.format_sql", "true");
|
properties.put("hibernate.format_sql", "true");
|
||||||
|
|
||||||
if (prop instanceof EmsDatasourceProperty) {
|
if (prop instanceof EmsDatasourceProperty) {
|
||||||
@@ -110,9 +111,22 @@ public class BaseDatasourceConfiguration {
|
|||||||
return builder
|
return builder
|
||||||
.dataSource(dataSource)
|
.dataSource(dataSource)
|
||||||
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
||||||
.packages(prop.getEntityPackage().split("\\s*,\\s*"))
|
.packages(splitPackages(prop.getEntityPackage()))
|
||||||
.persistenceUnit(persistenceUnit)
|
.persistenceUnit(persistenceUnit)
|
||||||
.properties(properties)
|
.properties(properties)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마로 나열된 엔티티 패키지를 분리한다. 앞뒤 공백은 걷어내고 빈 토큰은 버린다.
|
||||||
|
*
|
||||||
|
* <p>구분자 정규식 {@code "\\s*,\\s*"} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 단순 분리 + trim 으로 대체했다.</p>
|
||||||
|
*/
|
||||||
|
private static String[] splitPackages(String entityPackage) {
|
||||||
|
return Arrays.stream(entityPackage.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(pkg -> !pkg.isEmpty())
|
||||||
|
.toArray(String[]::new);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import javax.sql.DataSource;
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EntityScan(basePackages = {"com.eactive.apim.gateway"})
|
@EntityScan(basePackages = {"com.eactive.apim.gateway"})
|
||||||
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class, entityManagerFactoryRef = "gatewayEntityManagerFactory")
|
@EnableJpaRepositories(basePackages = "com.eactive.apim.gateway", repositoryBaseClass = BaseRepositoryImpl.class,
|
||||||
|
entityManagerFactoryRef = "gatewayEntityManagerFactory", transactionManagerRef = "gatewayTransactionManager")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
|
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
|
||||||
private final Environment env;
|
private final Environment env;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내부 API 공유 토큰 초기화 — 포탈 기동 시 1회.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY {@code Portal / internal.api.header-name}, {@code internal.api.token} 이 없으면
|
||||||
|
* 이 시점에 생성된다(토큰은 SecureRandom 난수). eapim-admin 은 이 값을 <b>읽기만</b> 하므로,
|
||||||
|
* 토큰을 처음 만드는 주체는 포탈 한 곳으로 고정된다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@link ApplicationReadyEvent} 를 쓰는 이유: 데이터소스/JTA 초기화가 끝난 뒤여야 프로퍼티를 저장할 수 있다.
|
||||||
|
* WAR 배포(WebLogic)에서도 동일하게 발생한다.</p>
|
||||||
|
*
|
||||||
|
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InternalApiTokenInitializer {
|
||||||
|
|
||||||
|
private final InternalApiTokenService internalApiTokenService;
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void initialize() {
|
||||||
|
internalApiTokenService.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,15 +33,13 @@ import org.springframework.web.filter.ForwardedHeaderFilter;
|
|||||||
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
|
* 즉 {@code MenuInternalController#isAllowedIp} 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다.
|
||||||
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
|
* 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로 검사되므로 기존 가드가 설계대로 동작한다.
|
||||||
* 검사되므로 기존 가드가 설계대로 동작한다.
|
|
||||||
*
|
*
|
||||||
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
||||||
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
||||||
*
|
*
|
||||||
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||||
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
|
|||||||
@@ -107,9 +107,16 @@ public class PortalConfigSecurity {
|
|||||||
.logoutSuccessHandler(logoutSuccessHandler))
|
.logoutSuccessHandler(logoutSuccessHandler))
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.csrfTokenRepository(csrfTokenRepository)
|
.csrfTokenRepository(csrfTokenRepository)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
// /_proxy 는 대응 핸들러가 없어 예외를 해제했다. 경로가 부활하면 아래를 되살릴 것.
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
// .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
// /internal/migration 은 LegacyEncryptionMigrationController 제거와 함께 삭제됨.
|
||||||
|
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
||||||
|
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
||||||
|
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
||||||
|
// /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식.
|
||||||
|
.ignoringRequestMatchers(
|
||||||
|
new AntPathRequestMatcher("/internal/menu/**"),
|
||||||
|
new AntPathRequestMatcher("/internal/test-cleanup/**"))
|
||||||
)
|
)
|
||||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
package com.eactive.apim.portal.config;
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
import com.atomikos.icatch.jta.UserTransactionImp;
|
import javax.persistence.EntityManagerFactory;
|
||||||
import com.atomikos.icatch.jta.UserTransactionManager;
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.DependsOn;
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
import org.springframework.transaction.jta.JtaTransactionManager;
|
|
||||||
|
|
||||||
import javax.transaction.SystemException;
|
|
||||||
import javax.transaction.UserTransaction;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랜잭션 매니저 설정.
|
||||||
|
*
|
||||||
|
* <p>EMS(포털)·AGW(게이트웨이) 두 데이터소스를 쓰지만 <b>JTA/XA 는 사용하지 않는다</b>.
|
||||||
|
* 두 DB 를 한 트랜잭션으로 묶어 쓰는 지점이 없고(게이트웨이 측은 조회 전용),
|
||||||
|
* 이전 Atomikos 구성도 {@code AtomikosDataSourceBean} 없이 JNDI/Hikari 데이터소스를 그대로 넘겨
|
||||||
|
* XA 리소스가 하나도 enlist 되지 않는 형태였다. 즉 2PC 는 실제로 동작한 적이 없고
|
||||||
|
* {@code com.atomikos.icatch.max_actives} 상한(기본 50)만 병목으로 남았다.</p>
|
||||||
|
*
|
||||||
|
* <p>따라서 EntityManagerFactory 별로 로컬 {@link JpaTransactionManager} 를 둔다.
|
||||||
|
* 게이트웨이 리포지토리는 {@code GatewayDatasourceConfiguration} 의
|
||||||
|
* {@code transactionManagerRef = "gatewayTransactionManager"} 로 연결된다.</p>
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
public class PortalConfigTransaction {
|
public class PortalConfigTransaction {
|
||||||
|
|
||||||
@Bean(name = "userTransaction")
|
/** EMS(포털) 기본 트랜잭션 매니저. {@code @Transactional} 무지정 시 이 매니저가 쓰인다. */
|
||||||
public UserTransaction userTransaction() throws SystemException {
|
@Primary
|
||||||
UserTransactionImp userTransactionImp = new UserTransactionImp();
|
|
||||||
userTransactionImp.setTransactionTimeout(30000);
|
|
||||||
return userTransactionImp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean(name = "atomikosTransactionManager", initMethod = "init", destroyMethod = "close")
|
|
||||||
public UserTransactionManager atomikosTransactionManager() {
|
|
||||||
UserTransactionManager userTransactionManager = new UserTransactionManager();
|
|
||||||
userTransactionManager.setForceShutdown(false);
|
|
||||||
return userTransactionManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean(name = "transactionManager")
|
@Bean(name = "transactionManager")
|
||||||
@DependsOn({"userTransaction", "atomikosTransactionManager"})
|
public PlatformTransactionManager transactionManager(
|
||||||
public PlatformTransactionManager transactionManager(UserTransactionManager atomikosTransactionManager) throws SystemException {
|
@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
|
||||||
UserTransaction userTransaction = userTransaction();
|
return new JpaTransactionManager(entityManagerFactory);
|
||||||
return new JtaTransactionManager(userTransaction, atomikosTransactionManager);
|
}
|
||||||
|
|
||||||
|
/** AGW(게이트웨이) 트랜잭션 매니저. {@code @Transactional("gatewayTransactionManager")} 로 지정해 쓴다. */
|
||||||
|
@Bean(name = "gatewayTransactionManager")
|
||||||
|
public PlatformTransactionManager gatewayTransactionManager(
|
||||||
|
@Qualifier("gatewayEntityManagerFactory") EntityManagerFactory gatewayEntityManagerFactory) {
|
||||||
|
return new JpaTransactionManager(gatewayEntityManagerFactory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
@Value("${app.resource-caching.enabled:false}")
|
@Value("${app.resource-caching.enabled:false}")
|
||||||
private boolean resourceCachingEnabled;
|
private boolean resourceCachingEnabled;
|
||||||
|
|
||||||
|
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
|
||||||
|
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
|
||||||
|
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
|
||||||
|
@Value("${app.web-resources.static-base:classpath:/static/}")
|
||||||
|
private String staticBase;
|
||||||
|
|
||||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
||||||
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
|
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
|
||||||
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
|
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
|
||||||
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
|
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
|
||||||
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
|
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
|
||||||
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
|
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
|
||||||
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
|
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
|
||||||
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
|
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
|
||||||
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
|
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
|
||||||
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
|
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.qna.entity.InquiryComment;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -17,4 +18,11 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
|
|||||||
+ " group by c.inquiry.id")
|
+ " group by c.inquiry.id")
|
||||||
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
||||||
@Param("delYn") String delYn);
|
@Param("delYn") String delYn);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquiry_Inquirer_Id(String inquirerId);
|
||||||
|
|
||||||
|
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.djb.menu;
|
package com.eactive.apim.portal.djb.menu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -19,14 +20,20 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||||
*
|
*
|
||||||
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
* <p>가드는 두 겹이다.</p>
|
||||||
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
* <ol>
|
||||||
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}. 헤더명/토큰은 PTL_PROPERTY
|
||||||
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
* {@code Portal / internal.api.header-name}, {@code internal.api.token} 으로 관리하며
|
||||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
* 토큰은 포탈 최초 기동 시 자동 생성된다. 이 경로는 CSRF 예외 대상
|
||||||
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
|
* (PortalConfigSecurity {@code /internal/menu/**})이라, 커스텀 헤더 요구가 CSRF 를 대신한다 —
|
||||||
|
* 브라우저의 cross-site form POST 는 커스텀 헤더를 붙일 수 없다.</li>
|
||||||
|
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / menu.internal.allow-ips}(기본 loopback)
|
||||||
|
* + X-Forwarded-For 동반 요청 거부. 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||||
|
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.</li>
|
||||||
|
* </ol>
|
||||||
*
|
*
|
||||||
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
|
* <pre>curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||||
|
* http://127.0.0.1:39130/internal/menu/reload</pre>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@@ -43,14 +50,18 @@ public class MenuInternalController {
|
|||||||
|
|
||||||
private final MenuService menuService;
|
private final MenuService menuService;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final InternalApiTokenService internalApiTokenService;
|
||||||
|
|
||||||
@PostMapping("/reload")
|
@PostMapping("/reload")
|
||||||
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
||||||
if (!isAllowed(request)) {
|
if (!isAllowedIp(request)) {
|
||||||
Map<String, Object> denied = new LinkedHashMap<>();
|
return denied(HttpStatus.FORBIDDEN,
|
||||||
denied.put("result", "DENIED");
|
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||||
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
|
}
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
|
if (!hasValidToken(request)) {
|
||||||
|
return denied(HttpStatus.UNAUTHORIZED,
|
||||||
|
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
|
||||||
|
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
|
||||||
}
|
}
|
||||||
|
|
||||||
MenuService.MenuSnapshot snapshot = menuService.reload();
|
MenuService.MenuSnapshot snapshot = menuService.reload();
|
||||||
@@ -63,12 +74,31 @@ public class MenuInternalController {
|
|||||||
return ResponseEntity.ok(body);
|
return ResponseEntity.ok(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> denied = new LinkedHashMap<>();
|
||||||
|
denied.put("result", "DENIED");
|
||||||
|
denied.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
|
||||||
|
*/
|
||||||
|
private boolean hasValidToken(HttpServletRequest request) {
|
||||||
|
String headerName = internalApiTokenService.ensureHeaderName();
|
||||||
|
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
|
||||||
|
log.warn("메뉴 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||||
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
||||||
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
||||||
*/
|
*/
|
||||||
private boolean isAllowed(HttpServletRequest request) {
|
private boolean isAllowedIp(HttpServletRequest request) {
|
||||||
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
|
|
||||||
|
|||||||
+447
@@ -0,0 +1,447 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||||
|
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||||
|
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
|
||||||
|
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
|
||||||
|
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
|
||||||
|
* ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
|
||||||
|
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
|
||||||
|
* 이를 포함하지 않아 빈 자체가 없어 404.
|
||||||
|
*
|
||||||
|
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
|
||||||
|
* <ol>
|
||||||
|
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).</li>
|
||||||
|
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback).
|
||||||
|
* menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며
|
||||||
|
* 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만
|
||||||
|
* 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.</p>
|
||||||
|
*
|
||||||
|
* <pre>curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||||
|
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Profile("playwright")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/internal/test-cleanup")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TestCleanupInternalController {
|
||||||
|
|
||||||
|
static final String PROP_GROUP = "Portal";
|
||||||
|
static final String PROP_ALLOW_IPS = "testcleanup.internal.allow-ips";
|
||||||
|
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||||
|
static final String PROP_ALLOW_IPS_DESCRIPTION =
|
||||||
|
"테스트 정리 내부 API(법인/계정/고아데이터 삭제) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
|
||||||
|
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
|
||||||
|
|
||||||
|
private final TestCleanupService testCleanupService;
|
||||||
|
private final OrphanCleanupService orphanCleanupService;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final InternalApiTokenService internalApiTokenService;
|
||||||
|
|
||||||
|
@PostMapping("/org")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteOrg(
|
||||||
|
@RequestParam String compRegNo, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(compRegNo)) {
|
||||||
|
return badRequest("compRegNo 는 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.deleteOrgCascade(compRegNo);
|
||||||
|
log.info("테스트 정리(org) 실행 - compRegNo: {}, from: {}", compRegNo, request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("compRegNo", compRegNo);
|
||||||
|
body.put("orgId", result.getTargetId());
|
||||||
|
body.put("orgFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/user")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteUser(
|
||||||
|
@RequestParam String email, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email)) {
|
||||||
|
return badRequest("email 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.deleteUserCascade(email);
|
||||||
|
log.info("테스트 정리(user) 실행 - email: {}, from: {}", email, request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("userId", result.getTargetId());
|
||||||
|
body.put("userFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/exists")
|
||||||
|
public ResponseEntity<Map<String, Object>> checkUserExists(
|
||||||
|
@RequestParam String email, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email)) {
|
||||||
|
return badRequest("email 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.checkUserExists(email);
|
||||||
|
log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}",
|
||||||
|
email, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("userId", result.getTargetId());
|
||||||
|
body.put("userFound", result.isFound());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다.
|
||||||
|
* API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을
|
||||||
|
* 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> getUserStatus(
|
||||||
|
@RequestParam String email, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email)) {
|
||||||
|
return badRequest("email 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = testCleanupService.findUserForStatus(email);
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("userFound", userOpt.isPresent());
|
||||||
|
if (userOpt.isPresent()) {
|
||||||
|
PortalUser user = userOpt.get();
|
||||||
|
PortalOrg org = user.getPortalOrg();
|
||||||
|
body.put("userId", user.getId());
|
||||||
|
body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name());
|
||||||
|
body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name());
|
||||||
|
body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name());
|
||||||
|
body.put("authCompletedYn", user.getAuthCompletedYn());
|
||||||
|
body.put("orgId", org == null ? null : org.getId());
|
||||||
|
body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name());
|
||||||
|
body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null
|
||||||
|
? null : org.getApprovalStatus().name());
|
||||||
|
}
|
||||||
|
log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}",
|
||||||
|
email, userOpt.isPresent(), request.getRemoteAddr());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다.
|
||||||
|
* 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/app-request")
|
||||||
|
public ResponseEntity<Map<String, Object>> cancelPendingTestAppRequests(
|
||||||
|
@RequestParam String email, @RequestParam String clientName, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) {
|
||||||
|
return badRequest("email 과 clientName 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName);
|
||||||
|
log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}",
|
||||||
|
email, clientName, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("clientName", clientName);
|
||||||
|
body.put("appRequestFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4020 재실행 전, 해당 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다.
|
||||||
|
* 대상은 이메일(작성자)과 {@code 단위테스트*} 제목 접두사로 이중 한정한다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/partnership")
|
||||||
|
public ResponseEntity<Map<String, Object>> deletePartnershipApplications(
|
||||||
|
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
|
||||||
|
return badRequest("email 과 subjectPrefix 는 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.deletePartnershipApplicationsByEmail(email, subjectPrefix);
|
||||||
|
log.info("테스트 정리(partnership) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
|
||||||
|
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("subjectPrefix", subjectPrefix);
|
||||||
|
body.put("partnershipFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4010 재실행 전(또는 종료 후 최종 정리), 해당 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
|
||||||
|
* 대상은 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사로 이중 한정한다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/inquiry")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteInquiries(
|
||||||
|
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
|
||||||
|
return badRequest("email 과 subjectPrefix 는 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.deleteInquiriesByEmail(email, subjectPrefix);
|
||||||
|
log.info("테스트 정리(inquiry) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
|
||||||
|
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("subjectPrefix", subjectPrefix);
|
||||||
|
body.put("inquiryFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4010 실행 전, 지정 이메일 계정을 관리자 이메일로 조회한 법인의 ROLE_CORP_USER(법인개발자)로 준비한다.
|
||||||
|
* 이미 존재하면 재소속+비밀번호 재설정(heal), 없으면 신규 생성(create) — 두 경우 모두 응답한 비밀번호로
|
||||||
|
* 곧바로 로그인 가능한 상태가 된다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/corp-developer")
|
||||||
|
public ResponseEntity<Map<String, Object>> ensureCorpDeveloper(
|
||||||
|
@RequestParam String managerEmail, @RequestParam String email, @RequestParam String password,
|
||||||
|
@RequestParam String mobile, @RequestParam String userName, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(email) || StringUtils.isBlank(password)
|
||||||
|
|| StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
|
||||||
|
return badRequest("managerEmail, email, password, mobile, userName 은 모두 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.ensureTestCorpDeveloper(managerEmail, email, password, mobile, userName);
|
||||||
|
boolean created = result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0;
|
||||||
|
log.info("테스트 정리(corp-developer) 실행 - managerEmail: {}, email: {}, created: {}, from: {}",
|
||||||
|
managerEmail, email, created, request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("managerEmail", managerEmail);
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("userId", result.getTargetId());
|
||||||
|
body.put("created", created);
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 그대로). 화면 가입 절차 없이 즉시 로그인
|
||||||
|
* 가능한 값으로 되돌리는 용도 — 공용 시드 계정의 비밀번호가 정책과 안 맞게 바뀐 경우 등.
|
||||||
|
*/
|
||||||
|
@PostMapping("/password")
|
||||||
|
public ResponseEntity<Map<String, Object>> resetPassword(
|
||||||
|
@RequestParam String email, @RequestParam String password, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
|
||||||
|
return badRequest("email 과 password 는 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.resetTestPassword(email, password);
|
||||||
|
log.info("테스트 정리(password) 실행 - email: {}, found: {}, from: {}",
|
||||||
|
email, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("email", email);
|
||||||
|
body.put("userFound", result.isFound());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/invitation")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteInvitations(
|
||||||
|
@RequestParam String mobile, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(mobile)) {
|
||||||
|
return badRequest("mobile 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile);
|
||||||
|
log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}",
|
||||||
|
mobile, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("mobile", mobile);
|
||||||
|
body.put("invitationFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/membership")
|
||||||
|
public ResponseEntity<Map<String, Object>> detachMembership(
|
||||||
|
@RequestParam String mobile, HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(mobile)) {
|
||||||
|
return badRequest("mobile 은 필수입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile);
|
||||||
|
log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}",
|
||||||
|
mobile, result.isFound(), request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("mobile", mobile);
|
||||||
|
body.put("membershipFound", result.isFound());
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/orphans")
|
||||||
|
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
|
||||||
|
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||||
|
if (guardFailure != null) {
|
||||||
|
return guardFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = orphanCleanupService.sweep();
|
||||||
|
log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr());
|
||||||
|
|
||||||
|
Map<String, Object> body = baseBody();
|
||||||
|
body.put("deletedCounts", result.getDeletedCounts());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> checkGuards(HttpServletRequest request) {
|
||||||
|
if (!isAllowedIp(request)) {
|
||||||
|
return denied(HttpStatus.FORBIDDEN,
|
||||||
|
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||||
|
}
|
||||||
|
if (!hasValidToken(request)) {
|
||||||
|
return denied(HttpStatus.UNAUTHORIZED,
|
||||||
|
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
|
||||||
|
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> baseBody() {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("result", "OK");
|
||||||
|
body.put("processedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> badRequest(String message) {
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("result", "BAD_REQUEST");
|
||||||
|
body.put("message", message);
|
||||||
|
return ResponseEntity.badRequest().body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> denied = new LinkedHashMap<>();
|
||||||
|
denied.put("result", "DENIED");
|
||||||
|
denied.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
|
||||||
|
*/
|
||||||
|
private boolean hasValidToken(HttpServletRequest request) {
|
||||||
|
String headerName = internalApiTokenService.ensureHeaderName();
|
||||||
|
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
|
||||||
|
log.warn("테스트 정리 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||||
|
*/
|
||||||
|
private boolean isAllowedIp(HttpServletRequest request) {
|
||||||
|
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||||
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
|
|
||||||
|
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
|
||||||
|
log.warn("테스트 정리 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveAllowIps() {
|
||||||
|
try {
|
||||||
|
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||||
|
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||||
|
return DEFAULT_ALLOW_IPS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import javax.persistence.EntityManager;
|
||||||
|
import javax.persistence.PersistenceContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
|
||||||
|
* 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
|
||||||
|
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Profile("playwright")
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class OrphanCleanupService {
|
||||||
|
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
public OrphanCleanupService(Environment environment) {
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TestCleanupResult sweep() {
|
||||||
|
assertNonProdProfile();
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(null);
|
||||||
|
|
||||||
|
// 자식 -> 부모 순서 (FK 제약은 없으나 논리적 정합성 유지 목적)
|
||||||
|
result.put("PTL_INQUIRY_COMMENT", exec(
|
||||||
|
"DELETE FROM PTL_INQUIRY_COMMENT WHERE INQUIRY_ID IN "
|
||||||
|
+ "(SELECT ID FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER))"));
|
||||||
|
result.put("PTL_INQUIRY", exec(
|
||||||
|
"DELETE FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_PARTNERSHIP_APPLICATION", exec(
|
||||||
|
"DELETE FROM PTL_PARTNERSHIP_APPLICATION WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", exec(
|
||||||
|
"DELETE FROM PTL_USER_PRIVACY_POLICY_AGREEMENT WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_USER_PASSWORD_HISTORY", exec(
|
||||||
|
"DELETE FROM PTL_USER_PASSWORD_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_USER_ROLE_HISTORY", exec(
|
||||||
|
"DELETE FROM PTL_USER_ROLE_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_USER_LOG", exec(
|
||||||
|
"DELETE FROM PTL_USER_LOG WHERE LOGIN_ID IS NOT NULL AND LOGIN_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
|
||||||
|
result.put("PTL_CREDENTIAL_API", exec(
|
||||||
|
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
|
||||||
|
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||||
|
result.put("PTL_CREDENTIAL", exec(
|
||||||
|
"DELETE FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||||
|
result.put("PTL_WEBHOOK_REQ_API", exec(
|
||||||
|
"DELETE FROM PTL_WEBHOOK_REQ_API WHERE WEBHOOK_REQ_ID IN "
|
||||||
|
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||||
|
result.put("PTL_WEBHOOK_REQ_EVENT", exec(
|
||||||
|
"DELETE FROM PTL_WEBHOOK_REQ_EVENT WHERE WEBHOOK_REQ_ID IN "
|
||||||
|
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||||
|
result.put("PTL_WEBHOOK_REQ", exec(
|
||||||
|
"DELETE FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||||
|
result.put("PTL_WEBHOOK_SEND_LOG", exec(
|
||||||
|
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||||
|
|
||||||
|
log.info("테스트 정리 - 고아 데이터 스윕 완료: {}", result.getDeletedCounts());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long exec(String sql) {
|
||||||
|
return entityManager.createNativeQuery(sql).executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNonProdProfile() {
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
|
||||||
|
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import javax.persistence.EntityManager;
|
||||||
|
import javax.persistence.PersistenceContext;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
|
||||||
|
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
|
||||||
|
*/
|
||||||
|
@Profile("playwright")
|
||||||
|
@Repository
|
||||||
|
class TestCleanupNativeQueries {
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
int deleteCredentialApiByOrgId(String orgId) {
|
||||||
|
return entityManager.createNativeQuery(
|
||||||
|
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
|
||||||
|
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID = :orgId)")
|
||||||
|
.setParameter("orgId", orgId)
|
||||||
|
.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
int deleteWebhookSendLogByOrgId(String orgId) {
|
||||||
|
return entityManager.createNativeQuery(
|
||||||
|
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID = :orgId")
|
||||||
|
.setParameter("orgId", orgId)
|
||||||
|
.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 정리 API 응답용 결과 누적기. 대상 존재 여부 + 테이블별 삭제 건수를 담는다.
|
||||||
|
*/
|
||||||
|
public class TestCleanupResult {
|
||||||
|
|
||||||
|
private boolean found;
|
||||||
|
private String targetId;
|
||||||
|
private final Map<String, Long> deletedCounts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public static TestCleanupResult notFound() {
|
||||||
|
return new TestCleanupResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TestCleanupResult found(String targetId) {
|
||||||
|
TestCleanupResult result = new TestCleanupResult();
|
||||||
|
result.found = true;
|
||||||
|
result.targetId = targetId;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void put(String table, long count) {
|
||||||
|
deletedCounts.merge(table, count, Long::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void merge(TestCleanupResult other) {
|
||||||
|
other.deletedCounts.forEach(this::put);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFound() {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTargetId() {
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Long> getDeletedCounts() {
|
||||||
|
return deletedCounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
|
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||||
|
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
|
||||||
|
import com.eactive.apim.portal.approval.statemachine.RequestedState;
|
||||||
|
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
|
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||||
|
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||||
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||||
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
|
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
|
||||||
|
* 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
|
||||||
|
* local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
|
||||||
|
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
|
||||||
|
*
|
||||||
|
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Profile("playwright")
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional
|
||||||
|
public class TestCleanupService {
|
||||||
|
|
||||||
|
private final Environment environment;
|
||||||
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
private final InquiryRepository inquiryRepository;
|
||||||
|
private final InquiryCommentRepository inquiryCommentRepository;
|
||||||
|
private final PartnershipApplicationRepository partnershipApplicationRepository;
|
||||||
|
private final UserRoleHistoryRepository userRoleHistoryRepository;
|
||||||
|
private final PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
|
||||||
|
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
|
||||||
|
private final UserLogRepository userLogRepository;
|
||||||
|
private final CredentialRepository credentialRepository;
|
||||||
|
private final AppRequestRepository appRequestRepository;
|
||||||
|
private final ApprovalService approvalService;
|
||||||
|
private final WebhookRequestRepository webhookRequestRepository;
|
||||||
|
private final WebhookRequestApiRepository webhookRequestApiRepository;
|
||||||
|
private final WebhookRequestEventRepository webhookRequestEventRepository;
|
||||||
|
private final WebhookService webhookService;
|
||||||
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
|
private final FileService fileService;
|
||||||
|
private final TestCleanupNativeQueries nativeQueries;
|
||||||
|
private final PortalUserService portalUserService;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult deleteOrgCascade(String compRegNo) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
|
||||||
|
// 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다.
|
||||||
|
// cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다.
|
||||||
|
String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", "");
|
||||||
|
if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) {
|
||||||
|
orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo);
|
||||||
|
}
|
||||||
|
if (!orgOpt.isPresent()) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
PortalOrg org = orgOpt.get();
|
||||||
|
String orgId = org.getId();
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(orgId);
|
||||||
|
|
||||||
|
result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId));
|
||||||
|
|
||||||
|
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
|
||||||
|
for (PortalUser user : users) {
|
||||||
|
result.merge(deleteUserCascadeInternal(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("PTL_CREDENTIAL_API", nativeQueries.deleteCredentialApiByOrgId(orgId));
|
||||||
|
result.put("PTL_CREDENTIAL", credentialRepository.deleteByOrgid(orgId));
|
||||||
|
|
||||||
|
Optional<WebhookRequest> webhook = webhookRequestRepository.findByOrgId(orgId);
|
||||||
|
if (webhook.isPresent()) {
|
||||||
|
Long webhookReqId = webhook.get().getId();
|
||||||
|
result.put("PTL_WEBHOOK_REQ_API", webhookRequestApiRepository.findByWebhookReqId(webhookReqId).size());
|
||||||
|
result.put("PTL_WEBHOOK_REQ_EVENT", webhookRequestEventRepository.findByWebhookReqId(webhookReqId).size());
|
||||||
|
webhookService.delete(webhookReqId, orgId);
|
||||||
|
result.put("PTL_WEBHOOK_REQ", 1L);
|
||||||
|
} else {
|
||||||
|
result.put("PTL_WEBHOOK_REQ_API", 0L);
|
||||||
|
result.put("PTL_WEBHOOK_REQ_EVENT", 0L);
|
||||||
|
result.put("PTL_WEBHOOK_REQ", 0L);
|
||||||
|
}
|
||||||
|
result.put("PTL_WEBHOOK_SEND_LOG", nativeQueries.deleteWebhookSendLogByOrgId(orgId));
|
||||||
|
|
||||||
|
portalOrgRepository.delete(org);
|
||||||
|
result.put("PTL_ORG", 1L);
|
||||||
|
|
||||||
|
log.info("테스트 정리 - 법인 완전삭제 완료: compRegNo={}, orgId={}, userCount={}", compRegNo, orgId, users.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일로 계정을 찾아 단독 하드 삭제한다(org 소속 여부와 무관 — org 소유 CREDENTIAL/WEBHOOK 은 건드리지 않는다).
|
||||||
|
*/
|
||||||
|
public TestCleanupResult deleteUserCascade(String email) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
if (!userOpt.isPresent()) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
PortalUser user = userOpt.get();
|
||||||
|
TestCleanupResult result = deleteUserCascadeInternal(user);
|
||||||
|
log.info("테스트 정리 - 계정 완전삭제 완료: userId={}", user.getId());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public TestCleanupResult checkUserExists(String email) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
return portalUserRepository.findPortalUserByEmailAddr(email)
|
||||||
|
.map(user -> TestCleanupResult.found(user.getId()))
|
||||||
|
.orElseGet(TestCleanupResult::notFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다.
|
||||||
|
* 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<PortalUser> findUserForStatus(String email) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
return portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다.
|
||||||
|
* 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
if (clientName == null || !clientName.startsWith("단위테스트앱-")) {
|
||||||
|
throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.notFound();
|
||||||
|
long cancelled = 0L;
|
||||||
|
for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) {
|
||||||
|
if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState
|
||||||
|
|| request.getApproval().getApprovalStatus() instanceof ProcessingState)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
approvalService.cancelAppApproval(request);
|
||||||
|
result = TestCleanupResult.found(request.getId());
|
||||||
|
cancelled++;
|
||||||
|
}
|
||||||
|
result.put("PTL_APP_REQUEST_CANCELLED", cancelled);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult deleteInvitationsByMobile(String mobile) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
|
||||||
|
if (normalizedMobile == null) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile);
|
||||||
|
TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound();
|
||||||
|
result.put("PTL_USER_INVITATION", deleted);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다.
|
||||||
|
* 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult detachUsersFromOrgByMobile(String mobile) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
|
||||||
|
if (normalizedMobile == null) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.notFound();
|
||||||
|
for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) {
|
||||||
|
if (user.getPortalOrg() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
user.setPortalOrg(null);
|
||||||
|
user.setRoleCode(RoleCode.ROLE_USER);
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_USER_ORG_MEMBERSHIP", 1L);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4020 재실행 전, 특정 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다(첨부파일 포함).
|
||||||
|
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 단위테스트} 로 시작하는 제목 접두사를 모두 요구한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult deletePartnershipApplicationsByEmail(String email, String subjectPrefix) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
if (subjectPrefix == null || !subjectPrefix.startsWith("단위테스트")) {
|
||||||
|
throw new IllegalArgumentException("테스트 글 제목 접두사(단위테스트*)만 정리할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
if (!userOpt.isPresent()) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
PortalUser user = userOpt.get();
|
||||||
|
|
||||||
|
List<PartnershipApplication> targets =
|
||||||
|
partnershipApplicationRepository.findAllByCreatedByAndBizSubjectStartingWith(user.getId(), subjectPrefix);
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
TestCleanupResult empty = TestCleanupResult.notFound();
|
||||||
|
empty.put("PTL_PARTNERSHIP_APPLICATION", 0L);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
long files = 0L;
|
||||||
|
for (PartnershipApplication target : targets) {
|
||||||
|
if (target.getFileId() != null && !target.getFileId().trim().isEmpty()) {
|
||||||
|
fileService.deleteFile(target.getFileId());
|
||||||
|
files++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
partnershipApplicationRepository.deleteAll(targets);
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_PARTNERSHIP_APPLICATION", (long) targets.size());
|
||||||
|
result.put("PTL_FILE_INFO", files);
|
||||||
|
log.info("테스트 정리 - 피드백/개선요청 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4010 재실행 전(또는 종료 후 최종 정리) 특정 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
|
||||||
|
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사를 모두 요구한다.
|
||||||
|
* 답변완료(RESPONDED)로 전환된 문의는 포털 자가삭제(PENDING + 작성자 전용)가 막히므로, 4010 종료 시점의
|
||||||
|
* 최종 정리도 이 API를 재사용한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult deleteInquiriesByEmail(String email, String subjectPrefix) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
if (subjectPrefix == null || !subjectPrefix.startsWith("새글 작성 테스트")) {
|
||||||
|
throw new IllegalArgumentException("테스트 문의글 제목 접두사(새글 작성 테스트*)만 정리할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
if (!userOpt.isPresent()) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
PortalUser user = userOpt.get();
|
||||||
|
|
||||||
|
List<Inquiry> targets =
|
||||||
|
inquiryRepository.findAllByInquirer_IdAndInquirySubjectStartingWith(user.getId(), subjectPrefix);
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
TestCleanupResult empty = TestCleanupResult.notFound();
|
||||||
|
empty.put("PTL_INQUIRY", 0L);
|
||||||
|
empty.put("PTL_INQUIRY_COMMENT", 0L);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> ids = targets.stream().map(Inquiry::getId).collect(Collectors.toList());
|
||||||
|
long comments = inquiryCommentRepository.deleteByInquiry_IdIn(ids);
|
||||||
|
inquiryRepository.deleteAll(targets);
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_INQUIRY", (long) targets.size());
|
||||||
|
result.put("PTL_INQUIRY_COMMENT", comments);
|
||||||
|
log.info("테스트 정리 - 문의글 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4010 재실행 안정성을 위한 테스트 전용 API. 지정 이메일 계정을 관리자 이메일로 조회한 법인의
|
||||||
|
* ROLE_CORP_USER(법인개발자)로 만든다 — 이미 존재하면(다른 시나리오/수작업이 소속을 해제했더라도)
|
||||||
|
* 재소속·비밀번호 재설정으로 로그인 가능 상태를 보장하고(heal), 계정이 없으면 새로 만든다(create).
|
||||||
|
* 계정 생성/소속 전환 로직은 초대 수락 경로({@link PortalUserService#registerInvitedUser},
|
||||||
|
* {@link PortalUserService#updateUserToCorpUser})를 그대로 재사용한다.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult ensureTestCorpDeveloper(
|
||||||
|
String managerEmail, String email, String password, String mobile, String userName) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
|
||||||
|
PortalOrg org = manager.getPortalOrg();
|
||||||
|
if (org == null) {
|
||||||
|
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<PortalUser> existing = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
boolean created;
|
||||||
|
PortalUser user;
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
user = existing.get();
|
||||||
|
portalUserService.updateUserToCorpUser(user, org.getId());
|
||||||
|
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
|
||||||
|
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
|
||||||
|
user.setAccountLockYn("N");
|
||||||
|
user.setLoginFailureCount(0);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setPasswordChangeDate(LocalDateTime.now());
|
||||||
|
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
|
||||||
|
if (normalizedMobile != null) {
|
||||||
|
user.setMobileNumber(normalizedMobile);
|
||||||
|
}
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
created = false;
|
||||||
|
} else {
|
||||||
|
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
|
||||||
|
dto.setLoginId(email);
|
||||||
|
dto.setUserName(userName);
|
||||||
|
dto.setPassword(password);
|
||||||
|
dto.setMobileNumber(mobile);
|
||||||
|
user = portalUserService.registerInvitedUser(dto, org.getId());
|
||||||
|
created = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_USER_CREATED", created ? 1L : 0L);
|
||||||
|
result.put("PTL_USER_ATTACHED", created ? 0L : 1L);
|
||||||
|
log.info("테스트 정리 - 법인개발자 계정 준비 완료: email={}, orgId={}, created={}", email, org.getId(), created);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 건드리지 않음). 계정이 아예 없으면 not-found.
|
||||||
|
* 4010 등에서 재사용하는 공용 계정(예: REG_CORP_LOGIN_ID)의 비밀번호가 클라이언트 정책(길이/복잡도)에
|
||||||
|
* 안 맞게 바뀌었을 때, 화면 가입 절차 없이 즉시 로그인 가능한 값으로 되돌리는 용도.
|
||||||
|
*/
|
||||||
|
public TestCleanupResult resetTestPassword(String email, String password) {
|
||||||
|
assertNonProdProfile();
|
||||||
|
|
||||||
|
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||||
|
if (!userOpt.isPresent()) {
|
||||||
|
return TestCleanupResult.notFound();
|
||||||
|
}
|
||||||
|
PortalUser user = userOpt.get();
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setPasswordChangeDate(LocalDateTime.now());
|
||||||
|
user.setLoginFailureCount(0);
|
||||||
|
user.setAccountLockYn("N");
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_USER_PASSWORD_RESET", 1L);
|
||||||
|
log.info("테스트 정리 - 비밀번호 재설정 완료: email={}", email);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
|
||||||
|
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||||
|
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
|
||||||
|
result.put("PTL_INQUIRY", inquiryRepository.deleteByInquirer_Id(user.getId()));
|
||||||
|
result.put("PTL_PARTNERSHIP_APPLICATION", partnershipApplicationRepository.deleteByCreatedBy(user.getId()));
|
||||||
|
// UserRoleHistory.userId 는 loginId 를 저장한다(다른 이력 테이블과 시맨틱이 반대이므로 혼동 주의).
|
||||||
|
result.put("PTL_USER_ROLE_HISTORY", userRoleHistoryRepository.deleteByUserId(user.getLoginId()));
|
||||||
|
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", portalUserPrivacyAgreementRepository.deleteByCreatedBy(user.getId()));
|
||||||
|
// UserPasswordHistory.userId 는 PortalUser.id 를 저장한다.
|
||||||
|
result.put("PTL_USER_PASSWORD_HISTORY", userPasswordHistoryRepository.deleteByUserId(user.getId()));
|
||||||
|
result.put("PTL_USER_LOG", userLogRepository.deleteByLoginId(user.getLoginId()));
|
||||||
|
portalUserRepository.delete(user);
|
||||||
|
result.put("PTL_USER", 1L);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNonProdProfile() {
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
|
||||||
|
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class WebhookApiDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -18,9 +18,12 @@ public class WebhookDTO implements Serializable {
|
|||||||
private String secretMasked;
|
private String secretMasked;
|
||||||
private String createdDate;
|
private String createdDate;
|
||||||
|
|
||||||
/** 구독 API ID 목록. */
|
/** 구독 API ID 목록(폼 prefill 등 내부용). */
|
||||||
private List<String> apiIds = new ArrayList<>();
|
private List<String> apiIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 구독 API ID+명칭 목록(화면 표시용). */
|
||||||
|
private List<WebhookApiDTO> apis = new ArrayList<>();
|
||||||
|
|
||||||
/** 구독 EventType(코드+한글명) 목록. */
|
/** 구독 EventType(코드+한글명) 목록. */
|
||||||
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.eactive.apim.portal.djb.webhook.service;
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
|
||||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
||||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||||
@@ -45,6 +48,7 @@ public class WebhookService {
|
|||||||
private final WebhookSecretGenerator secretGenerator;
|
private final WebhookSecretGenerator secretGenerator;
|
||||||
private final WebhookEventTypeProvider eventTypeProvider;
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
private final WebhookMapper webhookMapper;
|
private final WebhookMapper webhookMapper;
|
||||||
|
private final ApiService apiService;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean existsByOrg(String orgId) {
|
public boolean existsByOrg(String orgId) {
|
||||||
@@ -175,8 +179,15 @@ public class WebhookService {
|
|||||||
WebhookDTO dto = webhookMapper.toDto(request);
|
WebhookDTO dto = webhookMapper.toDto(request);
|
||||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||||
|
|
||||||
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
|
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
.map(WebhookRequestApi::getApiId)
|
.map(WebhookRequestApi::getApiId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setApiIds(apiIds);
|
||||||
|
|
||||||
|
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
|
||||||
|
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
|
||||||
|
dto.setApis(apiIds.stream()
|
||||||
|
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
Map<String, String> names = eventTypeProvider.asMap();
|
Map<String, String> names = eventTypeProvider.asMap();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
spring:
|
spring:
|
||||||
jta:
|
|
||||||
enabled: false
|
|
||||||
config:
|
config:
|
||||||
activate:
|
activate:
|
||||||
on-profile: dev
|
on-profile: dev
|
||||||
|
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
|
||||||
|
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
|
||||||
jpa:
|
jpa:
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
spring:
|
spring:
|
||||||
jta:
|
|
||||||
enabled: false
|
|
||||||
config:
|
config:
|
||||||
activate:
|
activate:
|
||||||
on-profile: prod
|
on-profile: prod
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
spring:
|
spring:
|
||||||
jta:
|
|
||||||
enabled: false
|
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
cache:
|
cache:
|
||||||
|
|||||||
@@ -30,12 +30,25 @@ server:
|
|||||||
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
|
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
|
||||||
forward-headers-strategy: native
|
forward-headers-strategy: native
|
||||||
|
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||||
|
min-response-size: 1024
|
||||||
|
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
config:
|
config:
|
||||||
import:
|
import:
|
||||||
- classpath:menu.yml
|
- classpath:menu.yml
|
||||||
- classpath:roles.yml
|
- classpath:roles.yml
|
||||||
|
profiles:
|
||||||
|
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
|
||||||
|
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
|
||||||
|
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
|
||||||
|
# 반드시 이 base 문서에 정의)
|
||||||
|
group:
|
||||||
|
dev: playwright
|
||||||
|
local_rinjaemac: playwright
|
||||||
data:
|
data:
|
||||||
web:
|
web:
|
||||||
pageable:
|
pageable:
|
||||||
@@ -58,13 +71,7 @@ spring:
|
|||||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||||
|
|
||||||
jta:
|
# JTA/Atomikos 제거됨. EMS·AGW 각각 로컬 트랜잭션(JpaTransactionManager) 사용 - PortalConfigTransaction 참고
|
||||||
enabled: false
|
|
||||||
atomikos:
|
|
||||||
properties:
|
|
||||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
|
||||||
log-base-dir: /dev/null
|
|
||||||
log-base-name: adp_tx
|
|
||||||
|
|
||||||
thymeleaf:
|
thymeleaf:
|
||||||
prefix: 'classpath:/templates/views/'
|
prefix: 'classpath:/templates/views/'
|
||||||
@@ -86,6 +93,10 @@ app:
|
|||||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||||
resource-caching:
|
resource-caching:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
|
||||||
|
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
|
||||||
|
web-resources:
|
||||||
|
static-base: 'classpath:/static/'
|
||||||
|
|
||||||
security:
|
security:
|
||||||
basic:
|
basic:
|
||||||
@@ -125,7 +136,7 @@ portal:
|
|||||||
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
|
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
log-path: /logs/eapim
|
log-path: /logs/prod/eapim
|
||||||
|
|
||||||
# 내부 사용자(운영자) 판별 설정
|
# 내부 사용자(운영자) 판별 설정
|
||||||
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
|
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# application.yml ? ??
|
|
||||||
# com.atomikos.icatch.log_base_dir=/Log/eapim/portal/
|
|
||||||
# com.atomikos.icatch.log_base_name=adp_tx
|
|
||||||
@@ -87,9 +87,6 @@
|
|||||||
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
|
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
|
||||||
<appender-ref ref="FILE_HIBERNATE" />
|
<appender-ref ref="FILE_HIBERNATE" />
|
||||||
</logger>
|
</logger>
|
||||||
<logger name="com.atomikos" level="DEBUG" additivity="false">
|
|
||||||
<appender-ref ref="FILE_HIBERNATE" />
|
|
||||||
</logger>
|
|
||||||
|
|
||||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
||||||
<appender-ref ref="HTTP_SESSION" />
|
<appender-ref ref="HTTP_SESSION" />
|
||||||
|
|||||||
@@ -19,6 +19,21 @@
|
|||||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||||
|
|
||||||
|
<!-- 파일 로그(ROLLING/ERROR_FILE 등 root 하위 appender) 레벨 JVM 파라미터 제어 -->
|
||||||
|
<!-- 사용: -DFILE_LOG_LEVEL=ERROR (전체 미남김: -DFILE_LOG_LEVEL=OFF) -->
|
||||||
|
<!-- 기본값: dev 프로파일 DEBUG, 그 외 INFO (기존 동작 유지) -->
|
||||||
|
<property name="FILE_LOG_LEVEL" value="${FILE_LOG_LEVEL:-INFO}"/>
|
||||||
|
<property name="FILE_LOG_LEVEL_DEV" value="${FILE_LOG_LEVEL:-DEBUG}"/>
|
||||||
|
|
||||||
|
<!-- API 테스터 감사 로그(eapim.portal.apitester.audit) on/off JVM 파라미터 제어 -->
|
||||||
|
<!-- 사용: -DAPI_TESTER_AUDIT_LOG_ENABLED=false / 기본값: true(켜짐, INFO) -->
|
||||||
|
<property name="API_TESTER_AUDIT_LOG_ENABLED" value="${API_TESTER_AUDIT_LOG_ENABLED:-true}"/>
|
||||||
|
<property name="__apiTesterAuditLevel.true" value="INFO"/>
|
||||||
|
<property name="__apiTesterAuditLevel.TRUE" value="INFO"/>
|
||||||
|
<property name="__apiTesterAuditLevel.false" value="OFF"/>
|
||||||
|
<property name="__apiTesterAuditLevel.FALSE" value="OFF"/>
|
||||||
|
<property name="API_TESTER_AUDIT_EFFECTIVE_LEVEL" value="${__apiTesterAuditLevel.${API_TESTER_AUDIT_LOG_ENABLED}}"/>
|
||||||
|
|
||||||
|
|
||||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
<file>${LOG_PATH}/hibernate.log</file>
|
<file>${LOG_PATH}/hibernate.log</file>
|
||||||
@@ -95,23 +110,23 @@
|
|||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
|
|
||||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
<logger name="eapim.portal.session" level="${FILE_LOG_LEVEL}" additivity="false">
|
||||||
<appender-ref ref="HTTP_SESSION" />
|
<appender-ref ref="HTTP_SESSION" />
|
||||||
</logger>
|
</logger>
|
||||||
|
|
||||||
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
|
<logger name="eapim.portal.apitester.audit" level="${API_TESTER_AUDIT_EFFECTIVE_LEVEL}" additivity="false">
|
||||||
<appender-ref ref="API_TESTER_AUDIT" />
|
<appender-ref ref="API_TESTER_AUDIT" />
|
||||||
</logger>
|
</logger>
|
||||||
|
|
||||||
|
|
||||||
<root level="INFO">
|
<root level="${FILE_LOG_LEVEL}">
|
||||||
<appender-ref ref="ROLLING"/>
|
<appender-ref ref="ROLLING"/>
|
||||||
<appender-ref ref="CONSOLE"/>
|
<appender-ref ref="CONSOLE"/>
|
||||||
<appender-ref ref="ERROR_FILE"/>
|
<appender-ref ref="ERROR_FILE"/>
|
||||||
</root>
|
</root>
|
||||||
|
|
||||||
<springProfile name="dev">
|
<springProfile name="dev">
|
||||||
<root level="DEBUG">
|
<root level="${FILE_LOG_LEVEL_DEV}">
|
||||||
<appender-ref ref="ROLLING"/>
|
<appender-ref ref="ROLLING"/>
|
||||||
<appender-ref ref="CONSOLE"/>
|
<appender-ref ref="CONSOLE"/>
|
||||||
<appender-ref ref="ERROR_FILE"/>
|
<appender-ref ref="ERROR_FILE"/>
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ body {
|
|||||||
color: #1A1A2E;
|
color: #1A1A2E;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
body > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul, ol {
|
||||||
@@ -821,6 +828,54 @@ hr {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.env-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 0 0 4px 0;
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 1100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.env-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.env-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 1100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1822,6 +1877,7 @@ hr {
|
|||||||
background-color: rgb(15, 23, 42);
|
background-color: rgb(15, 23, 42);
|
||||||
color: rgb(100, 116, 139);
|
color: rgb(100, 116, 139);
|
||||||
padding: 60px 0px;
|
padding: 60px 0px;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.global-footer .container {
|
.global-footer .container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
@@ -18073,6 +18129,10 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.detail-wrap .dt-api-name:hover {
|
||||||
|
color: #2a69de;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.detail-wrap .dt-api-name {
|
.detail-wrap .dt-api-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -21791,6 +21851,35 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.recent-apps .recent-apps-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.recent-apps .recent-apps-delete {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.recent-apps .recent-apps-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
.recent-apps .recent-apps-delete:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -321,6 +321,14 @@ function insertImageAsDataUri(file, $editor) {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정규식 기반 정리를 적용할 HTML 최대 길이(문자).
|
||||||
|
* 아래 VML/네임스페이스 정리 정규식은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 비정상적으로 큰 입력에서는 정규식 단계를 건너뛴다. data-uri 이미지를 포함한 일반적인
|
||||||
|
* Office 붙여넣기는 이 한도 아래다.
|
||||||
|
*/
|
||||||
|
var MAX_REGEX_CLEAN_LENGTH = 500000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
|
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
|
||||||
* - 불필요한 Office 속성 제거
|
* - 불필요한 Office 속성 제거
|
||||||
@@ -358,6 +366,14 @@ function cleanPastedHtml(html) {
|
|||||||
|
|
||||||
// VML 태그 제거 (v:, o:, w: 등)
|
// VML 태그 제거 (v:, o:, w: 등)
|
||||||
var cleanedHtml = $temp.html();
|
var cleanedHtml = $temp.html();
|
||||||
|
|
||||||
|
// 한도 초과 시 정규식 정리를 건너뛴다 — DOM 정리(이미지 속성 제거)는 이미 끝났으므로
|
||||||
|
// 붙여넣기 자체는 동작하고, Office 네임스페이스 잔여물만 남는다. (브라우저 멈춤 방지)
|
||||||
|
if (cleanedHtml.length > MAX_REGEX_CLEAN_LENGTH) {
|
||||||
|
console.warn('붙여넣기 HTML이 너무 커서 Office 태그 정리를 건너뜁니다. length=' + cleanedHtml.length);
|
||||||
|
return cleanedHtml;
|
||||||
|
}
|
||||||
|
|
||||||
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||||
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
||||||
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
var undefined;
|
var undefined;
|
||||||
|
|
||||||
/** Used as the semantic version number. */
|
/** Used as the semantic version number. */
|
||||||
var VERSION = '4.17.21';
|
var VERSION = '4.18.1';
|
||||||
|
|
||||||
/** Used as the size to enable large array optimizations. */
|
/** Used as the size to enable large array optimizations. */
|
||||||
var LARGE_ARRAY_SIZE = 200;
|
var LARGE_ARRAY_SIZE = 200;
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
/** Error message constants. */
|
/** Error message constants. */
|
||||||
var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',
|
var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',
|
||||||
FUNC_ERROR_TEXT = 'Expected a function',
|
FUNC_ERROR_TEXT = 'Expected a function',
|
||||||
INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`';
|
INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`',
|
||||||
|
INVALID_TEMPL_IMPORTS_ERROR_TEXT = 'Invalid `imports` option passed into `_.template`';
|
||||||
|
|
||||||
/** Used to stand-in for `undefined` hash values. */
|
/** Used to stand-in for `undefined` hash values. */
|
||||||
var HASH_UNDEFINED = '__lodash_hash_undefined__';
|
var HASH_UNDEFINED = '__lodash_hash_undefined__';
|
||||||
@@ -1752,6 +1753,10 @@
|
|||||||
* embedded Ruby (ERB) as well as ES2015 template strings. Change the
|
* embedded Ruby (ERB) as well as ES2015 template strings. Change the
|
||||||
* following template settings to use alternative delimiters.
|
* following template settings to use alternative delimiters.
|
||||||
*
|
*
|
||||||
|
* **Security:** See
|
||||||
|
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md)
|
||||||
|
* — `_.template` is insecure and will be removed in v5.
|
||||||
|
*
|
||||||
* @static
|
* @static
|
||||||
* @memberOf _
|
* @memberOf _
|
||||||
* @type {Object}
|
* @type {Object}
|
||||||
@@ -2300,7 +2305,7 @@
|
|||||||
* @name has
|
* @name has
|
||||||
* @memberOf SetCache
|
* @memberOf SetCache
|
||||||
* @param {*} value The value to search for.
|
* @param {*} value The value to search for.
|
||||||
* @returns {number} Returns `true` if `value` is found, else `false`.
|
* @returns {boolean} Returns `true` if `value` is found, else `false`.
|
||||||
*/
|
*/
|
||||||
function setCacheHas(value) {
|
function setCacheHas(value) {
|
||||||
return this.__data__.has(value);
|
return this.__data__.has(value);
|
||||||
@@ -3766,7 +3771,7 @@
|
|||||||
if (isArray(iteratee)) {
|
if (isArray(iteratee)) {
|
||||||
return function(value) {
|
return function(value) {
|
||||||
return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);
|
return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return iteratee;
|
return iteratee;
|
||||||
});
|
});
|
||||||
@@ -4370,8 +4375,34 @@
|
|||||||
*/
|
*/
|
||||||
function baseUnset(object, path) {
|
function baseUnset(object, path) {
|
||||||
path = castPath(path, object);
|
path = castPath(path, object);
|
||||||
object = parent(object, path);
|
|
||||||
return object == null || delete object[toKey(last(path))];
|
// Prevent prototype pollution:
|
||||||
|
// https://github.com/lodash/lodash/security/advisories/GHSA-xxjr-mmjv-4gpg
|
||||||
|
// https://github.com/lodash/lodash/security/advisories/GHSA-f23m-r3pf-42rh
|
||||||
|
var index = -1,
|
||||||
|
length = path.length;
|
||||||
|
|
||||||
|
if (!length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (++index < length) {
|
||||||
|
var key = toKey(path[index]);
|
||||||
|
|
||||||
|
// Always block "__proto__" anywhere in the path if it's not expected
|
||||||
|
if (key === '__proto__' && !hasOwnProperty.call(object, '__proto__')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block constructor/prototype as non-terminal traversal keys to prevent
|
||||||
|
// escaping the object graph into built-in constructors and prototypes.
|
||||||
|
if ((key === 'constructor' || key === 'prototype') && index < length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = parent(object, path);
|
||||||
|
return obj == null || delete obj[toKey(last(path))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6922,7 +6953,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an array with all falsey values removed. The values `false`, `null`,
|
* Creates an array with all falsey values removed. The values `false`, `null`,
|
||||||
* `0`, `""`, `undefined`, and `NaN` are falsey.
|
* `0`, `-0`, `0n`, `""`, `undefined`, and `NaN` are falsy.
|
||||||
*
|
*
|
||||||
* @static
|
* @static
|
||||||
* @memberOf _
|
* @memberOf _
|
||||||
@@ -7461,7 +7492,7 @@
|
|||||||
|
|
||||||
while (++index < length) {
|
while (++index < length) {
|
||||||
var pair = pairs[index];
|
var pair = pairs[index];
|
||||||
result[pair[0]] = pair[1];
|
baseAssignValue(result, pair[0], pair[1]);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -14121,6 +14152,8 @@
|
|||||||
* **Note:** JavaScript follows the IEEE-754 standard for resolving
|
* **Note:** JavaScript follows the IEEE-754 standard for resolving
|
||||||
* floating-point values which can produce unexpected results.
|
* floating-point values which can produce unexpected results.
|
||||||
*
|
*
|
||||||
|
* **Note:** If `lower` is greater than `upper`, the values are swapped.
|
||||||
|
*
|
||||||
* @static
|
* @static
|
||||||
* @memberOf _
|
* @memberOf _
|
||||||
* @since 0.7.0
|
* @since 0.7.0
|
||||||
@@ -14134,9 +14167,16 @@
|
|||||||
* _.random(0, 5);
|
* _.random(0, 5);
|
||||||
* // => an integer between 0 and 5
|
* // => an integer between 0 and 5
|
||||||
*
|
*
|
||||||
|
* // when lower is greater than upper the values are swapped
|
||||||
|
* _.random(5, 0);
|
||||||
|
* // => an integer between 0 and 5
|
||||||
|
*
|
||||||
* _.random(5);
|
* _.random(5);
|
||||||
* // => also an integer between 0 and 5
|
* // => also an integer between 0 and 5
|
||||||
*
|
*
|
||||||
|
* _.random(-5);
|
||||||
|
* // => an integer between -5 and 0
|
||||||
|
*
|
||||||
* _.random(5, true);
|
* _.random(5, true);
|
||||||
* // => a floating-point number between 0 and 5
|
* // => a floating-point number between 0 and 5
|
||||||
*
|
*
|
||||||
@@ -14738,6 +14778,10 @@
|
|||||||
* properties may be accessed as free variables in the template. If a setting
|
* properties may be accessed as free variables in the template. If a setting
|
||||||
* object is given, it takes precedence over `_.templateSettings` values.
|
* object is given, it takes precedence over `_.templateSettings` values.
|
||||||
*
|
*
|
||||||
|
* **Security:** `_.template` is insecure and should not be used. It will be
|
||||||
|
* removed in Lodash v5. Avoid untrusted input. See
|
||||||
|
* [threat model](https://github.com/lodash/lodash/blob/main/threat-model.md).
|
||||||
|
*
|
||||||
* **Note:** In the development build `_.template` utilizes
|
* **Note:** In the development build `_.template` utilizes
|
||||||
* [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)
|
* [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)
|
||||||
* for easier debugging.
|
* for easier debugging.
|
||||||
@@ -14845,12 +14889,18 @@
|
|||||||
options = undefined;
|
options = undefined;
|
||||||
}
|
}
|
||||||
string = toString(string);
|
string = toString(string);
|
||||||
options = assignInWith({}, options, settings, customDefaultsAssignIn);
|
options = assignWith({}, options, settings, customDefaultsAssignIn);
|
||||||
|
|
||||||
var imports = assignInWith({}, options.imports, settings.imports, customDefaultsAssignIn),
|
var imports = assignWith({}, options.imports, settings.imports, customDefaultsAssignIn),
|
||||||
importsKeys = keys(imports),
|
importsKeys = keys(imports),
|
||||||
importsValues = baseValues(imports, importsKeys);
|
importsValues = baseValues(imports, importsKeys);
|
||||||
|
|
||||||
|
arrayEach(importsKeys, function(key) {
|
||||||
|
if (reForbiddenIdentifierChars.test(key)) {
|
||||||
|
throw new Error(INVALID_TEMPL_IMPORTS_ERROR_TEXT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
var isEscaping,
|
var isEscaping,
|
||||||
isEvaluating,
|
isEvaluating,
|
||||||
index = 0,
|
index = 0,
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ body {
|
|||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
background-color: $white;
|
background-color: $white;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
// 콘텐츠가 짧은 페이지에서도 footer 가 화면 하단에 붙도록(sticky footer).
|
||||||
|
// footer 는 .global-footer 의 margin-top:auto 로 밀려난다.
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// flex 컨테이너가 되면 자식이 축소(shrink)되거나 내부 콘텐츠 min-content 폭까지 늘어난다.
|
||||||
|
// block 레이아웃과 동일한 폭 계산이 되도록 고정한다(모바일 가로 스크롤 방지).
|
||||||
|
> * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
|
|||||||
@@ -163,6 +163,8 @@
|
|||||||
background-color: rgb(15, 23, 42);
|
background-color: rgb(15, 23, 42);
|
||||||
color: rgb(100, 116, 139);
|
color: rgb(100, 116, 139);
|
||||||
padding: 60px 0px;
|
padding: 60px 0px;
|
||||||
|
// body(flex column) 기준으로 남은 공간을 위쪽 여백으로 흡수 → 짧은 페이지에서 화면 하단 고정
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|||||||
@@ -156,6 +156,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 활성 Spring 프로파일 표시 (prod 제외)
|
||||||
|
// 데스크톱: 화면 최좌측·최상단 floating 뱃지 / 모바일·태블릿: 최상단 floating 바
|
||||||
|
// 둘 다 fixed 라 헤더 레이아웃(로고·메뉴 정렬)에는 영향을 주지 않는다.
|
||||||
|
.env-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
// 좌상단 모서리에 붙으므로 우/하단만 둥글게
|
||||||
|
border-radius: 0 0 4px 0;
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 1100;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-strip {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent-orange);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
z-index: 1100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Logo Wrapper
|
// Logo Wrapper
|
||||||
.logo-wrapper {
|
.logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1198,6 +1198,11 @@
|
|||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #2a69de;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,40 @@
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 본인 글 삭제 버튼(아코디언 펼친 상태에서만 노출)
|
||||||
|
.recent-apps-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-delete {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
<form id="searchForm" th:action="@{/apis}" method="get">
|
<form id="searchForm" th:action="@{/apis}" method="get">
|
||||||
<input type="hidden" name="groupIds"
|
<input type="hidden" name="groupIds"
|
||||||
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
|
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
|
||||||
|
<input type="hidden" name="page" value="1" />
|
||||||
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
|
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
|
||||||
placeholder="검색어를 입력하세요.">
|
placeholder="검색어를 입력하세요.">
|
||||||
<button type="submit" class="search-submit-btn" aria-label="검색">
|
<button type="submit" class="search-submit-btn" aria-label="검색">
|
||||||
@@ -99,13 +100,17 @@
|
|||||||
|
|
||||||
<!-- API Image (Bottom Right) -->
|
<!-- API Image (Bottom Right) -->
|
||||||
<div class="api-card-image">
|
<div class="api-card-image">
|
||||||
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}"
|
<img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
|
||||||
onerror="this.style.display='none'">
|
th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="pagination" th:if="${apis != null and !apis.isEmpty()}"
|
||||||
|
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
|
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
|
||||||
<div class="empty-icon">🔍</div>
|
<div class="empty-icon">🔍</div>
|
||||||
@@ -121,6 +126,14 @@
|
|||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
|
// fragment/pagination.html 의 인라인 onclick 에서 호출 (전역 스코프 필요)
|
||||||
|
window.fn_select_page = function (pageNo) {
|
||||||
|
const form = document.getElementById('searchForm');
|
||||||
|
const pageInput = form.querySelector('input[name="page"]');
|
||||||
|
if (pageInput) pageInput.value = pageNo;
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const sidebar = document.getElementById('apiSidebar');
|
const sidebar = document.getElementById('apiSidebar');
|
||||||
@@ -128,6 +141,7 @@
|
|||||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||||
const searchForm = document.getElementById('searchForm');
|
const searchForm = document.getElementById('searchForm');
|
||||||
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
|
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
|
||||||
|
const pageInput = searchForm.querySelector('input[name="page"]');
|
||||||
const searchInput = searchForm.querySelector('input[name="keyword"]');
|
const searchInput = searchForm.querySelector('input[name="keyword"]');
|
||||||
|
|
||||||
// API Card Click Handlers
|
// API Card Click Handlers
|
||||||
@@ -230,6 +244,9 @@
|
|||||||
groupIdsInput.value = '';
|
groupIdsInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 변경 시 1페이지로 리셋
|
||||||
|
if (pageInput) pageInput.value = '1';
|
||||||
|
|
||||||
// Submit form
|
// Submit form
|
||||||
searchForm.submit();
|
searchForm.submit();
|
||||||
});
|
});
|
||||||
@@ -239,6 +256,7 @@
|
|||||||
searchInput.addEventListener('keypress', function (e) {
|
searchInput.addEventListener('keypress', function (e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (pageInput) pageInput.value = '1';
|
||||||
searchForm.submit();
|
searchForm.submit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,21 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="recent-apps-body">
|
<div class="recent-apps-body">
|
||||||
<p class="recent-apps-detail" th:text="${item.bizDetail}">내용</p>
|
<p class="recent-apps-detail" th:text="${item.bizDetail}">내용</p>
|
||||||
|
<div class="recent-apps-actions">
|
||||||
|
<form class="recent-apps-delete-form" method="post"
|
||||||
|
th:action="@{/partnership/{id}/delete(id=${item.id})}">
|
||||||
|
<button type="button" class="recent-apps-delete" th:attr="data-subject=${item.bizSubject}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" aria-hidden="true">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
||||||
|
<path d="M10 11v6"></path>
|
||||||
|
<path d="M14 11v6"></path>
|
||||||
|
</svg>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -265,6 +280,17 @@
|
|||||||
item.siblings('.recent-apps-item').removeClass('active').find('.recent-apps-body').slideUp(200);
|
item.siblings('.recent-apps-item').removeClass('active').find('.recent-apps-body').slideUp(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 최근 글 삭제 — 확인 팝업 후 항목별 form 전송(POST /partnership/{id}/delete)
|
||||||
|
$('.recent-apps-delete').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const form = $(this).closest('form.recent-apps-delete-form').get(0);
|
||||||
|
const subject = $(this).data('subject') || '';
|
||||||
|
customPopups.showConfirm('피드백/개선요청 [' + subject + '] 을(를) 삭제하시겠습니까?', function (ok) {
|
||||||
|
if (ok) form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Focus on subject field
|
// Focus on subject field
|
||||||
document.getElementById('bizSubject').focus();
|
document.getElementById('bizSubject').focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
|
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
|
||||||
|
|||||||
@@ -238,10 +238,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="card-illustration">
|
<div class="card-illustration">
|
||||||
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 -->
|
<!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
|
||||||
<img
|
<img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
|
||||||
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
|
|
||||||
th:alt="${service.groupName}">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,9 +558,6 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
|
|
||||||
<script th:src="@{/js/main.js}"></script>
|
|
||||||
|
|
||||||
<!-- 로그인 후 처리 스크립트 -->
|
<!-- 로그인 후 처리 스크립트 -->
|
||||||
<script th:src="@{/js/login-success-handler.js}"></script>
|
<script th:src="@{/js/login-success-handler.js}"></script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
<label class="dt-label">API 목록</label>
|
<label class="dt-label">API 목록</label>
|
||||||
<div class="dt-api-list-box">
|
<div class="dt-api-list-box">
|
||||||
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
||||||
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
|
<a class="dt-api-name" th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">추가된 API 1</a>
|
||||||
<span class="dt-api-status-badge">승인</span>
|
<span class="dt-api-status-badge">승인</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
||||||
|
|||||||
@@ -754,15 +754,15 @@
|
|||||||
if (withdrawalBtn) {
|
if (withdrawalBtn) {
|
||||||
withdrawalBtn.addEventListener('click', (e) => {
|
withdrawalBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
customPopups.showWithdrawal();
|
customPopups.showAlert(
|
||||||
|
'법인 관리자는 회원 탈퇴를 할 수 없습니다.<br>' +
|
||||||
|
'관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
<section layout:fragment="pagePopups">
|
|
||||||
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
|
|
||||||
</section>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
|
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
|
||||||
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
|
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}">
|
<button type="button" class="btn-action-primary md btn_check_email" th:classappend="${isInvited ? 'hidden' : ''}">
|
||||||
중복체크
|
중복체크
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
비밀번호 확인 <span class="required-badge">필수</span>
|
비밀번호 확인 <span class="required-badge">필수</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="org-form-input-wrapper">
|
<div class="org-form-input-wrapper">
|
||||||
<input type="password" name="password2" id="password2" class="org-form-input"
|
<input type="password" name="confirmPassword" id="confirmPassword" class="org-form-input"
|
||||||
th:placeholder="#{portalUser.Register.passConfirm}">
|
th:placeholder="#{portalUser.Register.passConfirm}">
|
||||||
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
|
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
|
||||||
<div id="password-match-validation" class="org-validation-message"></div>
|
<div id="password-match-validation" class="org-validation-message"></div>
|
||||||
@@ -411,9 +411,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 비밀번호 확인 검증
|
// 비밀번호 확인 검증
|
||||||
$('#password2').on('blur', function () {
|
$('#confirmPassword').on('blur', function () {
|
||||||
let password2 = $(this).val();
|
let confirmPassword = $(this).val();
|
||||||
if (!password2) {
|
if (!confirmPassword) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
password: $('#password').val(),
|
password: $('#password').val(),
|
||||||
password2: password2,
|
confirmPassword: confirmPassword,
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
},
|
},
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
// 시나리오별 필수 필드 정의
|
// 시나리오별 필수 필드 정의
|
||||||
const requiredFieldsByScenario = {
|
const requiredFieldsByScenario = {
|
||||||
new: {
|
new: {
|
||||||
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
|
user: ['loginId', 'userName', 'password', 'confirmPassword', 'mobileNumber', 'authNumber'],
|
||||||
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
|
||||||
},
|
},
|
||||||
retain: {
|
retain: {
|
||||||
@@ -228,6 +228,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add confirmPassword manually based on the scenario
|
// Add confirmPassword manually based on the scenario
|
||||||
|
// (new 시나리오는 #confirmPassword 필드 자체가 있어 위 공통 user 필드 루프가 그대로 처리한다.
|
||||||
|
// 서버는 시나리오 무관하게 confirmPassword 단일 필드로 비밀번호 확인을 검증한다 —
|
||||||
|
// OrgRegisterFacadeImpl.registerNewOrgUser 참고.)
|
||||||
if (registrationScenario === 'retain') {
|
if (registrationScenario === 'retain') {
|
||||||
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
|
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
|
||||||
if (passwordConfirmIndividual) {
|
if (passwordConfirmIndividual) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="webhook-info-group">
|
<div class="webhook-info-group">
|
||||||
<span class="group-label">대상 API</span>
|
<span class="group-label">대상 API</span>
|
||||||
<div class="badges-row">
|
<div class="badges-row">
|
||||||
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
|
<span class="api-badge" th:each="api : *{apis}" th:text="${api.name}" th:title="${api.id}">API</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
|
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
<!-- 고객센터 연락처: PortalProperty(Portal/customer.center.contact). 값이 공백이면 미노출 -->
|
||||||
|
<p class="footer-contact" th:if="${!#strings.isEmpty(#strings.trim(customerCenterContact))}"
|
||||||
|
th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||||
<body>
|
<body>
|
||||||
<th:block th:fragment="headerFragment(headerClass)">
|
<th:block th:fragment="headerFragment(headerClass)">
|
||||||
|
<!-- 활성 프로파일 표시(prod 제외). 데스크톱은 로고 옆 뱃지, 모바일/태블릿은 최상단 floating 바(env-badge 는 그때 숨김) -->
|
||||||
|
<div th:if="${activeProfileBadge != null}" class="env-strip" th:text="${activeProfileBadge}">dev</div>
|
||||||
<!-- Global Header Container -->
|
<!-- Global Header Container -->
|
||||||
<header class="global-header" th:classappend="${headerClass}">
|
<header class="global-header" th:classappend="${headerClass}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
|
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
|
||||||
</a>
|
</a>
|
||||||
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
|
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
|
||||||
|
<span th:if="${activeProfileBadge != null}" class="env-badge" th:text="${activeProfileBadge}">dev</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||||
|
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthNumberServiceImplTest {
|
||||||
|
|
||||||
|
private static final Pattern RETRY_SECONDS = Pattern.compile(
|
||||||
|
"인증번호 재발송 제한이 적용 중입니다\\. (\\d+)초 후 다시 시도해 주세요\\.");
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuthNumberStorage storage;
|
||||||
|
@Mock
|
||||||
|
private AuthNumberGenerator generator;
|
||||||
|
@Mock
|
||||||
|
private MessageSender messageSender;
|
||||||
|
|
||||||
|
private AuthNumberServiceImpl service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new AuthNumberServiceImpl(storage, generator, messageSender);
|
||||||
|
ReflectionTestUtils.setField(service, "authNumberExpirationTime", 300);
|
||||||
|
ReflectionTestUtils.setField(service, "resendLimitSeconds", 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resendLimitMessageIncludesRemainingSeconds() {
|
||||||
|
String recipient = "01099121100";
|
||||||
|
TwoFactorAuth existing = new TwoFactorAuth(
|
||||||
|
recipient, "123456", LocalDateTime.now().plusSeconds(300));
|
||||||
|
when(storage.getAuthNumber(recipient)).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
AuthNumberException exception = assertThrows(
|
||||||
|
AuthNumberException.class,
|
||||||
|
() -> service.sendRequestAuthNumber(recipient, "SMS")
|
||||||
|
);
|
||||||
|
|
||||||
|
Matcher matcher = RETRY_SECONDS.matcher(exception.getMessage());
|
||||||
|
assertTrue(matcher.matches(), "남은 재시도 초가 안내 메시지에 포함되어야 함");
|
||||||
|
long remainingSeconds = Long.parseLong(matcher.group(1));
|
||||||
|
assertTrue(remainingSeconds >= 1 && remainingSeconds <= 30,
|
||||||
|
"남은 초는 1~30 범위여야 함: " + remainingSeconds);
|
||||||
|
assertNull(exception.getReason());
|
||||||
|
verifyNoInteractions(generator, messageSender);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.eactive.apim.portal.apps.user;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PasswordService;
|
||||||
|
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PasswordServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PortalUserRepository portalUserRepository;
|
||||||
|
@Mock
|
||||||
|
private UserPasswordHistoryRepository passwordHistoryRepository;
|
||||||
|
@Mock
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
@Mock
|
||||||
|
private LocalValidatorFactoryBean validator;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PasswordService passwordService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsPasswordStoredAgainstPortalUserId() {
|
||||||
|
PortalUser user = new PortalUser();
|
||||||
|
user.setId("user-uuid");
|
||||||
|
user.setLoginId("user@example.com");
|
||||||
|
user.setMobileNumber("010-1234-5678");
|
||||||
|
user.setPasswordHash("temporary-password-hash");
|
||||||
|
|
||||||
|
UserPasswordHistory previousPassword = new UserPasswordHistory();
|
||||||
|
previousPassword.setUserId("user-uuid");
|
||||||
|
previousPassword.setPasswordHash("original-password-hash");
|
||||||
|
|
||||||
|
when(portalUserRepository.findByLoginId("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("Original!123", "temporary-password-hash")).thenReturn(false);
|
||||||
|
when(validator.validate(any(PasswordValidationDTO.class))).thenReturn(Collections.emptySet());
|
||||||
|
when(passwordHistoryRepository.findRecentPasswordsByUserId("user-uuid"))
|
||||||
|
.thenReturn(Collections.singletonList(previousPassword));
|
||||||
|
when(passwordEncoder.matches("Original!123", "original-password-hash")).thenReturn(true);
|
||||||
|
|
||||||
|
IllegalArgumentException exception = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> passwordService.updatePassword(
|
||||||
|
"user@example.com", "Original!123", "Original!123")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("최근 5회 이내에 사용한 비밀번호는 사용할 수 없습니다.", exception.getMessage());
|
||||||
|
verify(passwordHistoryRepository).findRecentPasswordsByUserId("user-uuid");
|
||||||
|
verify(passwordHistoryRepository, never()).findRecentPasswordsByUserId("user@example.com");
|
||||||
|
verify(portalUserRepository, never()).save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.eactive.apim.portal.apps.user;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
|
import com.eactive.apim.portal.apps.user.facade.MessageRequestFacade;
|
||||||
|
import com.eactive.apim.portal.apps.user.facade.UserFacadeImpl;
|
||||||
|
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PasswordService;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserFacadeImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PortalUserService portalUserService;
|
||||||
|
@Mock
|
||||||
|
private PortalOrgService portalOrgService;
|
||||||
|
@Mock
|
||||||
|
private PasswordService passwordService;
|
||||||
|
@Mock
|
||||||
|
private PortalUserMapper portalUserMapper;
|
||||||
|
@Mock
|
||||||
|
private MessageHandlerService messageHandlerService;
|
||||||
|
@Mock
|
||||||
|
private AgreementsFacade agreementsFacade;
|
||||||
|
@Mock
|
||||||
|
private MessageRequestFacade messageRequestFacade;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private UserFacadeImpl userFacade;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void corporateManagerCannotWithdraw() {
|
||||||
|
PortalUser manager = new PortalUser();
|
||||||
|
manager.setId("manager-1");
|
||||||
|
manager.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
|
||||||
|
when(portalUserService.findById("manager-1")).thenReturn(manager);
|
||||||
|
|
||||||
|
IllegalArgumentException exception = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> userFacade.withdrawUser("manager-1", "withdrawal reason")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
|
||||||
|
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.",
|
||||||
|
exception.getMessage()
|
||||||
|
);
|
||||||
|
verifyNoInteractions(agreementsFacade, messageRequestFacade);
|
||||||
|
verify(portalUserService, never()).deleteUser(manager, "withdrawal reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void corporateUserCanWithdraw() {
|
||||||
|
PortalUser user = new PortalUser();
|
||||||
|
user.setId("user-1");
|
||||||
|
user.setLoginId("corp-user@example.com");
|
||||||
|
user.setUserName("법인 사용자");
|
||||||
|
user.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||||
|
when(portalUserService.findById("user-1")).thenReturn(user);
|
||||||
|
|
||||||
|
userFacade.withdrawUser("user-1", "withdrawal reason");
|
||||||
|
|
||||||
|
verify(agreementsFacade).deleteUserAgreements("user-1");
|
||||||
|
verify(messageRequestFacade).deleteUserMessage("법인 사용자", "corp-user@example.com");
|
||||||
|
verify(portalUserService).deleteUser(user, "withdrawal reason");
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import javax.persistence.EntityManager;
|
||||||
|
import javax.persistence.Query;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class OrphanCleanupServiceTest {
|
||||||
|
|
||||||
|
@Mock private Environment environment;
|
||||||
|
@Mock private EntityManager entityManager;
|
||||||
|
@Mock private Query query;
|
||||||
|
|
||||||
|
private OrphanCleanupService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new OrphanCleanupService(environment);
|
||||||
|
ReflectionTestUtils.setField(service, "entityManager", entityManager);
|
||||||
|
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
|
||||||
|
when(entityManager.createNativeQuery(org.mockito.ArgumentMatchers.anyString())).thenReturn(query);
|
||||||
|
when(query.executeUpdate()).thenReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sweep_runsAllThirteenTablesAndReportsCounts() {
|
||||||
|
when(query.executeUpdate()).thenReturn(3);
|
||||||
|
|
||||||
|
TestCleanupResult result = service.sweep();
|
||||||
|
|
||||||
|
assertEquals(13, result.getDeletedCounts().size());
|
||||||
|
result.getDeletedCounts().values().forEach(count -> assertEquals(3L, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sweep_prodProfile_throws() {
|
||||||
|
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.sweep());
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||||
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
|
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||||
|
import com.eactive.apim.portal.approval.entity.Approval;
|
||||||
|
import com.eactive.apim.portal.approval.statemachine.RequestedState;
|
||||||
|
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
|
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||||
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||||
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||||
|
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class TestCleanupServiceTest {
|
||||||
|
|
||||||
|
@Mock private Environment environment;
|
||||||
|
@Mock private PortalOrgRepository portalOrgRepository;
|
||||||
|
@Mock private PortalUserRepository portalUserRepository;
|
||||||
|
@Mock private InquiryRepository inquiryRepository;
|
||||||
|
@Mock private InquiryCommentRepository inquiryCommentRepository;
|
||||||
|
@Mock private PartnershipApplicationRepository partnershipApplicationRepository;
|
||||||
|
@Mock private UserRoleHistoryRepository userRoleHistoryRepository;
|
||||||
|
@Mock private PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
|
||||||
|
@Mock private UserPasswordHistoryRepository userPasswordHistoryRepository;
|
||||||
|
@Mock private UserLogRepository userLogRepository;
|
||||||
|
@Mock private CredentialRepository credentialRepository;
|
||||||
|
@Mock private AppRequestRepository appRequestRepository;
|
||||||
|
@Mock private ApprovalService approvalService;
|
||||||
|
@Mock private WebhookRequestRepository webhookRequestRepository;
|
||||||
|
@Mock private WebhookRequestApiRepository webhookRequestApiRepository;
|
||||||
|
@Mock private WebhookRequestEventRepository webhookRequestEventRepository;
|
||||||
|
@Mock private WebhookService webhookService;
|
||||||
|
@Mock private UserInvitationRepository userInvitationRepository;
|
||||||
|
@Mock private FileService fileService;
|
||||||
|
@Mock private TestCleanupNativeQueries nativeQueries;
|
||||||
|
@Mock private PortalUserService portalUserService;
|
||||||
|
@Mock private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private TestCleanupService service;
|
||||||
|
|
||||||
|
private static final String ORG_ID = "ORG1";
|
||||||
|
private static final String USER_ID = "USER1";
|
||||||
|
private static final String LOGIN_ID = "user1@test.com";
|
||||||
|
private static final String COMP_REG_NO = "1234567890";
|
||||||
|
private static final String EMAIL = "user1@test.com";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository,
|
||||||
|
inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository,
|
||||||
|
userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository,
|
||||||
|
userLogRepository, credentialRepository, appRequestRepository, approvalService,
|
||||||
|
webhookRequestRepository, webhookRequestApiRepository,
|
||||||
|
webhookRequestEventRepository, webhookService, userInvitationRepository, fileService,
|
||||||
|
nativeQueries, portalUserService, passwordEncoder);
|
||||||
|
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PortalUser user() {
|
||||||
|
PortalUser u = new PortalUser();
|
||||||
|
u.setId(USER_ID);
|
||||||
|
u.setLoginId(LOGIN_ID);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrgCascade_notFound() {
|
||||||
|
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||||
|
|
||||||
|
assertFalse(result.isFound());
|
||||||
|
assertTrue(result.getDeletedCounts().isEmpty());
|
||||||
|
verifyNoInteractions(webhookService, portalUserRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrgCascade_fallsBackToDigitsOnlyBusinessNumber() {
|
||||||
|
String hyphenatedCompRegNo = "123-45-67890";
|
||||||
|
String digitsOnlyCompRegNo = "1234567890";
|
||||||
|
PortalOrg org = org();
|
||||||
|
when(portalOrgRepository.findByCompRegNo(hyphenatedCompRegNo)).thenReturn(Optional.empty());
|
||||||
|
when(portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo)).thenReturn(Optional.of(org));
|
||||||
|
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList());
|
||||||
|
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteOrgCascade(hyphenatedCompRegNo);
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals(ORG_ID, result.getTargetId());
|
||||||
|
verify(portalOrgRepository).findByCompRegNo(digitsOnlyCompRegNo);
|
||||||
|
verify(portalOrgRepository).delete(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrgCascade_found_cascadesUsersAndOrg() {
|
||||||
|
PortalOrg org = org();
|
||||||
|
PortalUser member = user();
|
||||||
|
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org));
|
||||||
|
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.singletonList(member));
|
||||||
|
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty());
|
||||||
|
when(nativeQueries.deleteCredentialApiByOrgId(ORG_ID)).thenReturn(2);
|
||||||
|
when(credentialRepository.deleteByOrgid(ORG_ID)).thenReturn(1L);
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals(ORG_ID, result.getTargetId());
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_USER"));
|
||||||
|
assertEquals(2L, result.getDeletedCounts().get("PTL_CREDENTIAL_API"));
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_CREDENTIAL"));
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_ORG"));
|
||||||
|
assertEquals(0L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ"));
|
||||||
|
verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID);
|
||||||
|
verify(inquiryRepository).deleteByInquirer_Id(USER_ID);
|
||||||
|
verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID);
|
||||||
|
verify(userPasswordHistoryRepository).deleteByUserId(USER_ID);
|
||||||
|
verify(portalUserRepository).delete(member);
|
||||||
|
verify(webhookService, never()).delete(anyLong(), anyString());
|
||||||
|
verify(portalOrgRepository).delete(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrgCascade_withWebhook_delegatesToWebhookService() {
|
||||||
|
PortalOrg org = org();
|
||||||
|
WebhookRequest webhook = new WebhookRequest();
|
||||||
|
webhook.setId(99L);
|
||||||
|
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org));
|
||||||
|
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList());
|
||||||
|
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.of(webhook));
|
||||||
|
when(webhookRequestApiRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList());
|
||||||
|
when(webhookRequestEventRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||||
|
|
||||||
|
verify(webhookService, times(1)).delete(99L, ORG_ID);
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserCascade_notFound() {
|
||||||
|
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteUserCascade(EMAIL);
|
||||||
|
|
||||||
|
assertFalse(result.isFound());
|
||||||
|
assertTrue(result.getDeletedCounts().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkUserExists_found_doesNotDeleteData() {
|
||||||
|
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(user()));
|
||||||
|
|
||||||
|
TestCleanupResult result = service.checkUserExists(EMAIL);
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals(USER_ID, result.getTargetId());
|
||||||
|
assertTrue(result.getDeletedCounts().isEmpty());
|
||||||
|
verify(portalUserRepository, never()).delete(org.mockito.ArgumentMatchers.any(PortalUser.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkUserExists_notFound() {
|
||||||
|
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
TestCleanupResult result = service.checkUserExists(EMAIL);
|
||||||
|
|
||||||
|
assertFalse(result.isFound());
|
||||||
|
assertTrue(result.getDeletedCounts().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteInvitationsByMobile_normalizesAndDeletesAllMatches() {
|
||||||
|
when(userInvitationRepository.deleteByInvitationMobile("010-1234-5678")).thenReturn(2L);
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteInvitationsByMobile("01012345678");
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals("010-1234-5678", result.getTargetId());
|
||||||
|
assertEquals(2L, result.getDeletedCounts().get("PTL_USER_INVITATION"));
|
||||||
|
verify(userInvitationRepository).deleteByInvitationMobile("010-1234-5678");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detachUsersFromOrgByMobile_keepsUserAndRemovesOnlyMembership() {
|
||||||
|
PortalUser target = user();
|
||||||
|
target.setPortalOrg(org());
|
||||||
|
when(portalUserRepository.findAllByMobileNumber("010-1234-5678"))
|
||||||
|
.thenReturn(Collections.singletonList(target));
|
||||||
|
|
||||||
|
TestCleanupResult result = service.detachUsersFromOrgByMobile("01012345678");
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals(USER_ID, result.getTargetId());
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_USER_ORG_MEMBERSHIP"));
|
||||||
|
assertNull(target.getPortalOrg());
|
||||||
|
assertEquals(com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode.ROLE_USER, target.getRoleCode());
|
||||||
|
verify(portalUserRepository).save(target);
|
||||||
|
verify(portalUserRepository, never()).delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelPendingTestAppRequests_cancelsOnlyExactTestAppRequest() {
|
||||||
|
PortalUser target = user();
|
||||||
|
PortalOrg org = org();
|
||||||
|
target.setPortalOrg(org);
|
||||||
|
AppRequest request = new AppRequest();
|
||||||
|
request.setId("APP_REQ_1");
|
||||||
|
request.setClientName("단위테스트앱-20260824-144907");
|
||||||
|
Approval approval = new Approval();
|
||||||
|
approval.setApprovalStatus(new RequestedState());
|
||||||
|
request.setApproval(approval);
|
||||||
|
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target));
|
||||||
|
when(appRequestRepository.findAllByOrgAndClientName(org, request.getClientName()))
|
||||||
|
.thenReturn(Collections.singletonList(request));
|
||||||
|
|
||||||
|
TestCleanupResult result = service.cancelPendingTestAppRequests(EMAIL, request.getClientName());
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals("APP_REQ_1", result.getTargetId());
|
||||||
|
assertEquals(1L, result.getDeletedCounts().get("PTL_APP_REQUEST_CANCELLED"));
|
||||||
|
verify(approvalService).cancelAppApproval(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelPendingTestAppRequests_rejectsNonTestName() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.cancelPendingTestAppRequests(EMAIL, "운영앱"));
|
||||||
|
verifyNoInteractions(appRequestRepository, approvalService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserCascade_found_deletesOrphanDataAndUser() {
|
||||||
|
PortalUser target = user();
|
||||||
|
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target));
|
||||||
|
|
||||||
|
TestCleanupResult result = service.deleteUserCascade(EMAIL);
|
||||||
|
|
||||||
|
assertTrue(result.isFound());
|
||||||
|
assertEquals(USER_ID, result.getTargetId());
|
||||||
|
verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID);
|
||||||
|
verify(inquiryRepository).deleteByInquirer_Id(USER_ID);
|
||||||
|
verify(partnershipApplicationRepository).deleteByCreatedBy(USER_ID);
|
||||||
|
verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID);
|
||||||
|
verify(portalUserPrivacyAgreementRepository).deleteByCreatedBy(USER_ID);
|
||||||
|
verify(userPasswordHistoryRepository).deleteByUserId(USER_ID);
|
||||||
|
verify(userLogRepository).deleteByLoginId(LOGIN_ID);
|
||||||
|
verify(portalUserRepository).delete(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrgCascade_prodProfile_throws() {
|
||||||
|
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.deleteOrgCascade(COMP_REG_NO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserCascade_prodProfile_throws() {
|
||||||
|
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.deleteUserCascade(EMAIL));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PortalOrg org() {
|
||||||
|
PortalOrg o = new PortalOrg();
|
||||||
|
o.setId(ORG_ID);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user