27 Commits

Author SHA1 Message Date
Rinjae a456ed9bb0 사용자 Secret 관리 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 사용자 지정 Secret 필드 추가(WebhookRequest, DTO, Mapper)
- 사용자 Secret 평문 조회/갱신 로직 및 비밀번호 재인증 처리
- Secret 입력 UI 개선 및 스타일 수정
2026-08-25 15:16:57 +09:00
Rinjae 33c64972f8 - 브랜드명 템플릿화 적용 - 정적 리소스/HTML 다수 수정
- 서비스 소개 페이지 디자인/스타일 대폭 개선
- 웹훅/OAuth2 개발 가이드 문구 및 텍스트 업데이트/템플릿화
2026-08-25 13:42:30 +09:00
Rinjae cff0199d2d TestCleanupService 확장 - FileService/PasswordEncoder 추가 Mock 설정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-24 21:16:23 +09:00
Rinjae 2e920eec96 4010-Q&A 테스트 계정 준비 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 법인개발자 계정 소속/비밀번호 초기화(`ensureTestCorpDeveloper`)
- 비밀번호만 재설정하는 테스트 API 추가(`resetTestPassword`)
- Q&A 테스트 문서 및 시나리오 수정(계정 준비 단계 8-3 추가)
2026-08-24 18:45:50 +09:00
Rinjae 65a262849e - 4020-partnership 테스트 추가 - 등록/조회/삭제 시나리오 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- e2e/admin-partnership.page.ts - admin 접근 및 확인 로직 작성
- e2e/partnership-feedback.spec.ts - e2e 테스트 케이스 작성
2026-08-24 17:51:36 +09:00
Rinjae 7056306718 - 파트너십 글 단건 조회/삭제 메서드 추가 및 본인 확인 로직 적용
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestCleanupService 확장 - 테스트 글 삭제 및 첨부파일 정리 추가
- 레이아웃 개선 - footer 하단 고정(sticky) 처리
2026-08-24 17:21:47 +09:00
Rinjae 05e0599b26 - AppRequestRepository에 findAllByOrgAndClientName 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 클라이언트 신청 해지 프로세스 및 테스트 클린업 로직 구현
- Playwright API 키 관리/클라이언트 관리 경로 및 셀렉터 업데이트
2026-08-24 15:25:18 +09:00
Rinjae c223fa3124 PasswordServiceTest 추가 - 최근 비밀번호 중복 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
TestCleanupService 확장 - 초대/회원 소속 관리 메서드 구현
API 추가 - 초대 삭제 및 소속 제외, 사용자 존재 여부 확인
2026-08-24 14:13:26 +09:00
Rinjae 78b1edff5a - 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword)
- 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정
- 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
2026-08-24 13:17:14 +09:00
Rinjae b41a81bda3 Webhook API 표시 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- WebhookDTO에 API 이름 포함 필드 추가(WebhookApiDTO)
- 프로필 활성화 뱃지 CSS 수정(고정 위치, 크기 축소) - 정적 자원 서빙 경로 설정 개선(staticBase 적용)
2026-08-24 10:37:12 +09:00
Rinjae 1ea2c89776 - 인덱스 통계 API 이용 건수 로직 수정 - 월누적 호출 건수 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GwAuthClient 리포지토리 다건 org용 메서드 추가
- 통계 DTO 주석 수정 및 관련 메서드 추가
2026-08-21 18:07:06 +09:00
Rinjae f1d6181210 - Playwright 테스트 정리용 internal API 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- /internal/test-cleanup/{org,user,orphans} 신설
- dev 프로필 전용, 토큰+IP allowlist 이중 가드
- 13개 고아 테이블 cascade/스윕 삭제 서비스
- 리포지토리 deleteBy 메서드 추가, 단위테스트 포함
2026-08-21 17:57:51 +09:00
Rinjae bac9a54dee 활성 프로파일 뱃지 추가 - HTTP 세션 로그 설정 제거 - 개인정보 암호화 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-21 17:25:41 +09:00
Rinjae 603cba65a7 API 목록 페이징 기능 추가 - 검색 결과 페이지 처리 로직 구현
API 아이콘 스트리밍 엔드포인트 추가 - base64 데이터 URL 캐싱 처리
response 압축 설정 추가 - application.yml에 설정 값 정의
2026-08-21 10:48:47 +09:00
Rinjae 889d77b990 로그 설정 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JVM 파라미터로 파일, 세션, API 테스터 로그 레벨 제어 추가
- 루트, 세션, API 테스터 로그 레벨 동적 설정 지원
2026-08-20 14:34:01 +09:00
Rinjae bf2dad6ba7 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:47:54 +09:00
Rinjae 3a1b0cb9c1 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 함수 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:25:19 +09:00
Rinjae a13279a777 고객센터 연락처 미노출 조건 추가 - 값 공백 시 th:if로 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 16:46:58 +09:00
Rinjae 5299352235 CSS hover 색상값 표현 변경 (RGB → 퍼센트 형식)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 15:17:41 +09:00
Rinjae 8b4bc425ec - build.gradle: damo-manager 의존성 scope 변경(compileOnly → implementation)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- application.yml: 로그 경로 수정(/logs/eapim → /logs/prod/eapim)
2026-08-14 19:05:29 +09:00
Rinjae 06dc148515 Lodash 업데이트 및 보안 강화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Lodash 버전 4.17.21 → 4.18.1로 업데이트
- prototype pollution 방지 로직 추가
- `_.template` 관련 보안 경고 및 문서 갱신
2026-08-14 17:48:45 +09:00
Rinjae 3cfd6b0b04 Atomikos JTA 제거: 로컬 트랜잭션 전환 및 관련 설정 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JTA/XA 미사용으로 변경: JpaTransactionManager로 전환
- Atomikos 의존성 및 설정 제거, 관련 문서 및 주석 업데이트
2026-08-14 17:11:54 +09:00
Rinjae 7a0f88bfb1 Atomikos 설정 변경: max-actives 속성 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-14 16:50:10 +09:00
Rinjae 6d7b292b8a 운영 인증번호 테스트 종료일 판정 클래스 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestNoticeWindow: 운영 환경에서 인증번호 노출 종료일 유효성 검증
- TwoFactorProperties/AuthNoticeProperties에 종료일 기반 처리 로직 통합
2026-08-14 13:13:20 +09:00
Rinjae 20ad6f8394 Thymeleaf 템플릿 JS 분석 제외 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- sonar.javascript.exclusions 속성에 템플릿 경로 추가
- XSS 룰 유지 위해 HTML 분석은 제외하지 않음
2026-08-13 18:18:09 +09:00
Rinjae 2bae0e350c OWASP Dependency-Check 통합 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Jenkinsfile.security: 라이브러리 취약점 검사용 파이프라인 작성 (SAST/SCA 병행)
- dependency-check-classpath.gradle: 스캔 대상 의존성 jar 분리 로직 작성
- dependency-check-suppressions.xml: 오탐 억제 규칙 파일 추가
2026-08-13 17:53:14 +09:00
Rinjae 45e8fa2903 레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-13 16:52:04 +09:00
116 changed files with 4593 additions and 760 deletions
+10 -3
View File
@@ -185,8 +185,9 @@ com.eactive.apim.portal/
설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager
- Atomikos JTA를 통한 분산 트랜잭션
- 트랜잭션 로그: `/Log/eapim/portal/`
- **JTA/XA 미사용**. EntityManagerFactory 별 로컬 트랜잭션 (`config/PortalConfigTransaction.java`)
- `transactionManager` (@Primary) → EMS
- `gatewayTransactionManager` → Gateway
### 설정 프로파일
@@ -359,7 +360,13 @@ return queryFactory.selectFrom(user)
적절한 propagation과 함께 `@Transactional` 사용:
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
- 읽기 전용 작업: 최적화를 위해 `@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 는 조회 전용이다.
### 에러 처리
+501
View File
@@ -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"
}
}
}
}
}
-1
View File
@@ -60,7 +60,6 @@ dependencies {
// implementation project(':kjb-safedb')
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-validation')
+58
View File
@@ -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}")
}
}
}
}
+31
View File
@@ -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>
+8
View File
@@ -51,6 +51,14 @@ sonar.exclusions=\
# 자동 생성 코드(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 -------------------------------------------------------------------
# blame 기반 "새 코드" 판정을 위해 Jenkins Job 에서 shallow clone 을 쓰지 않는다.
sonar.scm.provider=git
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :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.Optional;
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.ui.Model;
import org.springframework.ui.ModelMap;
@@ -79,12 +83,17 @@ public class ApiController {
}
@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);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search);
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("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
@@ -94,6 +103,19 @@ public class ApiController {
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")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
private String displayRoleCode;
private String apiGroupName;
private String apiGroupId;
}
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) {
api.setMainIcon(service.getMainIcon());
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName());
}
});
@@ -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.service.ApiServiceService;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor
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;
@@ -52,4 +65,28 @@ public class ApiServiceController {
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);
}
}
@@ -22,8 +22,19 @@ import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
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")
@Transactional
@Transactional("gatewayTransactionManager")
@RequiredArgsConstructor
@Slf4j
public class ApiServiceService {
@@ -109,5 +120,14 @@ public class ApiServiceService {
.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(
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 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
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.setTargetId(request.getId());
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()) {
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 lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Component;
/**
@@ -12,10 +10,18 @@ import org.springframework.stereotype.Component;
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
* {@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> 를 반환한다(운영 환경 인증번호 노출 금지).
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
* <p><b>운영(prod)에서 DB 값만으로 켤 수 있다.</b> 대신 종료일
* {@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
@RequiredArgsConstructor
@@ -23,21 +29,45 @@ public class AuthNoticeProperties {
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_PROD_UNTIL = "auth.test-notice.prod-until";
/** 판정 결과 캐시 유효시간(ms) */
static final long CACHE_TTL_MILLIS = 30_000L;
private final PortalPropertyService portalPropertyService;
private final Environment environment;
private final TestNoticeWindow testNoticeWindow;
private volatile boolean cachedEnabled;
/** 캐시 갱신 시각(ms). 0 이면 미조회 */
private volatile long cachedAt;
/**
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
* prod 환경에서는 property 값과 무관하게 항상 false.
* 운영에서는 종료일({@link #KEY_TEST_NOTICE_PROD_UNTIL})까지만 참이 된다.
*/
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;
}
String value = portalPropertyService.getOrCreateProperty(
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
"인증(이메일/SMS) 요청 시 인증번호 화면 표시할지 여부 (true/false, 테스트 전용)");
return value != null && "true".equalsIgnoreCase(value.trim());
String until = portalPropertyService.getOrCreateProperty(
GROUP, KEY_TEST_NOTICE_PROD_UNTIL, TestNoticeWindow.UNSET,
"운영(prod)에서 인증번호 화면 표시를 허용할 종료일 (yyyy-MM-dd, 미사용은 none). 경과 시 자동 차단");
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.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.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));
}
});
}
@@ -1,9 +1,12 @@
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.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
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.ui.Model;
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.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
@@ -28,8 +33,14 @@ import javax.servlet.http.HttpSession;
@RequestMapping("/auth/stepup")
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 TwoFactorService twoFactorService;
private final UserSessionService userSessionService;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +55,8 @@ public class StepUpPasswordController {
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
@@ -52,16 +64,34 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
session.removeAttribute(ATTR_FAIL_COUNT);
twoFactorService.grantStepUpPass(session, 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);
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 방지) */
private static String pathOf(String url) {
if (url == null) {
@@ -1,5 +1,6 @@
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.portaluser.entity.PortalUserEnums.RoleCode;
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_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_PROD_UNTIL = "two-factor.test-notice.prod-until";
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
private final PortalPropertyService portalPropertyService;
private final TestNoticeWindow testNoticeWindow;
/** 로그인 2FA 활성화 여부 */
public boolean isLoginEnabled() {
@@ -91,9 +94,21 @@ public class TwoFactorProperties {
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() {
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) {
@@ -61,4 +61,23 @@ public class PartnershipApplicationController {
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";
}
}
@@ -3,9 +3,11 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
@@ -16,4 +18,20 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
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);
}
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
}
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service
@RequiredArgsConstructor
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
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);
}
}
@@ -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.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String 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);
}
}
@@ -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.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
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
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/
@Data
@Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총
* API 이용 건수 (월누적)
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/
private int totalApiCount;
}
@@ -1,14 +1,20 @@
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.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.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
.filter(id -> id != null && !id.trim().isEmpty())
.collect(Collectors.toList());
if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
}
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에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
}
@PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
}
@PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@PasswordMatch(input = "password", confirm = "confirmPassword")
@Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/
private String password;
private String password2;
@CellPhone
private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
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 checkPasswordMatch(String password, String password2);
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
@Override
public ValidationResponse checkPasswordMatch(String password, String password2) {
boolean isMatch = password.equals(password2);
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message);
}
@@ -34,7 +34,8 @@ public class PasswordService {
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
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());
if(histories.isEmpty()) {
@@ -85,6 +86,17 @@ public class PasswordService {
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) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override
@Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser);
} 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("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -111,6 +111,18 @@ public class GlobalControllerAdvice {
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)에서 조회.
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
@@ -121,6 +133,61 @@ public class GlobalControllerAdvice {
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
}
/**
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
*/
@ModelAttribute("brandName")
public String brandName() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
}
/**
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
*/
@ModelAttribute("brandNameJosaGa")
public String brandNameJosaGa() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* brandName 뒤에 바로 붙는 보조사(은/는).
*/
@ModelAttribute("brandNameJosaEun")
public String brandNameJosaEun() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
*/
@ModelAttribute("brandLogoHeaderPath")
public String brandLogoHeaderPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
}
/**
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
*/
@ModelAttribute("brandLogoFooterPath")
public String brandLogoFooterPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
}
private boolean hasBatchim(String word) {
if (word == null || word.isEmpty()) {
return false;
}
char last = word.charAt(word.length() - 1);
if (last >= 0xAC00 && last <= 0xD7A3) {
return (last - 0xAC00) % 28 != 0;
}
return "AEIOUaeiou".indexOf(last) < 0;
}
/**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/
@@ -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.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import javax.naming.NamingException;
import javax.sql.DataSource;
@@ -58,11 +59,11 @@ public class BaseDatasourceConfiguration {
//public interface AvailableSettings extends org.hibernate.jpa.AvailableSettings 참고
properties.put("hibernate.dialect", prop.getHibernateDialect());
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.default_schema", prop.getSchema());
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");
if (prop instanceof EmsDatasourceProperty) {
@@ -110,9 +111,22 @@ public class BaseDatasourceConfiguration {
return builder
.dataSource(dataSource)
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
.packages(prop.getEntityPackage().split("\\s*,\\s*"))
.packages(splitPackages(prop.getEntityPackage()))
.persistenceUnit(persistenceUnit)
.properties(properties)
.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
@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
public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration {
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)
* </pre>
*
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
* 검사되므로 기존 가드가 설계대로 동작한다.
* 즉 {@code MenuInternalController#isAllowedIp} 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다.
* 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로 검사되므로 기존 가드가 설계대로 동작한다.
*
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
*
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
*/
@Slf4j
@Configuration
@@ -107,9 +107,16 @@ public class PortalConfigSecurity {
.logoutSuccessHandler(logoutSuccessHandler))
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
// /_proxy 는 대응 핸들러가 없어 예외를 해제했다. 경로가 부활하면 아래를 되살릴 것.
// .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
// /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 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -1,39 +1,44 @@
package com.eactive.apim.portal.config;
import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.icatch.jta.UserTransactionManager;
import javax.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
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.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
@EnableTransactionManagement
public class PortalConfigTransaction {
@Bean(name = "userTransaction")
public UserTransaction userTransaction() throws SystemException {
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;
}
/** EMS(포털) 기본 트랜잭션 매니저. {@code @Transactional} 무지정 시 이 매니저가 쓰인다. */
@Primary
@Bean(name = "transactionManager")
@DependsOn({"userTransaction", "atomikosTransactionManager"})
public PlatformTransactionManager transactionManager(UserTransactionManager atomikosTransactionManager) throws SystemException {
UserTransaction userTransaction = userTransaction();
return new JtaTransactionManager(userTransaction, atomikosTransactionManager);
public PlatformTransactionManager transactionManager(
@Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
/** 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}")
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,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
}
/**
@@ -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.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
@@ -17,4 +18,11 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
+ " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@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;
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
@@ -19,14 +20,20 @@ import java.util.Map;
/**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
*
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
* <p>가드는 두 겹이다.</p>
* <ol>
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}. 헤더명/토큰은 PTL_PROPERTY
* {@code Portal / internal.api.header-name}, {@code internal.api.token} 으로 관리하며
* 토큰은 포탈 최초 기동 시 자동 생성된다. 이 경로는 CSRF 예외 대상
* (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: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* http://127.0.0.1:39130/internal/menu/reload</pre>
*/
@Slf4j
@RestController
@@ -43,14 +50,18 @@ public class MenuInternalController {
private final MenuService menuService;
private final PortalPropertyService portalPropertyService;
private final InternalApiTokenService internalApiTokenService;
@PostMapping("/reload")
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
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 + " 확인)");
}
MenuService.MenuSnapshot snapshot = menuService.reload();
@@ -63,12 +74,31 @@ public class MenuInternalController {
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 신뢰 불가로 거부한다.
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/
private boolean isAllowed(HttpServletRequest request) {
private boolean isAllowedIp(HttpServletRequest request) {
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
@@ -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: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* '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;
}
}
}
@@ -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를 수행할 수 없습니다.");
}
}
}
@@ -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;
}
}
@@ -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를 수행할 수 없습니다.");
}
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -14,10 +15,15 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.ui.Model;
import org.springframework.validation.BindingResult;
@@ -50,10 +56,16 @@ public class WebhookController {
private static final int TOTAL_STEPS = 3;
/** 비밀번호 재인증 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) — StepUpPasswordController 답습. */
private static final int MAX_PW_FAIL_COUNT = 5;
/** 연속 실패 횟수 세션 attribute 키 */
private static final String ATTR_PW_FAIL_COUNT = "WEBHOOK_PW_CONFIRM_FAIL_COUNT";
private final WebhookService webhookService;
private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
private final UserSessionService userSessionService;
@ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() {
@@ -211,6 +223,7 @@ public class WebhookController {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
addStepModel(mav, 1);
return mav;
}
@@ -297,33 +310,35 @@ public class WebhookController {
@PostMapping("/verify-secret")
@ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> verifySecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
Long webhookId = webhook.get().getId();
result.put("success", true);
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
return result;
}
@PostMapping("/regenerate-secret")
@ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> regenerateSecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -338,13 +353,13 @@ public class WebhookController {
@PostMapping("/delete")
@ResponseBody
public Map<String, Object> delete(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> delete(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -376,6 +391,38 @@ public class WebhookController {
return appServiceFacade.verifyUserPassword(user, password);
}
/**
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
* 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다
* (무차별 대입 방어 — {@code StepUpPasswordController} 답습).
*/
private Map<String, Object> checkPassword(String password, HttpSession session,
HttpServletRequest request, HttpServletResponse response) {
if (verifyPassword(password)) {
session.removeAttribute(ATTR_PW_FAIL_COUNT);
return null;
}
Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT);
int failCount = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_PW_FAIL_COUNT, failCount);
Map<String, Object> result = new HashMap<>();
result.put("success", false);
if (failCount >= MAX_PW_FAIL_COUNT) {
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
result.put("forceLogout", true);
result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다.");
log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId());
} else {
result.put("message",
"비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
}
return result;
}
private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
@@ -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;
}
@@ -16,11 +16,15 @@ public class WebhookDTO implements Serializable {
private Long id;
private String targetUrl;
private String secretMasked;
private String userSecretMasked;
private String createdDate;
/** 구독 API ID 목록. */
/** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>();
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "userSecretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity);
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
@Column(name = "SECRET", length = 500)
private String secret;
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
@Column(name = "USER_SECRET", length = 500)
private String userSecret;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@@ -1,5 +1,8 @@
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.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
@@ -70,6 +74,7 @@ public class WebhookService {
request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret);
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto);
@@ -79,12 +84,17 @@ public class WebhookService {
/**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
*/
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim());
String userSecret = normalizeUserSecret(dto.getUserSecret());
if (userSecret != null) {
request.setUserSecret(userSecret);
}
requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id);
@@ -128,6 +138,14 @@ public class WebhookService {
return loadOwned(id, orgId).getSecret();
}
/**
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
*/
@Transactional(readOnly = true)
public String getPlainUserSecret(Long id, String orgId) {
return loadOwned(id, orgId).getUserSecret();
}
// ----------------------------------------------------------------
private WebhookRequest loadOwned(Long id, String orgId) {
@@ -164,6 +182,14 @@ public class WebhookService {
}
}
private String normalizeUserSecret(String raw) {
if (raw == null) {
return null;
}
String trimmed = raw.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private List<String> dedup(List<String> values) {
if (values == null) {
return java.util.Collections.emptyList();
@@ -174,9 +200,18 @@ public class WebhookService {
private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.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()));
Map<String, String> names = eventTypeProvider.asMap();
+2 -2
View File
@@ -1,9 +1,9 @@
spring:
jta:
enabled: false
config:
activate:
on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa:
properties:
hibernate:
-2
View File
@@ -1,6 +1,4 @@
spring:
jta:
enabled: false
config:
activate:
on-profile: prod
-2
View File
@@ -1,6 +1,4 @@
spring:
jta:
enabled: false
web:
resources:
cache:
+19 -8
View File
@@ -30,12 +30,25 @@ server:
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
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:
config:
import:
- classpath:menu.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:
web:
pageable:
@@ -58,13 +71,7 @@ spring:
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: false
atomikos:
properties:
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
# JTA/Atomikos 제거됨. EMS·AGW 각각 로컬 트랜잭션(JpaTransactionManager) 사용 - PortalConfigTransaction 참고
thymeleaf:
prefix: 'classpath:/templates/views/'
@@ -86,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security:
basic:
@@ -125,7 +136,7 @@ portal:
allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png
logging:
log-path: /logs/eapim
log-path: /logs/prod/eapim
# 내부 사용자(운영자) 판별 설정
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
-3
View File
@@ -1,3 +0,0 @@
# application.yml ? ??
# com.atomikos.icatch.log_base_dir=/Log/eapim/portal/
# com.atomikos.icatch.log_base_name=adp_tx
-3
View File
@@ -87,9 +87,6 @@
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" />
+19 -4
View File
@@ -19,6 +19,21 @@
<property name="__consoleLevel.FALSE" value="OFF"/>
<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">
<file>${LOG_PATH}/hibernate.log</file>
@@ -95,23 +110,23 @@
</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" />
</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" />
</logger>
<root level="INFO">
<root level="${FILE_LOG_LEVEL}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<springProfile name="dev">
<root level="DEBUG">
<root level="${FILE_LOG_LEVEL_DEV}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
+432 -122
View File
@@ -23,6 +23,13 @@ body {
color: #1A1A2E;
background-color: #FFFFFF;
overflow-x: hidden;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body > * {
flex-shrink: 0;
width: 100%;
}
ul, ol {
@@ -821,6 +828,54 @@ hr {
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 {
display: flex;
align-items: center;
@@ -853,7 +908,7 @@ hr {
}
.logo img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
@@ -1192,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -1822,6 +1877,7 @@ hr {
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
margin-top: auto;
}
.global-footer .container {
max-width: 1200px;
@@ -2503,7 +2559,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px);
}
.btn-danger {
@@ -2511,7 +2567,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2777,7 +2833,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2895,7 +2951,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -2970,7 +3026,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px);
color: #fff;
}
@@ -3020,7 +3076,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3060,7 +3116,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.badge-sm {
@@ -4248,7 +4304,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4569,7 +4625,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4724,7 +4780,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.form-row--content .form-label-wrapper {
@@ -5660,7 +5716,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6373,7 +6429,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6381,7 +6437,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375);
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6389,7 +6445,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6397,7 +6453,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
}
.table-pagination {
@@ -7047,7 +7103,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center;
}
.alert.alert-error svg {
@@ -7061,7 +7117,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11432,10 +11488,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(25.65, 70.3, 173.85);
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
}
.login-button:active {
background: rgb(24.3, 66.6, 164.7);
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
}
.login-button:disabled {
opacity: 0.6;
@@ -11478,10 +11534,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(220.61, 227.85, 245.95);
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
}
.login-links-container .link-btn:active {
background: rgb(205.22, 215.7, 241.9);
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
}
.login-alert {
@@ -11990,12 +12046,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(21.411588785, 146.3125233645, 233.148411215);
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12044,10 +12100,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(225.45, 229.39, 235.3);
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(210.9, 216.78, 225.6);
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12057,7 +12113,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12293,7 +12349,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17493,7 +17549,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17520,7 +17576,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
.btn-view-secret svg {
width: 20px;
@@ -17705,7 +17761,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.btn-view-secret {
width: 100% !important;
@@ -17721,7 +17777,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
#revealedSecretBox {
width: 100%;
@@ -17994,7 +18050,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255);
background: rgb(74.3529411765%, 90.2296918768%, 100%);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18073,6 +18129,10 @@ input[type=checkbox]:checked + .custom-checkbox {
padding-right: 20px;
flex: 1;
}
.detail-wrap .dt-api-name:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) {
.detail-wrap .dt-api-name {
font-size: 14px;
@@ -18171,7 +18231,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18189,7 +18249,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24);
background: rgb(100%, 27.4868759774%, 25.1921568627%);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19763,7 +19823,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19796,7 +19856,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19829,7 +19889,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -19901,7 +19961,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -19933,7 +19993,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -19993,7 +20053,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20001,7 +20061,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20790,7 +20850,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21326,7 +21386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
}
.org-file-notice {
@@ -21791,6 +21851,35 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #334155;
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 {
font-size: 40px;
@@ -22338,7 +22427,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -23297,81 +23386,302 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.service-intro {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
color: #000;
display: flex;
flex-direction: column;
gap: 20px;
}
.service-intro__title {
margin: 0;
font-size: 25px;
line-height: 1.4;
letter-spacing: -0.01em;
}
.service-intro__lead {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
letter-spacing: -0.01em;
}
.service-intro__desc {
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__spacer {
height: 10px;
}
.service-intro__section-title {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
.service-intro__section-body {
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__section-body p {
margin: 0;
}
.service-intro__section-body p + p {
margin-top: 8px;
}
.service-intro__list {
margin: 0;
padding-left: 22px;
list-style: disc;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__list li + li {
margin-top: 4px;
color: #1e2939;
}
@media (max-width: 768px) {
.service-intro {
padding: 16px;
gap: 16px;
.intro-callout {
background: #0B2A5B;
border-radius: 16px;
padding: 34px 40px;
display: flex;
gap: 30px;
align-items: center;
box-shadow: 0 14px 34px rgba(11, 42, 91, 0.18);
}
.intro-callout__icon {
width: 56px;
height: 56px;
flex: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
display: grid;
place-items: center;
}
.intro-callout ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 11px;
}
.intro-callout li {
position: relative;
padding-left: 15px;
color: #C2D4EA;
font-size: 15.5px;
line-height: 1.6;
}
.intro-callout li::before {
content: "";
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #00acdd;
}
.intro-callout li strong {
color: #fff;
font-weight: 700;
}
.intro-section__eyebrow {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.4px;
color: #0049b4;
margin-bottom: 9px;
}
.intro-section__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -1px;
color: #0B2A5B;
margin: 0;
}
.intro-section__lead {
margin-top: 14px;
font-size: 16px;
color: #4a5565;
line-height: 1.85;
max-width: 830px;
}
.intro-section + .intro-section {
margin-top: 76px;
}
.intro-who {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 28px;
}
.intro-who__item {
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 22px 20px;
text-align: center;
background: #fff;
}
.intro-who__item p {
margin-top: 6px;
font-size: 13.6px;
color: #4a5565;
line-height: 1.65;
}
.intro-who__title {
margin-top: 12px;
font-size: 15.5px;
font-weight: 800;
color: #0B2A5B;
}
.intro-grid3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.intro-card {
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 26px;
background: #fff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.intro-card__icon {
width: 44px;
height: 44px;
border-radius: 11px;
background: #EFF6FD;
display: grid;
place-items: center;
margin-bottom: 16px;
}
.intro-card h3 {
font-size: 18px;
font-weight: 800;
color: #0B2A5B;
letter-spacing: -0.5px;
margin: 0;
}
.intro-card p {
margin-top: 9px;
font-size: 14.6px;
color: #4a5565;
line-height: 1.75;
}
.intro-card code {
font-family: "Fira Code", monospace;
font-size: 13px;
background: #EFF4FA;
color: #0049b4;
padding: 2px 6px;
border-radius: 6px;
}
.intro-card__tag {
display: inline-block;
margin-top: 14px;
font-size: 12.5px;
font-weight: 700;
color: #0049b4;
background: #EEF6FD;
border-radius: 6px;
padding: 5px 10px;
}
.intro-diagram {
margin-top: 28px;
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 30px 24px;
background: #FAFCFF;
overflow-x: auto;
}
.intro-diagram svg {
display: block;
width: 100%;
min-width: 700px;
height: auto;
}
.intro-steps {
margin-top: 34px;
position: relative;
padding-left: 38px;
}
.intro-steps::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 14px;
width: 2px;
background: #D8E5F3;
}
.intro-step {
display: flex;
gap: 22px;
margin-bottom: 18px;
position: relative;
}
.intro-step::before {
content: "";
position: absolute;
left: -38px;
top: 34px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #0049b4;
border: 3px solid #fff;
box-shadow: 0 0 0 3px #D9E7F7;
}
.intro-step__icon {
width: 88px;
height: 82px;
flex: none;
border: 1px solid #f3f4f6;
border-radius: 14px;
display: grid;
place-items: center;
background: #fff;
}
.intro-step__body {
flex: 1;
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 19px 26px;
background: #fff;
}
.intro-step__body p {
margin-top: 5px;
font-size: 14.4px;
color: #4a5565;
}
.intro-step__title {
font-size: 17px;
font-weight: 800;
color: #0B2A5B;
}
.intro-step__title em {
font-style: normal;
color: #0049b4;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.6px;
margin-right: 10px;
}
.intro-cta {
margin-top: 64px;
border-radius: 18px;
padding: 38px 44px;
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
display: flex;
align-items: center;
gap: 28px;
border: 1px solid #D5E6F7;
}
.intro-cta__body h3 {
margin: 0;
font-size: 22px;
color: #0B2A5B;
}
.intro-cta__body p {
margin-top: 6px;
font-size: 15px;
color: #4a5565;
}
.intro-cta .btn-action-primary {
margin-left: auto;
flex: none;
font-size: 15.5px;
padding: 15px 30px;
}
@media (max-width: 1024px) {
.intro-who,
.intro-grid3 {
grid-template-columns: repeat(2, 1fr);
}
.service-intro__title {
font-size: 22px;
.intro-cta {
flex-direction: column;
align-items: flex-start;
}
.service-intro__lead {
font-size: 17px;
.intro-cta .btn-action-primary {
margin-left: 0;
}
.service-intro__section-title {
font-size: 17px;
}
@media (max-width: 640px) {
.intro-who,
.intro-grid3 {
grid-template-columns: 1fr;
}
.service-intro__desc, .service-intro__section-body, .service-intro__list {
font-size: 14px;
.intro-callout {
flex-direction: column;
align-items: flex-start;
}
.intro-section__title {
font-size: 24px;
}
.intro-step {
flex-direction: column;
}
}
.service-main {
@@ -26100,7 +26410,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
width: 100%;
}
@@ -26111,24 +26421,24 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
flex: 1;
max-width: 250px;
height: 48px;
min-width: 0;
min-height: 48px;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 13px;
line-height: 1.4;
color: #4e5968;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF;
}
@media (max-width: 576px) {
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
max-width: 100%;
width: 100%;
flex: none;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+136 -49
View File
@@ -8,11 +8,13 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const paginationEl = document.getElementById('apiPagination');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let allApis = []; // 현재 카테고리 조회 결과 전체
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
let currentPage = 1;
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
clearCards();
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
applySearch();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
renderPagination(0);
});
}
// Render API cards
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
function clearCards() {
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
}
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
function applySearch() {
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
filteredApis = !term ? allApis : allApis.filter(function(api) {
const name = (api.apiName || '').toLowerCase();
const desc = (api.apiSimpleDescription || '').toLowerCase();
return name.includes(term) || desc.includes(term);
});
goToPage(1);
}
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
function goToPage(page) {
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), totalPages);
clearCards();
document.getElementById('apiResultCount').textContent = filteredApis.length;
if (filteredApis.length === 0) {
emptyState.style.display = 'block';
renderPagination(0);
updateSelectAllUI();
return;
}
emptyState.style.display = 'none';
const start = (currentPage - 1) * PAGE_SIZE;
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
renderPagination(filteredApis.length);
updateSelectAllUI();
}
// Render API cards (현재 페이지분)
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (!selectAllCheckbox) {
return;
}
if (visibleCards.length === 0) {
if (filteredApis.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
} else if (checkedCount === filteredApis.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
selectedApis.forEach(function(apiId) {
const apiData = allApis.find(a => a.apiId === apiId);
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiName = apiData ? apiData.apiName
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Search functionality
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
function renderPagination(totalItems) {
if (!paginationEl) {
return;
}
paginationEl.innerHTML = '';
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
if (totalPages <= 1) {
return;
}
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
function navLink(cls, iconHtml, targetPage, disabled) {
const a = document.createElement('a');
a.href = '#';
a.className = cls + (disabled ? ' disabled' : '');
a.innerHTML = iconHtml;
if (!disabled) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(targetPage);
});
}
return a;
}
function numLink(p) {
const a = document.createElement('a');
a.href = '#';
const isCurrent = p === currentPage;
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
a.textContent = String(p);
if (!isCurrent) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(p);
});
}
return a;
}
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const windowEnd = Math.min(totalPages, windowStart + 4);
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
paginationEl.appendChild(numLink(p));
}
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
}
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
applySearch();
});
}
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
loadApis(groupId);
});
});
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Select All checkbox event
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
filteredApis.forEach(function(api) {
if (isChecked) {
selectedApis.add(api.apiId);
} else {
selectedApis.delete(api.apiId);
}
});
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
document.querySelectorAll('.s2-api-card').forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
card.classList.toggle('selected', isChecked);
}
});
+16
View File
@@ -321,6 +321,14 @@ function insertImageAsDataUri(file, $editor) {
reader.readAsDataURL(file);
}
/**
* 정규식 기반 정리를 적용할 HTML 최대 길이(문자).
* 아래 VML/네임스페이스 정리 정규식은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
* 비정상적으로 큰 입력에서는 정규식 단계를 건너뛴다. data-uri 이미지를 포함한 일반적인
* Office 붙여넣기는 이 한도 아래다.
*/
var MAX_REGEX_CLEAN_LENGTH = 500000;
/**
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
* - 불필요한 Office 속성 제거
@@ -358,6 +366,14 @@ function cleanPastedHtml(html) {
// VML 태그 제거 (v:, o:, w: 등)
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(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
+60 -10
View File
@@ -12,7 +12,7 @@
var undefined;
/** 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. */
var LARGE_ARRAY_SIZE = 200;
@@ -20,7 +20,8 @@
/** Error message constants. */
var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',
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. */
var HASH_UNDEFINED = '__lodash_hash_undefined__';
@@ -1752,6 +1753,10 @@
* embedded Ruby (ERB) as well as ES2015 template strings. Change the
* 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
* @memberOf _
* @type {Object}
@@ -2300,7 +2305,7 @@
* @name has
* @memberOf SetCache
* @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) {
return this.__data__.has(value);
@@ -3766,7 +3771,7 @@
if (isArray(iteratee)) {
return function(value) {
return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);
}
};
}
return iteratee;
});
@@ -4370,8 +4375,34 @@
*/
function baseUnset(object, path) {
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`,
* `0`, `""`, `undefined`, and `NaN` are falsey.
* `0`, `-0`, `0n`, `""`, `undefined`, and `NaN` are falsy.
*
* @static
* @memberOf _
@@ -7461,7 +7492,7 @@
while (++index < length) {
var pair = pairs[index];
result[pair[0]] = pair[1];
baseAssignValue(result, pair[0], pair[1]);
}
return result;
}
@@ -14121,6 +14152,8 @@
* **Note:** JavaScript follows the IEEE-754 standard for resolving
* floating-point values which can produce unexpected results.
*
* **Note:** If `lower` is greater than `upper`, the values are swapped.
*
* @static
* @memberOf _
* @since 0.7.0
@@ -14134,9 +14167,16 @@
* _.random(0, 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);
* // => also an integer between 0 and 5
*
* _.random(-5);
* // => an integer between -5 and 0
*
* _.random(5, true);
* // => 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
* 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
* [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)
* for easier debugging.
@@ -14845,12 +14889,18 @@
options = undefined;
}
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),
importsValues = baseValues(imports, importsKeys);
arrayEach(importsKeys, function(key) {
if (reForbiddenIdentifierChars.test(key)) {
throw new Error(INVALID_TEMPL_IMPORTS_ERROR_TEXT);
}
});
var isEscaping,
isEvaluating,
index = 0,
@@ -30,6 +30,19 @@ body {
color: $text-dark;
background-color: $white;
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
@@ -163,6 +163,8 @@
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
// body(flex column) 기준으로 남은 공간을 위쪽 여백으로 흡수 → 짧은 페이지에서 화면 하단 고정
margin-top: auto;
.container {
max-width: 1200px;
@@ -137,7 +137,8 @@
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
// 로고 파일(PTL_PROPERTY brand.logo.header.path)마다 원본 비율이 달라 width는 auto로 두고
// height만 고정한다. flex 축소가 걸리면 그 auto width가 눌릴 수 있어 shrink는 막아둔다.
img { flex-shrink: 0; }
.mobile-logo-link {
@@ -156,6 +157,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 {
display: flex;
@@ -195,7 +246,7 @@
img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
}
@@ -1198,6 +1198,11 @@
padding-right: 20px;
flex: 1;
&:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) {
font-size: 14px;
}
@@ -102,6 +102,40 @@
color: #334155;
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 {
@@ -25,101 +25,348 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
// DJBank 개발자포탈 소개 (Figma 352:15 기반)
// =============================================================================
$intro-navy: #0B2A5B;
.service-intro {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
color: #000;
color: $service-text-dark;
}
// -----------------------------------------------------------------------------
// Intro page content sections (callout / eyebrow+title / card grids / steps)
// -----------------------------------------------------------------------------
.intro-callout {
background: $intro-navy;
border-radius: 16px;
padding: 34px 40px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 30px;
align-items: center;
box-shadow: 0 14px 34px rgba(11, 42, 91, .18);
&__title {
&__icon {
width: 56px;
height: 56px;
flex: none;
border-radius: 12px;
background: rgba(255, 255, 255, .14);
display: grid;
place-items: center;
}
ul {
list-style: none;
margin: 0;
font-size: 25px;
line-height: 1.4;
letter-spacing: -0.01em;
padding: 0;
display: grid;
gap: 11px;
}
&__lead {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
letter-spacing: -0.01em;
}
li {
position: relative;
padding-left: 15px;
color: #C2D4EA;
font-size: 15.5px;
line-height: 1.6;
&__desc {
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
&__spacer {
height: 10px;
}
&__section-title {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
&__section-body {
font-size: 15px;
font-weight: 500;
line-height: 1.7;
p {
margin: 0;
&+p {
margin-top: 8px;
}
&::before {
content: "";
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: $service-icon-cyan;
}
}
&__list {
margin: 0;
padding-left: 22px;
list-style: disc;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
li+li {
margin-top: 4px;
strong {
color: #fff;
font-weight: 700;
}
}
}
@media (max-width: 768px) {
.service-intro {
padding: 16px;
gap: 16px;
.intro-section {
&__eyebrow {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.4px;
color: $service-primary-blue;
margin-bottom: 9px;
}
&__title {
font-size: 22px;
&__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -1px;
color: $intro-navy;
margin: 0;
}
&__lead {
margin-top: 14px;
font-size: 16px;
color: $service-text-gray;
line-height: 1.85;
max-width: 830px;
}
&+& {
margin-top: 76px;
}
}
.intro-who {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 28px;
&__item {
border: 1px solid $service-card-border;
border-radius: 14px;
padding: 22px 20px;
text-align: center;
background: #fff;
p {
margin-top: 6px;
font-size: 13.6px;
color: $service-text-gray;
line-height: 1.65;
}
}
&__lead {
font-size: 17px;
&__title {
margin-top: 12px;
font-size: 15.5px;
font-weight: 800;
color: $intro-navy;
}
}
.intro-grid3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.intro-card {
border: 1px solid $service-card-border;
border-radius: 16px;
padding: 26px;
background: #fff;
box-shadow: $service-card-shadow;
&__icon {
width: 44px;
height: 44px;
border-radius: 11px;
background: #EFF6FD;
display: grid;
place-items: center;
margin-bottom: 16px;
}
h3 {
font-size: 18px;
font-weight: 800;
color: $intro-navy;
letter-spacing: -.5px;
margin: 0;
}
p {
margin-top: 9px;
font-size: 14.6px;
color: $service-text-gray;
line-height: 1.75;
}
code {
font-family: 'Fira Code', monospace;
font-size: 13px;
background: #EFF4FA;
color: $service-primary-blue;
padding: 2px 6px;
border-radius: 6px;
}
&__tag {
display: inline-block;
margin-top: 14px;
font-size: 12.5px;
font-weight: 700;
color: $service-primary-blue;
background: #EEF6FD;
border-radius: 6px;
padding: 5px 10px;
}
}
.intro-diagram {
margin-top: 28px;
border: 1px solid $service-card-border;
border-radius: 16px;
padding: 30px 24px;
background: #FAFCFF;
overflow-x: auto;
svg {
display: block;
width: 100%;
min-width: 700px;
height: auto;
}
}
.intro-steps {
margin-top: 34px;
position: relative;
padding-left: 38px;
&::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 14px;
width: 2px;
background: #D8E5F3;
}
}
.intro-step {
display: flex;
gap: 22px;
margin-bottom: 18px;
position: relative;
&::before {
content: "";
position: absolute;
left: -38px;
top: 34px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $service-primary-blue;
border: 3px solid #fff;
box-shadow: 0 0 0 3px #D9E7F7;
}
&__icon {
width: 88px;
height: 82px;
flex: none;
border: 1px solid $service-card-border;
border-radius: 14px;
display: grid;
place-items: center;
background: #fff;
}
&__body {
flex: 1;
border: 1px solid $service-card-border;
border-radius: 14px;
padding: 19px 26px;
background: #fff;
p {
margin-top: 5px;
font-size: 14.4px;
color: $service-text-gray;
}
}
&__section-title {
font-size: 17px;
}
&__title {
font-size: 17px;
font-weight: 800;
color: $intro-navy;
&__desc,
&__section-body,
&__list {
em {
font-style: normal;
color: $service-primary-blue;
font-size: 14px;
font-weight: 800;
letter-spacing: .6px;
margin-right: 10px;
}
}
}
.intro-cta {
margin-top: 64px;
border-radius: 18px;
padding: 38px 44px;
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
display: flex;
align-items: center;
gap: 28px;
border: 1px solid #D5E6F7;
&__body {
h3 {
margin: 0;
font-size: 22px;
color: $intro-navy;
}
p {
margin-top: 6px;
font-size: 15px;
color: $service-text-gray;
}
}
.btn-action-primary {
margin-left: auto;
flex: none;
font-size: 15.5px;
padding: 15px 30px;
}
}
@media (max-width: 1024px) {
.intro-who,
.intro-grid3 {
grid-template-columns: repeat(2, 1fr);
}
.intro-cta {
flex-direction: column;
align-items: flex-start;
.btn-action-primary {
margin-left: 0;
}
}
}
@media (max-width: 640px) {
.intro-who,
.intro-grid3 {
grid-template-columns: 1fr;
}
.intro-callout {
flex-direction: column;
align-items: flex-start;
}
.intro-section__title {
font-size: 24px;
}
.intro-step {
flex-direction: column;
}
}
// =============================================================================
// Service Common Sidebar & Main Layout
@@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9;
.secret-action-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
width: 100%;
@@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9;
.secret-box {
flex: 1;
max-width: 250px;
height: 48px;
min-width: 0;
min-height: 48px;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 13px;
line-height: 1.4;
color: #4e5968;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF;
@media (max-width: 576px) {
max-width: 100%;
width: 100%;
flex: none;
}
@@ -16,7 +16,7 @@
<span class="service-hero__badge-text">OPEN API 목록</span>
</div>
<h1 class="service-hero__title">OPEN API</h1>
<p class="service-hero__desc">비즈니스 확장을 위한 DJBank의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
<p class="service-hero__desc">비즈니스 확장을 위한 [[${brandName}]]의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
발급받아 지금 바로 개발을 시작해 보세요.</p>
</div>
</div>
@@ -61,6 +61,7 @@
<form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds"
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}"
placeholder="검색어를 입력하세요.">
<button type="submit" class="search-submit-btn" aria-label="검색">
@@ -99,13 +100,17 @@
<!-- API Image (Bottom Right) -->
<div class="api-card-image">
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}"
onerror="this.style.display='none'">
<img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
</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 -->
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
<div class="empty-icon">🔍</div>
@@ -121,6 +126,14 @@
<th:block layout:fragment="contentScript">
<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 () {
// DOM Elements
const sidebar = document.getElementById('apiSidebar');
@@ -128,6 +141,7 @@
const mobileOverlay = document.getElementById('mobileOverlay');
const searchForm = document.getElementById('searchForm');
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
const pageInput = searchForm.querySelector('input[name="page"]');
const searchInput = searchForm.querySelector('input[name="keyword"]');
// API Card Click Handlers
@@ -230,6 +244,9 @@
groupIdsInput.value = '';
}
// 카테고리 변경 시 1페이지로 리셋
if (pageInput) pageInput.value = '1';
// Submit form
searchForm.submit();
});
@@ -239,6 +256,7 @@
searchInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
if (pageInput) pageInput.value = '1';
searchForm.submit();
}
});
@@ -15,7 +15,7 @@
<div class="inner i_cs h_inner8 h_inner10">
<div class="swagger_title m-only">
<p class="title">API 테스트 베드</p>
<p class="text">DJBank API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
<p class="text">[[${brandName}]] API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
</div>
<div class="custom_select select_w h_inp" id="apiList">
@@ -96,7 +96,7 @@
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
제주은행(DJBank) 보안 인증
제주은행([[${brandName}]]) 보안 인증
</span>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다.</p>
</div>
</div>
@@ -24,7 +24,7 @@
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">피드백/개선요청</span>
</div>
<h1 class="service-hero__title">피드백/개선요청</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
</div>
</div>
</section>
@@ -57,6 +57,21 @@
</button>
<div class="recent-apps-body">
<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>
</li>
</ul>
@@ -74,7 +89,7 @@
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청
</h2>
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
<p class="board-desc">[[${brandName}]][[${brandNameJosaEun}]] 온라인 비즈니스 혁신을 위한 피드백/개선요청을
환영합니다.</p>
</div>
@@ -265,6 +280,17 @@
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
document.getElementById('bizSubject').focus();
});
@@ -19,7 +19,7 @@
</div>
<h2 class="login-title">로그인</h2>
</div>
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
<p class="login-message-sub">[[${brandName}]]에 오신걸 환영합니다</p>
</div>
<!-- Alert Messages -->
@@ -39,6 +39,10 @@
<i class="fas fa-info-circle"></i>
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
</div>
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
@@ -15,7 +15,7 @@
<div class="hero-text-content">
<div class="hero-text">
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
<h2 class="hero-title">[[${brandName}]] API가<br>함께 합니다.</h2>
</div>
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div>
@@ -45,7 +45,7 @@
<div class="hero-text-content">
<div class="hero-text">
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
<h2 class="hero-title">준비된 [[${brandName}]] API로 <br>완벽한 서비스를 성공하세요.</h2>
</div>
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
class="bi bi-chevron-right"></i></a>
@@ -234,14 +234,12 @@
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
th:text="${service.groupDesc}">서비스 설명</span>
<span th:unless="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}">
DJBank API 서비스를 이용해보세요.
[[${brandName}]] API 서비스를 이용해보세요.
</span>
</p>
<div class="card-illustration">
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 -->
<img
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
th:alt="${service.groupName}">
<!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
<img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
</div>
</div>
</div>
@@ -288,13 +286,13 @@
<div class="info-content">
<h2 class="info-title">
<span class="title-sub">차별화된 API 서비스</span>
<span class="title-highlight">DJBank API Portal</span>
<span class="title-highlight">[[${brandName}]] API Portal</span>
</h2>
<p class="info-description">
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
[[${brandName}]] API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
</p>
<div class="action-buttons">
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 [[${brandName}]] API</a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
class="bi bi-patch-question"></i></a>
</div>
@@ -325,7 +323,7 @@
<div class="support-header">
<h2 class="support-title">
<span class="title-regular">비즈니스의 시작,</span><br>
<span class="title-bold">DJBank 오픈 API가 함께 하겠습니다.</span>
<span class="title-bold">[[${brandName}]] 오픈 API가 함께 하겠습니다.</span>
</h2>
</div>
@@ -341,7 +339,7 @@
</div>
<div class="card-content">
<h3>공지사항</h3>
<p>DJBank API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
<p>[[${brandName}]] API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
</div>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"
@@ -475,7 +473,7 @@
<div class="stats-header">
<h2 class="stats-title">
<span class="title-top">우리 곁의 수많은 서비스들이</span><br>
<span class="title-highlight">DJBank 오픈 API</span>와 함께하고 있습니다.
<span class="title-highlight">[[${brandName}]] 오픈 API</span>와 함께하고 있습니다.
</h2>
</div>
@@ -550,7 +548,7 @@
<div class="cta-background"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
<h2 class="cta-title">[[${brandName}]] API를 지금 바로 만나보세요.</h2>
<a th:href="@{/signup}" class="btn-signup-cta">회원가입하기</a>
</div>
<img class="ctaImg right" th:src="@{/img/avatar1.svg}" ali="아바타1">
@@ -560,9 +558,6 @@
</th:block>
</body>
<th:block layout:fragment="contentScript">
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
<script th:src="@{/js/main.js}"></script>
<!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript">
@@ -164,7 +164,7 @@
<label class="dt-label">API 목록</label>
<div class="dt-api-list-box">
<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>
</div>
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
@@ -754,15 +754,15 @@
if (withdrawalBtn) {
withdrawalBtn.addEventListener('click', (e) => {
e.preventDefault();
customPopups.showWithdrawal();
customPopups.showAlert(
'법인 관리자는 회원 탈퇴를 할 수 없습니다.<br>' +
'관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.'
);
});
}
});
</script>
</th:block>
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section>
</body>
</html>
@@ -19,7 +19,7 @@
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
</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>
</div>
@@ -110,7 +110,7 @@
비밀번호 확인 <span class="required-badge">필수</span>
</label>
<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}">
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
<div id="password-match-validation" class="org-validation-message"></div>
@@ -411,9 +411,9 @@
});
// 비밀번호 확인 검증
$('#password2').on('blur', function () {
let password2 = $(this).val();
if (!password2) {
$('#confirmPassword').on('blur', function () {
let confirmPassword = $(this).val();
if (!confirmPassword) {
return;
}
@@ -422,7 +422,7 @@
type: 'POST',
data: {
password: $('#password').val(),
password2: password2,
confirmPassword: confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -136,7 +136,7 @@
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
user: ['loginId', 'userName', 'password', 'confirmPassword', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
@@ -228,6 +228,9 @@
});
// Add confirmPassword manually based on the scenario
// (new 시나리오는 #confirmPassword 필드 자체가 있어 위 공통 user 필드 루프가 그대로 처리한다.
// 서버는 시나리오 무관하게 confirmPassword 단일 필드로 비밀번호 확인을 검증한다 —
// OrgRegisterFacadeImpl.registerNewOrgUser 참고.)
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
@@ -17,7 +17,7 @@
</svg>
<h2 class="signup-title">회원가입</h2>
</div>
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
<p class="signup-message">[[${brandName}]] API Portal 사용을 위해 회원 가입해 주세요.</p>
</div>
<!-- Signup Cards -->
@@ -28,7 +28,7 @@
</p>
<p class="info-text" style="margin-top: 16px;">
<strong th:text="${orgName}"></strong>에서
DJBank API Portal 법인회원으로 초대하였습니다.
[[${brandName}]] API Portal 법인회원으로 초대하였습니다.
</p>
</div>
@@ -38,7 +38,7 @@
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
</strong>
<p>
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
<strong><span th:text="${orgName}"></span></strong>에서 [[${brandName}]] API Portal 법인회원으로 초대하였습니다.<br>
초대를 수락하시면 법인회원으로 전환됩니다.
</p>
</div>
@@ -18,7 +18,7 @@
<span class="service-hero__badge-text">회원가입 소개</span>
</div>
<h1 class="service-hero__title">회원 가입 안내</h1>
<p class="service-hero__desc">DJ Bank API 개발자 포털에 방문해 주셔서 감사합니다.<br>DJBank API 사용을 위해서는 다음과 같은
<p class="service-hero__desc">[[${brandName}]] API 개발자 포털에 방문해 주셔서 감사합니다.<br>[[${brandName}]] API 사용을 위해서는 다음과 같은
이용절차로 진행하여야 합니다.</p>
</div>
</div>
@@ -5,9 +5,235 @@
<body>
<th:block layout:fragment="contentFragment">
<section class="service-intro">
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
</section>
<div class="service-intro">
<!-- Hero -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<svg width="230" height="167" viewBox="0 0 300 200" fill="none" aria-hidden="true">
<ellipse cx="150" cy="178" rx="98" ry="14" fill="#0B2A5B" opacity=".88"/>
<path d="M44 96h212" stroke="#0049B4" stroke-width="6" stroke-linecap="round"/>
<path d="M60 96v58h180V96" stroke="#0049B4" stroke-width="6" stroke-linejoin="round"/>
<path d="M40 96 150 38l110 58" stroke="#0B2A5B" stroke-width="7" stroke-linejoin="round" fill="#fff"/>
<rect x="84" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="118" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="168" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="202" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<circle cx="150" cy="74" r="13" fill="#F08A24"/>
<path d="M144 74h12M150 68v12" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<path d="M22 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
<path d="M262 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
</svg>
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">서비스 소개</span>
</div>
<h1 class="service-hero__title">[[${brandName}]]의 금융을<br>API로 연결합니다</h1>
<p class="service-hero__desc">
1969년 제주에서 시작해 신한금융그룹과 함께 성장해 온 [[${brandName}]][[${brandNameJosaGa}]]<br>
인증·조회·이체·기업여신 서비스를 표준 오픈 API로 개방합니다.
</p>
</div>
</div>
</section>
<div class="container service-main">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('intro')}"></th:block>
<section class="service-content">
<!-- Callout -->
<div class="intro-callout">
<div class="intro-callout__icon">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="m6 13.4 5 5 9.5-11" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<ul>
<li>[[${brandName}]] API 포탈은 <strong>핀테크·법인·솔루션 사업자</strong>가 [[${brandName}]] 금융 서비스를 연동하는 <strong>공식 파트너 채널</strong>입니다.</li>
<li>API 명세·샌드박스·이용 신청·호출 이력을 <strong>한 곳에서</strong> 제공하며, 모든 호출은 <strong>OAuth2 기반 인증</strong>으로 보호됩니다.</li>
<li>이용은 <strong>회원가입 → 심사·승인 → 앱 등록 → 테스트 → 운영 전환</strong> 순으로 진행됩니다.</li>
</ul>
</div>
<!-- ABOUT -->
<section class="intro-section" aria-labelledby="intro-about-title">
<span class="intro-section__eyebrow">ABOUT PORTAL</span>
<h2 class="intro-section__title" id="intro-about-title">[[${brandName}]] 오픈 API 포탈이란</h2>
<p class="intro-section__lead">
1969년 설립된 제주은행은 반세기 넘게 지역 경제의 중추 역할을 해왔고, 신한금융지주회사의 자회사 편입 이후
디지털 전환에 속도를 내고 있습니다. 은행의 비전인 <strong>&ldquo;제주를 더 가깝고, 더 편리하게 &mdash; 당신의 설렘을 담은 은행&rdquo;</strong>
창구를 넘어 고객이 이미 사용하는 서비스 안으로 금융을 옮기는 일에서 시작합니다.<br><br>
API 포탈은 그 실행 도구입니다. 계좌 조회와 이체 같은 기본 뱅킹부터 기업여신·수납·알림까지,
내부에서만 쓰이던 금융 기능을 표준 REST API와 웹훅으로 정리해 외부 파트너에게 개방합니다.
문서·샌드박스·키 관리·모니터링을 하나의 화면에서 제공하므로, 별도 협의 없이도 연동 설계를 먼저 시작할 수 있습니다.
</p>
</section>
<!-- WHO -->
<section class="intro-section" aria-labelledby="intro-who-title">
<span class="intro-section__eyebrow">FOR WHOM</span>
<h2 class="intro-section__title" id="intro-who-title">이런 분들이 이용합니다</h2>
<div class="intro-who">
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="M8 12h8M8 15.5h5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg>
<div class="intro-who__title">핀테크 기업</div>
<p>결제·자산관리 서비스에 은행 계좌 기능을 탑재</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M4 20V8l8-4 8 4v12" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="M9.5 20v-5h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linejoin="round"/></svg>
<div class="intro-who__title">법인 · 기업 고객</div>
<p>자체 ERP·그룹웨어에서 자금 업무 자동화</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M9 6 4 12l5 6m6-12 5 6-5 6" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div class="intro-who__title">ERP · 회계 솔루션사</div>
<p>SaaS 제품에 임베디드 뱅킹 기능 제공</p>
</div>
</div>
</section>
<!-- SERVICES -->
<section class="intro-section" aria-labelledby="intro-services-title">
<span class="intro-section__eyebrow">API SERVICES</span>
<h2 class="intro-section__title" id="intro-services-title">제공 서비스</h2>
<p class="intro-section__lead">6개 도메인으로 구성되며, 서비스별로 이용 신청과 심사가 개별 진행됩니다.</p>
<div class="intro-grid3">
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="4" y="9.5" width="14" height="9" rx="2.4" stroke="#0049B4" stroke-width="2"/><path d="M7.5 9.5V7a3.5 3.5 0 1 1 7 0v2.5" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>인증 · 토큰</h3>
<p>OAuth2 client_credentials로 access_token을 발급하고, 모든 호출에 Bearer 토큰을 사용합니다.</p>
<span class="intro-card__tag">OAuth2</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="2.5" y="5" width="17" height="12" rx="2.6" stroke="#0049B4" stroke-width="2"/><path d="M2.5 9.5h17" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>계좌 · 조회</h3>
<p>실명확인, 계좌 개설, 잔액·거래내역 조회 등 기본 뱅킹 조회 기능을 제공합니다.</p>
<span class="intro-card__tag">Account</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M4 7h11l-3-3m6 11H7l3 3" stroke="#0049B4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>이체 · 자금</h3>
<p>단건·대량이체, 급여이체, 예약이체와 이체 결과 조회를 지원합니다.</p>
<span class="intro-card__tag">Transfer</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M3 18V9m5 9V4m5 14v-6m5 6V7" stroke="#0049B4" stroke-width="2.2" stroke-linecap="round"/></svg></div>
<h3>기업여신</h3>
<p>사전 한도 조회, 대출 신청·실행·상환, 매출채권 기반 여신 연계를 처리합니다.</p>
<span class="intro-card__tag">Loan</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="7.5" stroke="#0049B4" stroke-width="2"/><path d="M11 6.5v5l3 2" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>수납 · 외환</h3>
<p>가상계좌 발급·입금 통지, 공과금 수납, 환율 조회 등 부가 금융 서비스입니다.</p>
<span class="intro-card__tag">Billing / FX</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M11 3a6 6 0 0 1 6 6v4l2 3H4l2-3V9a6 6 0 0 1 5-6Z" stroke="#0049B4" stroke-width="2" stroke-linejoin="round"/><path d="M9 18.5a2.2 2.2 0 0 0 4 0" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>웹훅 · 알림</h3>
<p>입출금, 심사 결과, 상태 변경 이벤트를 등록된 URL로 실시간 전송합니다.</p>
<span class="intro-card__tag">Webhook</span>
</div>
</div>
</section>
<!-- ARCHITECTURE -->
<section class="intro-section" aria-labelledby="intro-arch-title">
<span class="intro-section__eyebrow">ARCHITECTURE</span>
<h2 class="intro-section__title" id="intro-arch-title">연동 구조</h2>
<div class="intro-diagram">
<svg width="100%" viewBox="0 0 950 250" fill="none">
<rect x="8" y="48" width="200" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="108" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">PARTNER</text>
<text x="108" y="106" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">파트너 시스템</text>
<text x="108" y="134" text-anchor="middle" font-size="12.5" fill="#55688A">핀테크 앱 · ERP · 회계 SaaS</text>
<text x="108" y="154" text-anchor="middle" font-size="12.5" fill="#55688A">법인 자체 시스템</text>
<path d="M214 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m306 118 10 6-10 6" fill="#0049B4"/>
<text x="262" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">HTTPS / OAuth2</text>
<rect x="322" y="26" width="286" height="196" rx="14" fill="#0B2A5B"/>
<text x="465" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="#4E9BE0">JEJU BANK API PORTAL</text>
<rect x="346" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">인증 서버</text>
<rect x="470" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">API Gateway</text>
<rect x="346" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">유량 · IP 제어</text>
<rect x="470" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">로그 · 모니터링</text>
<text x="465" y="198" text-anchor="middle" font-size="11.5" fill="#9EB6D6">샌드박스 · 키 관리 · 호출 이력 · 통계</text>
<path d="M614 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m706 118 10 6-10 6" fill="#0049B4"/>
<text x="662" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">내부 전문</text>
<rect x="722" y="48" width="212" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="828" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">CORE BANKING</text>
<text x="828" y="102" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">[[${brandName}]] 계정계</text>
<text x="828" y="130" text-anchor="middle" font-size="12.5" fill="#55688A">수신 · 여신 · 외환 원장</text>
<text x="828" y="150" text-anchor="middle" font-size="12.5" fill="#55688A">심사 · 컴플라이언스</text>
</svg>
</div>
</section>
<!-- PROCESS -->
<section class="intro-section" aria-labelledby="intro-process-title">
<span class="intro-section__eyebrow">PROCESS</span>
<h2 class="intro-section__title" id="intro-process-title">이용 절차</h2>
<div class="intro-steps">
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><circle cx="10" cy="8" r="3.4" stroke="#0049B4" stroke-width="1.8"/><path d="M3 20c.6-3.6 3.3-5.4 7-5.4" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round"/><path d="M17 13v7m-3.5-3.5h7" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 01</em>회원가입</div><p>법인 회원으로 온라인 가입을 신청합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 02</em>승인</div><p>운영 담당자가 신청 계정 정보를 확인한 후 승인합니다. 법인 관리자는 승인 이후 실제 사용할 직원(개발자)을 추가 등록합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M9.5 4.5 3.5 12l6 7.5" stroke="#F08A24" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="m14.5 4.5 6 7.5-6 7.5" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 03</em>API / APP 사용 신청</div><p>[내 앱]에서 애플리케이션을 등록하고 필요한 API를 선택해 신청하면 ClientID / Secret이 발급됩니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M4 17.5 9 12l3.5 3.5L20 8" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 8h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 04</em>샌드박스 개발 · 테스트</div><p>테스트 키로 토큰 발급과 API 호출, 웹훅 수신을 검증합니다. 테스트 데이터는 실제 원장에 반영되지 않습니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M12 3 4 6.5v6c0 4.5 3.3 7.6 8 8.8 4.7-1.2 8-4.3 8-8.8v-6L12 3Z" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="m8.8 12.2 2.4 2.4 4.2-4.8" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 05</em>운영 전환</div><p>보안 점검과 계약 절차를 마치면 운영 키가 발급되고 실거래 호출이 시작됩니다.</p></div>
</div>
</div>
</section>
<!-- SECURITY -->
<section class="intro-section" aria-labelledby="intro-security-title">
<span class="intro-section__eyebrow">SECURITY &amp; OPERATION</span>
<h2 class="intro-section__title" id="intro-security-title">보안 및 운영 정책</h2>
<div class="intro-grid3">
<div class="intro-card">
<h3>인증 · 통신</h3>
<p>OAuth2 client_credentials 방식으로 발급한 토큰을 <code>X-AUTH-TOKEN</code> 헤더로 전달합니다. 모든 구간은 TLS 1.2 이상으로 암호화합니다.</p>
</div>
<div class="intro-card">
<h3>접근 통제</h3>
<p>앱 단위로 IP 허용목록과 호출 유량(rate limit)을 적용하여 운영하고 있습니다.</p>
</div>
<div class="intro-card">
<h3>모니터링 · 지원</h3>
<p>포탈에서 호출 이력·응답코드·지연 통계를 조회할 수 있으며, 장애 상황은 API Status 페이지와 등록 메일로 공지합니다.</p>
</div>
</div>
</section>
<!-- CTA -->
<div class="intro-cta">
<div class="intro-cta__body">
<h3>[[${brandName}]] API, 지금 신청하세요</h3>
<p>가입 후 앱을 등록하면 샌드박스 키가 발급되어 바로 개발을 시작할 수 있습니다.</p>
</div>
<a class="btn-action-primary" th:href="@{/signup}">개발자 회원가입</a>
</div>
</section>
</div>
</div>
</th:block>
</body>

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