33 Commits

Author SHA1 Message Date
Rinjae 5585df9133 메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현 2026-08-05 14:19:33 +09:00
Rinjae 2c63fa1557 개발자포탈 미게시 GW 인터페이스 표시 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- hiddenApiCount 필드로 미게시 인터페이스 집계 노출
- API Pills 및 관련 CSS/JS 수정
- 영향 인터페이스 노출 로직 개선 및 VisibleApis 클래스 적용
2026-08-05 10:44:50 +09:00
Rinjae d71af34950 공지 연계 기능 추가 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 중, 과거 장애/점검 카드에 공지 제목 및 본문 추가.
- 공지 본문 2열 레이아웃 및 세로 스크롤 스타일 적용.
- CSS 반응형 수정(768px → 1024px).
2026-08-05 09:33:22 +09:00
Rinjae fbc145d145 로고 워드마크 정렬 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 로고 텍스트 및 링크 baseline 맞춤.
- CSS 수정 및 스타일 정렬 개선.
2026-08-04 19:27:54 +09:00
Rinjae f36b1a6478 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-08-04 19:23:45 +09:00
Rinjae 8f03209f8a UI 문구 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 공지 상세 화면 "끝" → "종료"로 수정
- Webhook 가이드 "장애 시작" → "장애 발생"으로 변경
2026-08-04 16:20:11 +09:00
hong 8743592fde fix : footer 수정 2026-08-04 14:59:49 +09:00
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +09:00
Rinjae b45a7a162a - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
Rinjae 07fbe1ba54 장애·지연 구분 및 집계 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 지연 유형 로직 및 UI/스타일 업데이트
- JPQL 조회와 공개 조건 로직 전면 개정
2026-08-03 16:57:57 +09:00
Rinjae 4916bdf4af API 상태 지연 필터 및 UI/로직 개선:
- 장애·지연 구분 및 집계 로직 추가 대응
- 지연 유형 반영 색상/태그/레이블/UI 경로 업데이트
2026-08-03 15:33:21 +09:00
Rinjae 35621b5174 Merge branch 'master' into feats/api-status 2026-07-31 17:35:11 +09:00
Rinjae b97b0fcf7b "SBOM 생성 및 변환 추가 - CycloneDX -> Excel(xlsx) 지원 - Jenkins 단계에 SBOM 빌드 및 아티팩트 보관 연결"
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-07-31 17:31:45 +09:00
Rinjae 6cb98d36bb API Status 개선 및 조회 기간 설정 추가 2026-07-31 16:22:43 +09:00
Rinjae fd229fca43 API Status 페이지 기능 및 UI 업데이트:
- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가
- 지연 필터 및 상대 시간 표기 기능 구현
- 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
2026-07-31 14:42:09 +09:00
Rinjae 5e8d08f1af API Status 페이지 포탈 미게시 API 안내 추가:
- 포탈 미게시 API 필터 안내 배너 HTML 및 JS 추가
- 조회 시 API 명 표시 및 API 옵션 외 필터 유지 로직 변경
2026-07-31 14:10:39 +09:00
Rinjae e7945233fe Merged from master 2026-07-31 13:41:25 +09:00
Rinjae 815e2bdd04 API Status 페이지 UI 및 주요 기능 추가:
- API Status 페이지 SCSS 스타일 및 반응형 대응 추가
- 진행 중 장애 DTO 및 영향 API DTO 구현
- 진행 중 장애 및 점검 사항 관련 JS 렌더링 로직 추가
2026-07-31 13:38:37 +09:00
Rinjae 0629e842a3 푸터 외부 링크 스타일 추가 및 SCSS 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- footer-link--external 스타일 추가로 외부 링크 시각화
- 불필요한 CSS 줄바꿈 및 공백 제거로 파일 최적화
2026-07-31 13:33:41 +09:00
Rinjae 6fa2167378 Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현 2026-07-30 19:33:11 +09:00
Rinjae 84e873a08b Merge branch 'design' into feats/api-status 2026-07-30 17:42:29 +09:00
Rinjae 6c231c4189 API 해지 팝업 추가 및 사용자 오류 메시지 처리 개선
- API 해지 신청 팝업 UI 및 로직 구현
- 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가
- Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
2026-07-30 16:22:45 +09:00
Rinjae e3b5a44270 Merge branch 'master' into feats/api-status 2026-07-30 13:44:50 +09:00
Rinjae 829630ae5c API 스펙 관리 문구 및 로직 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:24:05 +09:00
Rinjae 2a9694cd85 API 스펙 관리 문구 및 로직 개선
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가
- 비 로그인 사용자 공개 관련 문구 수정
- description 필드 저장/복원 로직 추가
2026-07-30 11:24:05 +09:00
hong 456b635a32 merge 충돌 해결 2026-07-30 10:07:00 +09:00
hong faaf9e8cd6 feat: 로그인 퍼블리싱 2026-07-30 10:01:38 +09:00
Rinjae 4749613947 - PortalOrg 초기화 필드 추가(orgDesc, ipWhitelist 등)
- ApiTesterFilter 디버깅 로직/유틸 추가(maskHeaders 등)
- jackson-core 취약점(CVSS 7.5) 수정 버전 업그레이드(2.18.6)
2026-07-29 20:20:43 +09:00
Rinjae 5125dab897 Merge branch 'design' of ssh://172.30.1.50:2222/djb-eapim/eapim-portal into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-07-29 19:32:08 +09:00
hong 4181d114c1 style: SASS 빌드 결과 main.css 업데이트 2026-07-28 18:21:56 +09:00
hong daff07c006 merge후 충돌 해결 2026-07-28 18:18:23 +09:00
hong 1f51c9b21e feat: 로그인과 회원가입 퍼블리싱 2026-07-28 18:09:20 +09:00
165 changed files with 14258 additions and 6052 deletions
+15
View File
@@ -80,6 +80,21 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+15
View File
@@ -101,5 +101,20 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+1
View File
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
+7 -4
View File
@@ -80,9 +80,10 @@ dependencies {
// exclude group: 'commons-collections', module: 'commons-collections'
}
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
@@ -202,4 +203,6 @@ task printSourceSets {
println " Output dir : ${srcSet.output.classesDirs.asPath}"
}
}
}
}
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
+326
View File
@@ -0,0 +1,326 @@
/*
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
*
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
*
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
* classpath 나 WAR 산출물에는 포함되지 않는다.
*
* 시트: 요약 / WAR 기준
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
*/
buildscript {
repositories {
maven {
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
allowInsecureProtocol = true
}
mavenCentral()
}
dependencies {
classpath 'org.apache.poi:poi-ooxml:3.17'
}
}
import groovy.json.JsonSlurper
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
ext.SBOM_CELL_LIMIT = 32000
task sbomXlsx {
group = 'sbom'
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
if (!project.hasProperty('sbomJson')) {
dependsOn 'cyclonedxBom'
}
doLast {
File src = resolveBomJson(project)
File out = project.hasProperty('sbomOut')
? project.file(project.property('sbomOut'))
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
out.parentFile.mkdirs()
def bom = new JsonSlurper().parse(src, 'UTF-8')
def deploy = collectDeployJars(project)
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
def wb = new XSSFWorkbook()
def st = createStyles(wb)
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
writeWarSheet(wb, st, warRows)
out.withOutputStream { os -> wb.write(os) }
wb.close()
int unmatched = warRows.count { it.matched == 'N' }
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
}
}
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
String sbomFileName(Project p) {
def warTask = p.tasks.findByName('war')
if (warTask != null) {
String archive = warTask.archiveFileName.get()
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
}
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
return "${p.name}${ver}-sbom.xlsx"
}
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
File resolveBomJson(Project p) {
if (p.hasProperty('sbomJson')) {
File f = p.file(p.property('sbomJson'))
if (!f.exists()) {
throw new GradleException("bom.json 없음: ${f.absolutePath}")
}
return f
}
def candidates = [
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
new File(p.buildDir, 'reports/bom.json'),
]
File found = candidates.find { it.exists() }
if (found == null) {
throw new GradleException(
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
}
return found
}
/**
* 실제 배포물에 packaging 되는 jar 목록.
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
*
* @return [label: 기준 설명, jars: 행 목록]
*/
Map collectDeployJars(Project p) {
def cfg = p.configurations.findByName('runtimeClasspath')
if (cfg == null) {
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
return [label: '(없음)', jars: []]
}
def warTask = p.tasks.findByName('war')
def files
String label
if (warTask != null) {
files = warTask.classpath.files
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
} else {
files = cfg.files
label = 'runtimeClasspath (war 태스크 없음)'
}
def coordByFile = [:]
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
def id = a.moduleVersion.id
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
}
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
def c = coordByFile[f]
[
file : f.name,
group : c?.group ?: '',
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
version: c?.version ?: '',
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
]
}.sort { it.file }
return [label: label, jars: jars]
}
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
Map indexComponents(bom) {
String rootRef = bom.metadata?.component?.'bom-ref'
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
def index = [:]
bom.components?.each { c ->
def hashes = [:]
c.hashes?.each { h -> hashes[h.alg] = h.content }
def licenses = (c.licenses ?: []).collect { l ->
l.license?.id ?: l.license?.name ?: l.expression ?: ''
}.findAll { it }
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
licenses : licenses.join('; '),
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
purl : c.purl ?: '',
sha256 : hashes['SHA-256'] ?: '',
sha1 : hashes['SHA-1'] ?: '',
description: c.description ?: '',
]
}
return index
}
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
List joinWarRows(List warJars, Map index) {
def result = []
warJars.eachWithIndex { j, i ->
def m = j.coord ? index[j.coord] : null
result << [
no : i + 1,
file : j.file,
group : j.group,
name : j.name,
version : j.version,
direct : m?.direct ?: '',
licenses : m?.licenses ?: '',
licenseList: m?.licenseList ?: ['(미상)'],
purl : m?.purl ?: '',
sha256 : m?.sha256 ?: '',
sha1 : m?.sha1 ?: '',
matched : (m != null) ? 'Y' : 'N',
description: m?.description ?: '',
]
}
return result
}
Map createStyles(wb) {
def headFont = wb.createFont()
headFont.setBold(true)
headFont.setColor(IndexedColors.WHITE.getIndex())
def head = wb.createCellStyle()
head.setFont(headFont)
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
head.setAlignment(HorizontalAlignment.CENTER)
head.setVerticalAlignment(VerticalAlignment.CENTER)
head.setBorderBottom(BorderStyle.THIN)
def body = wb.createCellStyle()
body.setVerticalAlignment(VerticalAlignment.TOP)
def wrap = wb.createCellStyle()
wrap.setVerticalAlignment(VerticalAlignment.TOP)
wrap.setWrapText(true)
def labelFont = wb.createFont()
labelFont.setBold(true)
def label = wb.createCellStyle()
label.setFont(labelFont)
return [head: head, body: body, wrap: wrap, label: label]
}
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
def row = sheet.createRow(0)
row.setHeightInPoints(20f)
headers.eachWithIndex { h, i ->
def cell = row.createCell(i)
cell.setCellValue(h)
cell.setCellStyle(style)
sheet.setColumnWidth(i, widths[i] * 256)
}
sheet.createFreezePane(0, 1)
}
def cellOf(row, int idx, value, style) {
def cell = row.createCell(idx)
String s = (value == null) ? '' : value.toString()
if (s.length() > SBOM_CELL_LIMIT) {
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
}
cell.setCellValue(s)
cell.setCellStyle(style)
return cell
}
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
def sheet = wb.createSheet('요약')
def comp = bom.metadata?.component ?: [:]
def tool = bom.metadata?.tools?.components?.getAt(0)
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
def items = [
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
['serialNumber', bom.serialNumber ?: ''],
['생성 시각', bom.metadata?.timestamp ?: ''],
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
['원본 파일', src.absolutePath],
['배포 수록 jar', warRows.size()],
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
['라이선스 종류', licenseKinds.size()],
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
]
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
items.eachWithIndex { item, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, item[0], st.label)
cellOf(row, 1, item[1], st.body)
}
// 라이선스 분포 (요약 하단)
def byLicense = [:].withDefault { 0 }
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
int base = items.size() + 2
def hdr = sheet.createRow(base)
cellOf(hdr, 0, '라이선스', st.head)
cellOf(hdr, 1, 'jar 수', st.head)
sorted.eachWithIndex { e, i ->
def row = sheet.createRow(base + 1 + i)
cellOf(row, 0, e.key, st.body)
cellOf(row, 1, e.value, st.body)
}
}
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
def writeWarSheet(wb, st, List warRows) {
def sheet = wb.createSheet('WAR 기준')
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
writeHeader(sheet, st.head, headers, widths)
warRows.eachWithIndex { r, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, r.no, st.body)
cellOf(row, 1, r.file, st.body)
cellOf(row, 2, r.group, st.body)
cellOf(row, 3, r.name, st.body)
cellOf(row, 4, r.version, st.body)
cellOf(row, 5, r.direct, st.body)
cellOf(row, 6, r.licenses, st.body)
cellOf(row, 7, r.purl, st.body)
cellOf(row, 8, r.sha256, st.body)
cellOf(row, 9, r.sha1, st.body)
cellOf(row, 10, r.matched, st.body)
cellOf(row, 11, r.description, st.wrap)
}
if (!warRows.isEmpty()) {
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
}
}
@@ -0,0 +1,85 @@
# 메뉴 관리 개발 가이드
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
## 구성 요소
| 구성 | 위치 | 역할 |
|---|---|---|
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
## menu.yml 스키마
```yaml
portal-menu:
items:
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
name: "고객지원"
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
children:
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
```
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
## 시딩 규칙 (MenuSeeder)
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
2. **배치**: `PTL_MENU_PLACEMENT`**비어있을 때만** 기본 배치로 최초 시딩.
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
## 캐시와 리로드
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
## 새 메뉴 추가 절차
**기본 메뉴(코드 배포와 함께)**
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
커스텀 항목은 미배치 시 삭제된다.
## 로컬 개발 주의
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
## 역할(roles.yml) 변경
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
admin 권한 선택 체크박스 소스 전용.
- 역할 추가 시 `roles.yml``authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
@@ -0,0 +1,36 @@
package com.eactive.apim.gateway.data.statistics.entity;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@Data
public class GwApiStatus {
/** EAI 서비스명 */
@Id
@Column(name = "EAISVCNAME", length = 30)
private String eaisvcname;
/** N 정상 / C 점검 / D 지연 / E 장애 */
@Column(name = "STATUS_CODE", length = 1)
private String statusCode;
@Column(name = "MODIFIED_BY", length = 20)
private String modifiedBy;
@Column(name = "MODIFIED_DATE")
private LocalDateTime modifiedDate;
}
@@ -0,0 +1,17 @@
package com.eactive.apim.gateway.data.statistics.repository;
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
}
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -31,6 +32,7 @@ public class ApiController {
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -86,6 +88,8 @@ public class ApiController {
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList";
}
@@ -203,6 +203,14 @@ public class ApiTesterFilter implements Filter {
}
proxyTarget = targetUri;
if (logger.isDebugEnabled()) {
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
@@ -210,6 +218,11 @@ public class ApiTesterFilter implements Filter {
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
auditType, auditId, targetUri,
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
@@ -308,6 +321,27 @@ public class ApiTesterFilter implements Filter {
}
/** 상태코드 + JSON 본문 응답. */
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<String, String> e : headers.entrySet()) {
if (sb.length() > 1) {
sb.append(", ");
}
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
}
return sb.append('}').toString();
}
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
private String previewOf(String body) {
if (body == null) {
return "null";
}
String flat = body.replaceAll("\\s+", " ").trim();
return flat.length() > 300 ? flat.substring(0, 300) + "" : flat;
}
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
@@ -11,11 +11,11 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -84,7 +84,6 @@ public class MyAppController {
private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
@@ -184,6 +183,7 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -239,10 +239,12 @@ public class MyAppController {
}
/**
* API Key를 삭제합니다.
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
*
* @param requestData 요청 데이터 (clientId와 type 포함)
* @param requestData 요청 데이터 (clientId, reason)
* @return 성공/실패 결과를 담은 Map
*/
@PostMapping("/api_key_delete")
@@ -258,38 +260,44 @@ public class MyAppController {
return result;
}
String reason = requestData.get("reason");
if (reason == null || reason.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "해지 사유를 입력해 주세요.");
return result;
}
if (reason.length() > 1000) {
result.put("success", false);
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
try {
adminGatewayClient.blockClient(clientId);
} catch (Exception e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
} catch (IllegalStateException e) {
result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
result.put("msg", e.getMessage());
return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
return result;
}
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제니다.");
return result;
}
@@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -64,7 +65,11 @@ public class AppServiceFacade {
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
return clients.stream()
.sorted(Comparator.comparing(Credential::getModifiedon,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(credentialMapper::toVo).collect(Collectors.toList());
}
@@ -73,12 +78,29 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
return appRequests;
}
private int pendingStatusRank(AppRequest request) {
if (request.getApproval() == null) {
return 3;
}
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
return 1;
}
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
return 2;
}
return 3;
}
public ClientDTO getApiKey(String orgid, String clientId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
}
@@ -138,13 +160,70 @@ public class AppServiceFacade {
approvalService.beginApproval(approvalId);
}
/**
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
*
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
*/
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
for (AppRequest r : related) {
if (r.getApproval() == null) {
continue;
}
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
if (!inProgress) {
continue;
}
if (AppRequestType.DELETE.equals(r.getType())) {
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
}
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
}
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
AppRequestDTO dto = new AppRequestDTO();
dto.setType(AppRequestType.DELETE);
dto.setClientId(clientId);
dto.setReason(reason);
dto.setOrg(portalOrgMapper.toVo(portalOrg));
AppRequestDTO saved = createAppRequest(dto);
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
}
beginApproval(saved.getApproval().getId());
}
/**
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
*/
public boolean hasPendingDeleteRequest(String clientId) {
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
.anyMatch(r -> r.getApproval() != null
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
}
public void cancelApiRequest(String id, PortalOrg portalOrg) {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
if (request.getApproval() == null) {
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
if (StringUtils.isNotBlank(request.getClientId())) {
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
if (StringUtils.isNotBlank(request.getClientId())
&& !AppRequestType.DELETE.equals(request.getType())) {
try {
adminGatewayClient.blockClient(request.getClientId());
} catch (Exception e) {
@@ -343,18 +422,4 @@ public class AppServiceFacade {
return secret;
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
*
* @param orgId 조직 ID
* @param clientId 삭제할 클라이언트 ID
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public void deleteApp(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
credentialRepository.delete(credential);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
private List<TimelineEntryDTO> timeline = Collections.emptyList();
public boolean hasTimeline() {
return timeline != null && !timeline.isEmpty();
}
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@EMSDataSource
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
/**
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
*/
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
}
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusAssembler apiStatusAssembler;
private final ApiStatusCatalogService apiStatusCatalogService;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
int hiddenCount = 0;
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hiddenCount++;
continue;
}
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
}
dto.setAffectedApis(apis);
dto.setHiddenApiCount(hiddenCount);
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
dto.setTimeline(degrading
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
: Collections.emptyList());
}
}
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
private final SwingNotifier swingNotifier;
@Override
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
private final SwingNotifier swingNotifier;
private final FileService fileService;
@Override
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
@@ -57,6 +57,13 @@ public class AccountController {
private final TwoFactorProperties twoFactorProperties;
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
@PostMapping("/password/content-check")
public ResponseEntity<Map<String, Boolean>> checkPasswordContent(@RequestParam String password) {
String currentLoginId = SecurityUtil.getCurrentLoginId();
return ResponseEntity.ok(userFacade.checkPasswordContent(currentLoginId, password));
}
@PostMapping("/password/confirm")
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
String currentLoginId = SecurityUtil.getCurrentLoginId();
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final FileService fileService;
private final BasicValidationService validationService;
private final UserRegistrationValidationService userRegistrationValidationService;
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final ApprovalService approvalService;
@@ -75,14 +76,29 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
}
// 2025.10.20 - 휴대폰 번호 중복 무시
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
// 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
// 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (retain/change 시나리오는 기존 비밀번호 유지라 제외)
if (!passwordValidator.isValidPassword(orgDTO.getPassword(), orgDTO.getLoginId(), orgDTO.getMobileNumber())) {
return new ValidationResponse(false,
"비밀번호는 영문/숫자/특수문자 포함 8~50자이며, 아이디·휴대전화 번호, 3자리 이상 연속·반복 문자는 사용할 수 없습니다.");
}
if (orgDTO.getConfirmPassword() == null || !orgDTO.getConfirmPassword().equals(orgDTO.getPassword())) {
return new ValidationResponse(false, "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
}
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
if(existingUser.isPresent()) {
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
}
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
try {
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
if (uploadedFile == null) {
@@ -10,6 +10,9 @@ public interface UserFacade {
void updatePassword(String loginId, String newPassword, String confirmPassword);
/** 비밀번호에 아이디/휴대전화 번호가 포함되는지 라이브 체크용 판정 (키: idIncluded, mobileIncluded) */
java.util.Map<String, Boolean> checkPasswordContent(String loginId, String password);
void updateUser(PortalUserDTO portalUserDTO);
void updateCorporateManager(PortalUserDTO portalUserDTO);
@@ -60,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
}
@Override
@Transactional(readOnly = true)
public HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
return passwordService.checkPasswordContent(loginId, password);
}
@Override
@Transactional
public void updateUser(PortalUserDTO portalUserDTO) {
@@ -187,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(),
com.eactive.apim.portal.common.util.PhoneNumberUtil.normalize(registrationDTO.getMobileNumber()));
if(existingUser != null) {
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
@@ -71,6 +72,19 @@ public class PasswordService {
}
}
/** 비밀번호에 본인 아이디(local part)/휴대전화 세그먼트가 포함되는지 판정 — 변경 화면 라이브 체크용 */
@Transactional(readOnly = true)
public java.util.HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
java.util.HashMap<String, Boolean> result = new java.util.HashMap<>();
result.put("idIncluded",
PasswordRuleValidator.containsLoginIdLocalPart(password, user.getLoginId()));
result.put("mobileIncluded",
PasswordRuleValidator.containsMobileSegment(password, user.getMobileNumber()));
return result;
}
private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -5,11 +5,12 @@ import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -49,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final PortalUserRepository portalUserRepository;
private final PortalUserMapper portalUserMapper;
private final PortalProperties portalProperties;
private final PortalRolesProperties portalRolesProperties;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
@@ -75,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
List<String> roles = portalRolesProperties.getAuthorities(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
@@ -151,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
}
public void resetPassword(String loginId, String userName, String mobileNumber) {
// 입력 그룹핑이 저장 정규형과 달라도 매칭되도록 조회 전 정규화 (암호화 컬럼 등가 비교)
mobileNumber = PhoneNumberUtil.normalize(mobileNumber);
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
}
@@ -185,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
@Transactional
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
try {
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber)
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@@ -35,7 +36,7 @@ public class UserRoleHistoryService {
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
*/
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
try {
String actor = SecurityUtil.getCurrentLoginId();
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.common.exception;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
@@ -13,11 +11,12 @@ import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -39,6 +38,7 @@ public class PortalGlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass());
private final PortalProperties portalProperties;
private final Environment environment;
@ExceptionHandler(value = NotFoundException.class)
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
@@ -129,10 +129,10 @@ public class PortalGlobalExceptionHandler {
@ExceptionHandler(value = {IllegalArgumentException.class})
public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) {
log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("error");
log.error("잘못된 요청 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
return modelAndView;
}
@@ -146,34 +146,40 @@ public class PortalGlobalExceptionHandler {
@ExceptionHandler(value = HttpMediaTypeNotSupportedException.class)
public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) {
log.error(ex.getMessage());
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("error");
log.error("지원하지 않는 요청 형식 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
modelAndView.addObject("errorDescription", "지원하지 않는 요청 형식입니다.\n잠시 후 다시 시도해 주세요.");
return modelAndView;
}
/**
* 처리되지 않은 예외. 예외 원문은 로그에만 남기고 화면에는 사용자 안내 문구를 표시한다.
* 원문(클래스명/메시지)은 운영(prod) 이외 환경의 상세 영역에만 노출한다.
*/
@ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
Map<String, Object> params = new HashMap<>(2);
params.put("errorMessage", ex.getLocalizedMessage());
params.put("stackTrace", ExceptionUtils.getStackTrace(ex)); // Apache Commons Lang
params.put("requestURL", request.getRequestURL().toString());
String mapAsString = request.getParameterMap().entrySet()
String requestParams = request.getParameterMap().entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
.collect(Collectors.joining(", "));
params.put("requestParams", mapAsString);
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
log.error("Exception occurred: ", ex);
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("errorMessage", ex.getMessage());
modelAndView.setViewName("error");
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("errorTitle", "서비스 처리 중 오류가 발생했습니다.");
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
if (!isProd()) {
modelAndView.addObject("errorMessage", ex.getClass().getName() + ": " + ex.getMessage());
modelAndView.addObject("activeProfile", String.join(", ", environment.getActiveProfiles()));
}
return modelAndView;
}
private boolean isProd() {
return environment.acceptsProfiles(Profiles.of("prod"));
}
@ExceptionHandler(value = InvalidFileException.class)
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
ModelAndView modelAndView = new ModelAndView();
@@ -53,10 +53,14 @@ public class PortalRestExceptionHandler {
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
/**
* 처리되지 않은 예외. 원문 메시지는 로그에만 남기고, 화면에는 사용자 안내 문구를 내려준다.
* (JTA 롤백/DB 락 같은 인프라 예외의 영문 원문이 팝업에 그대로 노출되지 않도록)
*/
@ExceptionHandler(value = Exception.class)
public ResponseEntity<ResponseDTO> handleUnknownxception(HttpServletRequest request, Exception ex) {
ex.printStackTrace();
ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage());
log.error("처리되지 않은 예외 - uri={}", request.getRequestURI(), ex);
ResponseDTO response = new ResponseDTO(500, "500", UserErrorMessageResolver.resolveAsHtml(ex));
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
@@ -0,0 +1,238 @@
package com.eactive.apim.portal.common.exception;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.transaction.CannotCreateTransactionException;
import org.springframework.transaction.TransactionException;
/**
* 예외를 사용자에게 보여줄 안내 문구로 변환한다.
*
* <p>JTA 롤백/DB 락/타임아웃 같은 인프라 예외는 원문(예: {@code "JTA transaction unexpectedly rolled back
* (maybe due to a timeout); nested exception is javax.transaction.RollbackException"})이 그대로
* 팝업·에러 화면에 노출되면 사용자가 이해할 수 없고 내부 구조까지 드러난다. 이 클래스가 정해진 한글 문구로 치환한다.
*
* <p>서비스 코드가 의도적으로 던진 한글 안내문(예: {@code IllegalStateException("이미 초대가 진행 중입니다.")})은
* 그대로 유지한다. 판단은 {@link #looksUserFacing(String)} 의 휴리스틱(한글 포함 + 기술 토큰 없음)을 따른다.
*
* <p>반환 문구는 평문이며 줄바꿈은 {@code \n} 이다. HTML 팝업으로 내려줄 때는 {@link #toHtml(String)} 을 쓴다.
*/
public final class UserErrorMessageResolver {
/** 원인을 특정할 수 없을 때의 기본 문구. */
public static final String DEFAULT_MESSAGE =
"요청을 처리하는 중 오류가 발생했습니다.\n잠시 후 다시 시도해 주세요.";
private static final String ROLLBACK_MESSAGE =
"요청 처리가 정상적으로 끝나지 않아 변경 내용이 저장되지 않았습니다.\n잠시 후 다시 시도해 주세요. 같은 문제가 반복되면 관리자에게 문의해 주세요.";
private static final String TIMEOUT_MESSAGE =
"처리 시간이 초과되어 요청이 취소되었습니다.\n잠시 후 다시 시도해 주세요.";
private static final String CONFLICT_MESSAGE =
"이미 등록된 정보이거나 다른 정보와 충돌하여 저장할 수 없습니다.\n입력 내용을 확인해 주세요.";
private static final String LOCK_MESSAGE =
"다른 사용자가 동일한 정보를 변경하고 있습니다.\n잠시 후 다시 시도해 주세요.";
private static final String STALE_MESSAGE =
"다른 사용자가 먼저 정보를 변경했습니다.\n화면을 새로 고친 뒤 다시 시도해 주세요.";
private static final String CONNECTION_MESSAGE =
"시스템 연결이 원활하지 않아 요청을 처리하지 못했습니다.\n잠시 후 다시 시도해 주세요.";
/** 원문 노출을 막아야 하는 기술 토큰. 메시지에 하나라도 있으면 사용자 안내문으로 보지 않는다. */
private static final String[] TECHNICAL_TOKENS = {
"exception", "Exception", "rollback", "Rollback", "transaction", "Transaction",
"SQL", "ORA-", "JTA", "JDBC", "Hibernate", "hibernate", "com.eactive", "org.springframework",
"javax.", "java.", "oracle.", "at com.", "Caused by", "null pointer", "NullPointer",
"constraint", "Constraint", "statement", "Statement", "SocketTimeout", "Connection"
};
private UserErrorMessageResolver() {
}
/**
* 예외에서 사용자 안내 문구를 만든다.
*
* @param ex 발생한 예외 (null 허용)
* @return 사용자에게 보여줄 평문 문구. 줄바꿈은 {@code \n}
*/
public static String resolve(Throwable ex) {
if (ex == null) {
return DEFAULT_MESSAGE;
}
String infraMessage = resolveInfrastructureMessage(ex);
if (infraMessage != null) {
return infraMessage;
}
// 서비스가 의도적으로 던진 한글 안내문은 그대로 전달
String original = ex.getMessage();
if (looksUserFacing(original)) {
return original;
}
return DEFAULT_MESSAGE;
}
/** {@link #resolve(Throwable)} 결과를 팝업(HTML)용으로 변환한다. */
public static String resolveAsHtml(Throwable ex) {
return toHtml(resolve(ex));
}
/** 평문 줄바꿈을 {@code <br>} 로 바꾼다. */
public static String toHtml(String plainMessage) {
if (plainMessage == null) {
return null;
}
return plainMessage.replace("\n", "<br>");
}
/**
* 트랜잭션/DB/연결 계열 인프라 예외인지 원인 체인을 따라가며 판별한다.
*
* @return 해당 문구, 인프라 예외가 아니면 null
*/
private static String resolveInfrastructureMessage(Throwable ex) {
String chainText = causeChainText(ex);
// 1. 데이터 충돌(유니크/FK/NOT NULL) — 롤백 판정보다 먼저: 롤백 예외가 이를 감싸고 있어도 원인이 더 구체적이다.
if (hasType(ex, DataIntegrityViolationException.class)
|| containsAny(chainText, "org.hibernate.exception.ConstraintViolationException",
"SQLIntegrityConstraintViolationException",
"ORA-00001", "ORA-01400", "ORA-02291", "ORA-02292", "ORA-12899")) {
return CONFLICT_MESSAGE;
}
// 2. 낙관적 락 충돌
if (hasType(ex, OptimisticLockingFailureException.class)
|| containsAny(chainText, "OptimisticLockException", "StaleObjectStateException", "StaleStateException")) {
return STALE_MESSAGE;
}
// 3. 비관적 락 / 데드락
if (hasType(ex, PessimisticLockingFailureException.class)
|| hasType(ex, CannotAcquireLockException.class)
|| containsAny(chainText, "ORA-00060", "ORA-02049", "ORA-00054", "deadlock")) {
return LOCK_MESSAGE;
}
// 4. 타임아웃 (쿼리/소켓/사용자 취소)
if (hasType(ex, QueryTimeoutException.class)
|| containsAny(chainText, "SocketTimeoutException", "QueryTimeoutException", "ORA-01013")) {
return TIMEOUT_MESSAGE;
}
// 5. 연결 실패
if (hasType(ex, DataAccessResourceFailureException.class)
|| hasType(ex, CannotCreateTransactionException.class)
|| containsAny(chainText, "SQLRecoverableException", "ConnectException", "UnknownHostException",
"ORA-03113", "ORA-03114", "ORA-12541", "ORA-12170")) {
return CONNECTION_MESSAGE;
}
// 6. JTA 롤백 계열 — 원인을 특정하지 못한 트랜잭션 실패
if (hasType(ex, TransactionException.class)
|| containsAny(chainText, "RollbackException", "HeuristicMixedException", "HeuristicRollbackException",
"rolled back", "rollback only")) {
// "Transaction set to rollback only" 은 내부 예외가 삼켜진 경우다. Spring 원문에 "maybe due to a
// timeout" 이 붙어 있어도 실제 타임아웃이 아니므로 타임아웃 문구를 쓰지 않는다.
if (!containsAny(chainText, "rollback only")
&& containsAny(chainText, "timeout", "timed out")) {
return TIMEOUT_MESSAGE;
}
return ROLLBACK_MESSAGE;
}
return null;
}
/**
* 원문을 그대로 사용자에게 보여줘도 되는 안내문인지 판단한다.
* 한글이 포함되고 기술 토큰이 없어야 한다.
*/
private static boolean looksUserFacing(String message) {
if (message == null) {
return false;
}
String trimmed = message.trim();
if (trimmed.isEmpty() || trimmed.length() > 200) {
return false;
}
if (!containsHangul(trimmed)) {
return false;
}
for (String token : TECHNICAL_TOKENS) {
if (trimmed.contains(token)) {
return false;
}
}
return true;
}
private static boolean containsHangul(String text) {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c >= 0xAC00 && c <= 0xD7A3) { // 한글 음절
return true;
}
if (c >= 0x1100 && c <= 0x11FF) { // 한글 자모
return true;
}
}
return false;
}
/** 원인 체인의 클래스명 + 메시지를 한 문자열로 모은다(순환 참조 방지). */
private static String causeChainText(Throwable ex) {
StringBuilder sb = new StringBuilder();
Throwable current = ex;
int depth = 0;
while (current != null && depth < 10) {
sb.append(current.getClass().getName());
if (current.getMessage() != null) {
sb.append(' ').append(current.getMessage());
}
sb.append('\n');
Throwable cause = current.getCause();
if (cause == current) {
break;
}
current = cause;
depth++;
}
return sb.toString();
}
private static boolean hasType(Throwable ex, Class<? extends Throwable> type) {
Throwable current = ex;
int depth = 0;
while (current != null && depth < 10) {
if (type.isInstance(current)) {
return true;
}
Throwable cause = current.getCause();
if (cause == current) {
return false;
}
current = cause;
depth++;
}
return false;
}
private static boolean containsAny(String text, String... keywords) {
for (String keyword : keywords) {
if (text.contains(keyword)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,44 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 로그인 실패 계정 잠금 임계 횟수를 DB(PortalProperty)에서 조회한다.
*
* <p>PTL_PROPERTY (group={@code Portal}, name={@code login.failure.lock.count}) 값으로 제어한다.
* 값이 없으면 기본값 {@value #DEFAULT_LOCK_COUNT}로 자동 생성되고, 숫자가 아니거나
* 0 이하이면 기본값으로 동작한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginLockPolicy {
private static final String GROUP = "Portal";
private static final String NAME = "login.failure.lock.count";
/** 기본 잠금 임계 횟수 (프로퍼티 미존재/파싱 실패 시) */
public static final int DEFAULT_LOCK_COUNT = 5;
private final PortalPropertyService portalPropertyService;
/** 연속 로그인 실패가 이 값 이상이면 계정을 잠근다. */
public int lockCount() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME, String.valueOf(DEFAULT_LOCK_COUNT),
"로그인 연속 실패 계정 잠금 임계 횟수 (이 값 이상 실패 시 잠금)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed > 0) {
return parsed;
}
log.warn("login.failure.lock.count 값이 0 이하({}) - 기본값 {} 사용", parsed, DEFAULT_LOCK_COUNT);
} catch (NumberFormatException e) {
log.warn("login.failure.lock.count 값이 숫자가 아님('{}') - 기본값 {} 사용", raw, DEFAULT_LOCK_COUNT);
}
return DEFAULT_LOCK_COUNT;
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.common.validator;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator;
@@ -72,24 +73,12 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
if (containsLoginIdLocalPart(password, loginId)) {
return false;
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
if (containsMobileSegment(password, mobileNumber)) {
return false;
}
// 공백 체크
@@ -129,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return true;
}
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
public static boolean containsLoginIdLocalPart(String password, String loginId) {
if (password == null || loginId == null || loginId.isEmpty()) {
return false;
}
String username = loginId.split("@")[0].toUpperCase();
return !username.isEmpty() && password.toUpperCase().contains(username);
}
/**
* 휴대전화 번호의 하이픈 세그먼트(010/1234/5678)가 비밀번호에 포함되는지.
* DB 에 하이픈 없이 저장된 legacy 값도 잡도록 정규형으로 변환 후 분리한다.
*/
public static boolean containsMobileSegment(String password, String mobileNumber) {
if (password == null || mobileNumber == null || mobileNumber.isEmpty()) {
return false;
}
String tmpPw = password.toUpperCase();
String[] mobileParts = PhoneNumberUtil.normalize(mobileNumber).split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return true;
}
}
return false;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
@@ -47,14 +48,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
private final PortalUserRepository portalUserRepository;
private final PortalUserLogService userLogService;
private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy;
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
PortalUserLogService userLogService,
MessageHandlerService messageHandlerService) {
MessageHandlerService messageHandlerService,
LoginLockPolicy loginLockPolicy) {
this.portalUserRepository = portalUserRepository;
this.userLogService = userLogService;
this.messageHandlerService = messageHandlerService;
this.loginLockPolicy = loginLockPolicy;
}
@Override
@@ -73,15 +77,16 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
int lockCount = loginLockPolicy.lockCount();
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
if (user.getLoginFailureCount() >= 5) {
if (user.getLoginFailureCount() >= lockCount) {
user.setAccountLockYn("Y");
// 계정 잠금 알림
messageHandlerService.publishEvent(
MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user),
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
}
portalUserRepository.save(user);
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
private final PortalUserAuthService portalUserAuthService;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy;
@Override
@Transactional(noRollbackFor = {AuthenticationException.class})
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
if (!user.isAccountNonLocked()) {
if (user.getLoginFailureCount() >= 5) {
if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
}
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
private final Environment environment;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
com.eactive.apim.portal.djb.menu.MenuService menuService) {
this.environment = environment;
this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties;
this.menuService = menuService;
}
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/auth/2fa/**");
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/internal/**", "/auth/2fa/**",
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
}
@Bean
@@ -1,13 +1,11 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Data
@Component
@@ -25,8 +23,6 @@ public class PortalProperties {
private String authVirtualCode = "";
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties();
/**
@@ -1,6 +1,5 @@
package com.eactive.apim.portal.custom.config;
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return bcryptEncoder.matches(rawPassword, encodedPassword);
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
// return bcryptEncoder.matches(rawPassword, bcryptHash);
}
}
@@ -0,0 +1,211 @@
package com.eactive.apim.portal.djb.apistatus.controller;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusSupport;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusUptimeService;
import com.eactive.apim.portal.djb.apistatus.service.MyApiStatusQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 개발자포탈 API Status 화면.
*
* <p>장애/점검 데이터는 관리자 공지사항(PTL_NOTICE + DJB_APISTATUS_INCIDENT)과
* 자동 탐지(eapim-admin ApiStatusDetectionService)가 채운다. 본 컨트롤러는 읽기 전용이다.</p>
*
* <p>비로그인 사용자도 모두 조회 가능하며, "내 API 현황"만 로그인을 요구한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("/apistatus")
@RequiredArgsConstructor
public class ApiStatusController {
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
private static final int MAX_PAGE_SIZE = 50;
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
private static final int MAX_STATUS_BATCH = 100;
private final ApiStatusQueryService apiStatusQueryService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusIssueHistoryService issueHistoryService;
private final MyApiStatusQueryService myApiStatusQueryService;
private final ApiStatusCatalogService catalogService;
private final ApiCurrentStatusService apiCurrentStatusService;
/** P1 - API Status 메인 */
@GetMapping
public ModelAndView index() {
ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", catalogService.getWindowDays());
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
mav.addObject("lastFireAt", lastFireAt);
mav.addObject("lastFireRelative",
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
return mav;
}
/**
* P8 - 전체 이슈 이력. 날짜를 지정하지 않으면 조회 기간(90일) 전체를 본다.
* 날짜 선택 가능 범위는 서버 기준 일자로 내려 클라이언트 timezone 차이를 없앤다.
*/
@GetMapping("/issues")
public ModelAndView issues(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
LocalDate today = ApiStatusSupport.now().toLocalDate();
int windowDays = catalogService.getWindowDays();
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
mav.addObject("windowDays", windowDays);
mav.addObject("today", today);
mav.addObject("minDate", today.minusDays(windowDays - 1L));
mav.addObject("selectedDate", date);
mav.addObject("selectedApiId", apiId);
mav.addObject("selectedKind", kind);
return mav;
}
/** 이슈 이력 API 필터 - 현재 사용자가 조회 가능한 API 목록 */
@GetMapping("/apis.json")
@ResponseBody
public List<ApiOptionDTO> selectableApis() {
return catalogService.getSelectableApis();
}
/** P2 - 90일 가동률 */
@GetMapping("/uptime.json")
@ResponseBody
public List<DailyStatDTO> uptime(
@RequestParam(value = "days", defaultValue = "0") int days) {
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
}
/** P3 - 진행 중 장애 */
@GetMapping("/active.json")
@ResponseBody
public List<ActiveIncidentDTO> active() {
return apiStatusQueryService.getActiveIncidents();
}
/** P4 - 내 API 현황 (로그인 필수) */
@GetMapping("/my-apis.json")
@ResponseBody
public ResponseEntity<List<MyApiStatusDTO>> myApis() {
if (!SecurityUtil.isAuthenticated()) {
return new ResponseEntity<>(Collections.emptyList(), HttpStatus.UNAUTHORIZED);
}
return ResponseEntity.ok(myApiStatusQueryService.getMyApiStatuses());
}
/** P5 - 예정/진행 중 점검 */
@GetMapping("/maintenance.json")
@ResponseBody
public List<MaintenanceCardDTO> maintenance() {
return apiStatusQueryService.getOngoingMaintenance();
}
/** P6 - 지난 이슈 (종결) */
@GetMapping("/recent-issues.json")
@ResponseBody
public List<PastIssueCardDTO> recentIssues(
@RequestParam(value = "size", defaultValue = "" + DEFAULT_RECENT_ISSUE_SIZE) int size) {
return apiStatusQueryService.getRecentClosedIssues(size);
}
/** P7 - 이슈 상세 */
@GetMapping("/incident/{incidentId}")
@ResponseBody
public ResponseEntity<PastIssueCardDTO> incidentDetail(@PathVariable Long incidentId) {
return apiStatusQueryService.getIssueDetail(incidentId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** P9 - 90일 이슈 일자 인덱스 */
@GetMapping("/issues/dates.json")
@ResponseBody
public List<IssueDateEntryDTO> issueDates(
@RequestParam(value = "days", defaultValue = "0") int days,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
}
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
@GetMapping("/issues/list.json")
@ResponseBody
public Page<PastIssueCardDTO> issueList(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
int safePage = Math.max(page, 0);
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
}
/**
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
*
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
*/
@GetMapping("/current.json")
@ResponseBody
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
return apiCurrentStatusService.getStatus(apiId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.badRequest().build());
}
/**
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
*/
@GetMapping("/current-list.json")
@ResponseBody
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
}
}
@@ -0,0 +1,34 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 진행 중 장애 카드 (P3)
*/
@Data
public class ActiveIncidentDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 이슈 영향 API
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AffectedApiDTO {
private String apiId;
private String apiName;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime recoveredAt;
}
@@ -0,0 +1,53 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* API 한 건의 현재 상태 (정상/점검/지연/장애).
*
* <p>게시된 장애·점검 공지(PTL_NOTICE + DJB_APISTATUS_INCIDENT)만 근거로 한다.
* 미게시(초안/USE_YN='N') 건은 다른 API Status 화면과 동일하게 보이지 않는다.</p>
*/
@Data
public class ApiCurrentStatusDTO {
private String apiId;
/** 공개 API 목록(PTL_API_SPEC_INFO, DISPLAY_YN='Y')에 없으면 null */
private String apiName;
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String currentStatus;
/** 정상 / 지연 / 장애 / 점검 */
private String currentStatusLabel;
/** 현재 상태의 근거가 된 이슈. 정상이면 null */
private Long activeIncidentId;
/** INCIDENT / MAINTENANCE */
private String activeIncidentKind;
private String activeIncidentTitle;
/** 현재 상태가 시작된 시각 (근거 이슈의 시작 시각) */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime statusSince;
/** 점검 종료 예정 시각. 장애이거나 미정이면 null */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime expectedEndAt;
/** 조회 기간 내 마지막 장애 발생 시각 */
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastIncidentAt;
/** 조회 기간 가동률 (0.0 ~ 1.0). 상태만 계산하는 경로에서는 채우지 않는다 */
private Double uptimeRatio;
/** 가동률·최근 장애 집계 기간(일) */
private int windowDays;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 이슈 이력 API 필터 옵션. 화면에는 API 명만 노출하고 apiId 는 조회 파라미터로만 쓴다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiOptionDTO {
private String apiId;
private String apiName;
/** 소속 API 그룹명 (오픈 API 목록과 동일 기준) */
private String groupName;
}
@@ -0,0 +1,50 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 90일 가동률 일별 집계 (P2)
*/
@Data
public class DailyStatDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate statDate;
/** 0.0000 ~ 1.0000 */
private double uptimeRatio;
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
private long incidentMinutes;
/** 그 중 지연(자동 탐지) 분 */
private long delayMinutes;
private long maintenanceMinutes;
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String status;
private List<IssueRefDTO> issues = new ArrayList<>();
@Data
public static class IssueRefDTO {
private Long incidentId;
private String kind;
private String title;
public IssueRefDTO() {
}
public IssueRefDTO(Long incidentId, String kind, String title) {
this.incidentId = incidentId;
this.kind = kind;
this.title = title;
}
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 이슈 이력 페이지의 90일 인덱스바 1칸 (P9)
*/
@Data
public class IssueDateEntryDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate date;
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
private List<String> kinds = new ArrayList<>();
/** 장애 건수 (자동 탐지 지연 제외) */
private int incCount;
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
private int dlyCount;
private int mntCount;
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 점검 사항 카드 (P5). 상태 관리를 하지 않으므로(ADR-F15) 진행 단계는 화면이 현재 시각과 비교해 부여한다.
*/
@Data
public class MaintenanceCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime registeredAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastModifiedAt;
}
@@ -0,0 +1,25 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 내가 이용 중인 API 의 현재 상태 (P4)
*/
@Data
public class MyApiStatusDTO {
private String apiId;
private String apiName;
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String currentStatus;
private String currentStatusLabel;
private Long activeIncidentId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastIncidentAt;
private double uptime90d;
}
@@ -0,0 +1,39 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 지난 이슈 카드 (P6, P10). 장애는 타임라인 전체, 점검은 요약만 담는다.
*/
@Data
public class PastIssueCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 장애 처리 타임라인 1건
*/
@Data
public class TimelineEntryDTO {
private Long timelineId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime eventAt;
private String stateAfter;
private String labelKo;
private String body;
private String authorType;
}
@@ -0,0 +1,118 @@
package com.eactive.apim.portal.djb.apistatus.repository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
*
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
* <ul>
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
* </ul>
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY";
String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE";
/** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */
String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = " + KIND_MAINTENANCE
+ " AND i.endAt IS NOT NULL AND i.endAt < :now)) ";
/**
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind IN :kinds"
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now);
/**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
*/
@Query(value = "SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION
+ " ORDER BY i.startedAt DESC",
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
Pageable pageable);
/**
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
*/
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
+ " WHERE " + VISIBLE
+ " AND a.incidentId = i.incidentId"
+ " AND a.apiId = :apiId"
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("apiId") String apiId);
/**
* 공개 상세 (P7)
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
}
@@ -0,0 +1,274 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* API 별 현재 상태(정상/점검/지연/장애) 조회.
*
* <p>"내 API 현황"(P4)과 API 상세 화면이 이 서비스를 공유해 판정 기준을 하나로 유지한다.
* 근거는 <b>게시된</b> 장애·점검 공지뿐이며(PTL_NOTICE.USE_YN='Y' + DRAFT_YN='N'),
* 미게시 건은 다른 API Status 화면과 마찬가지로 반영하지 않는다.</p>
*
* <p>판정 규칙 (심각한 쪽 우선)
* <ul>
* <li>진행 중 장애 → 장애(OUTAGE). 단 모니터링 상태이거나 자동 탐지 지연 건이면 지연(DEGRADED)</li>
* <li>이미 시작된 점검 → 점검(MAINTENANCE)</li>
* <li>해당 없음 → 정상(NORMAL)</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiCurrentStatusService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
/** API 한 건의 현재 상태. apiId 가 비어 있으면 empty */
public Optional<ApiCurrentStatusDTO> getStatus(String apiId) {
if (StringUtils.isBlank(apiId)) {
return Optional.empty();
}
return getStatuses(Collections.singletonList(apiId)).stream().findFirst();
}
/**
* 여러 API 의 현재 상태 (API 명·가동률 포함). 요청 순서를 유지하며 중복 apiId 는 한 번만 반환한다.
*/
public List<ApiCurrentStatusDTO> getStatuses(Collection<String> apiIds) {
Set<String> ids = normalize(apiIds);
if (ids.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = resolveStatuses(ids);
Map<String, String> names = resolveApiNames(ids);
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(ids, catalogService.getWindowDays());
List<ApiCurrentStatusDTO> result = new ArrayList<>(ids.size());
for (String apiId : ids) {
ApiCurrentStatusDTO dto = statuses.get(apiId);
dto.setApiName(names.get(apiId));
dto.setUptimeRatio(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
return result;
}
/**
* 상태만 계산한다 (API 명·가동률 제외). 대상 API 를 이미 알고 있고 이름·가동률을
* 따로 채우는 호출자(P4 내 API 현황)를 위한 경량 경로다.
*/
public Map<String, ApiCurrentStatusDTO> resolveStatuses(Set<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
LocalDateTime now = ApiStatusSupport.now();
int windowDays = catalogService.getWindowDays();
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(apiIds);
Map<String, DjbApistatusIncident> maintenanceByApi = mapStartedMaintenance(apiIds, now);
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(apiIds, now, windowDays);
Map<String, ApiCurrentStatusDTO> result = new LinkedHashMap<>();
for (String apiId : apiIds) {
DjbApistatusIncident open = openByApi.get(apiId);
DjbApistatusIncident maintenance = maintenanceByApi.get(apiId);
ApiCurrentStatusDTO dto = new ApiCurrentStatusDTO();
dto.setApiId(apiId);
dto.setCurrentStatus(resolveStatus(open, maintenance != null));
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
dto.setWindowDays(windowDays);
// 장애가 점검보다 심각하므로 근거 이슈도 장애를 우선한다
DjbApistatusIncident active = open != null ? open : maintenance;
if (active != null) {
dto.setActiveIncidentId(active.getIncidentId());
dto.setActiveIncidentKind(active.getKind() == null ? null : active.getKind().name());
dto.setActiveIncidentTitle(active.getTitle());
dto.setStatusSince(active.getStartedAt());
dto.setExpectedEndAt(active.getEndAt());
}
result.put(apiId, dto);
}
return result;
}
/** 빈 값·중복 제거. 요청 순서는 유지한다 */
private Set<String> normalize(Collection<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptySet();
}
return apiIds.stream()
.filter(StringUtils::isNotBlank)
.map(StringUtils::trim)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* API 명은 공개 목록(DISPLAY_YN='Y')에서만 가져온다. 비공개 API 의 이름이 새어나가지 않게 한다.
*/
private Map<String, String> resolveApiNames(Set<String> apiIds) {
List<ApiSpecInfo> specs =
apiSpecInfoRepository.findAllByDisplayYnAndApiIdIn("Y", new ArrayList<>(apiIds));
Map<String, String> names = new HashMap<>();
for (ApiSpecInfo spec : specs) {
names.put(spec.getApiId(), StringUtils.defaultIfBlank(spec.getApiName(), spec.getApiId()));
}
return names;
}
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = openIncidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
// 같은 API 에 여러 장애가 열려 있으면 더 심각한(장애 > 지연, 조사중 > 모니터링) 쪽을 우선한다
if (previous == null || severity(incident) < severity(previous)) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList());
if (maintenances.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = maintenances.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
if (previous == null
|| (incident.getStartedAt() != null && previous.getStartedAt() != null
&& incident.getStartedAt().isBefore(previous.getStartedAt()))) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 조회 기간 내 마지막 장애·지연 발생 시각 */
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, LocalDateTime> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
LocalDateTime previous = result.get(api.getApiId());
if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
result.put(api.getApiId(), startedAt);
}
}
return result;
}
/**
* 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다.
*
* <p>KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라
* 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.</p>
*/
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
if (open != null) {
boolean degraded = open.getKind() == IncidentKind.DELAY
|| open.getState() == IncidentState.MONITORING;
return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
}
if (underMaintenance) {
return ApiStatusSupport.STATUS_MAINTENANCE;
}
return ApiStatusSupport.STATUS_NORMAL;
}
/**
* 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각).
* 지연은 장애보다 뒤로 민다.
*/
private int severity(DjbApistatusIncident incident) {
if (incident == null) {
return 99;
}
int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0;
IncidentState state = incident.getState();
if (state == null) {
return base + 9;
}
switch (state) {
case INVESTIGATING: return base;
case IDENTIFIED: return base + 1;
// 모니터링 단계는 지연으로 표기되므로 같은 장애라도 뒤로 민다
case MONITORING: return base + 12;
default: return base + 8;
}
}
}
@@ -0,0 +1,283 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.AffectedApiDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.apps.community.notice.repository.PortalNoticeRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 장애/점검 엔티티 → 화면 DTO 변환. 영향 API·타임라인은 한 번에 모아 읽어 1+N 조회를 피한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusAssembler {
private static final int RECENT_TIMELINE_SIZE = 3;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository timelineRepository;
private final PortalNoticeRepository portalNoticeRepository;
private final ApiStatusCatalogService catalogService;
/**
* 영향 인터페이스를 "개발자포탈에 게시된 API" 와 그 외 GW 인터페이스로 가른 결과.
* 게시된 것만 개별 노출하고 나머지는 건수로만 알린다.
*/
@Getter
@RequiredArgsConstructor
public static class VisibleApis {
private final List<AffectedApiDTO> visible;
private final int hiddenCount;
}
/**
* 이슈 영향 인터페이스에서 현재 사용자에게 공개된 API 만 남긴다.
*
* <p>이름은 이슈에 캐시된 EAI 서비스명이 아니라 포털 노출명(PTL_API_SPEC_INFO)을 쓴다 -
* 같은 API 가 화면마다 다른 이름으로 보이지 않게 한다.</p>
*/
private VisibleApis splitByVisibility(List<AffectedApiDTO> apis, Map<String, String> visibleNames) {
if (apis == null || apis.isEmpty()) {
return new VisibleApis(Collections.emptyList(), 0);
}
List<AffectedApiDTO> visible = new ArrayList<>();
int hidden = 0;
for (AffectedApiDTO api : apis) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hidden++;
continue;
}
visible.add(new AffectedApiDTO(api.getApiId(), publishedName, api.getRecoveredAt()));
}
return new VisibleApis(visible, hidden);
}
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<AffectedApiDTO>> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds)) {
result.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
.add(new AffectedApiDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getRecoveredAt()));
}
return result;
}
/**
* 공개 타임라인. 최신순으로 담는다.
*/
public Map<Long, List<TimelineEntryDTO>> loadTimelines(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<TimelineEntryDTO>> result = new HashMap<>();
for (DjbApistatusIncidentTimeline timeline :
timelineRepository.findByIncidentIdInAndVisibleYnOrderByEventAtDesc(incidentIds, "Y")) {
result.computeIfAbsent(timeline.getIncidentId(), key -> new ArrayList<>())
.add(toTimelineEntry(timeline));
}
return result;
}
/**
* 이슈에 연결된 공지. 게시 중(USE_YN='Y')인 것만 담는다.
*
* <p>조회 자체의 공개 조건({@code ApiStatusIncidentQueryRepository.VISIBLE})과 같은 기준이라
* 여기서 빠지는 건은 공지가 삭제된 고아 행뿐이다.</p>
*/
public Map<String, PortalNotice> loadNotices(Collection<String> noticeIds) {
if (noticeIds == null || noticeIds.isEmpty()) {
return Collections.emptyMap();
}
List<String> ids = noticeIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyMap();
}
Map<String, PortalNotice> result = new HashMap<>();
for (PortalNotice notice : portalNoticeRepository.findByIdInAndUseYn(ids, "Y")) {
result.put(notice.getId(), notice);
}
return result;
}
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<ActiveIncidentDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
ActiveIncidentDTO dto = new ActiveIncidentDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
? new ArrayList<>(all.subList(0, RECENT_TIMELINE_SIZE)) : all);
result.add(dto);
}
return result;
}
public List<MaintenanceCardDTO> toMaintenanceCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<MaintenanceCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
MaintenanceCardDTO dto = new MaintenanceCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setRegisteredAt(incident.getCreatedDate());
dto.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
}
return result;
}
/**
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
*/
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<PastIssueCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
PastIssueCardDTO dto = new PastIssueCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
VisibleApis affected = splitByVisibility(
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
dto.setImpactedApis(affected.getVisible());
dto.setHiddenApiCount(affected.getHiddenCount());
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
result.add(dto);
}
return result;
}
private TimelineEntryDTO toTimelineEntry(DjbApistatusIncidentTimeline timeline) {
TimelineEntryDTO dto = new TimelineEntryDTO();
dto.setTimelineId(timeline.getTimelineId());
dto.setEventAt(timeline.getEventAt());
dto.setStateAfter(timeline.getStateAfter() == null ? null : timeline.getStateAfter().name());
dto.setLabelKo(ApiStatusSupport.stateLabel(timeline.getStateAfter()));
dto.setBody(timeline.getBody());
dto.setAuthorType(timeline.getAuthorType());
return dto;
}
private List<Long> incidentIds(List<DjbApistatusIncident> incidents) {
return incidents.stream().map(DjbApistatusIncident::getIncidentId).collect(Collectors.toList());
}
}
@@ -0,0 +1,198 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.gateway.data.statistics.repository.GwApiStatusRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusCatalogService {
public static final String PROPERTY_GROUP = "Portal";
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
/** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
private static final int MIN_WINDOW_DAYS = 1;
private static final int MAX_WINDOW_DAYS = 365;
private final ApiSearchFacade apiSearchFacade;
private final PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/**
* 이슈 이력 필터용 API 목록.
*
* <p>"오픈 API" 목록 화면과 <b>같은 조회 경로</b>({@link ApiSearchFacade#searchApis})를 쓴다.
* 즉 API 그룹(AGWAPP.API_GROUP + API_GROUP_API)에 편성된 API 중
* PTL_API_SPEC_INFO 에 스펙이 있고 현재 사용자에게 공개된 것만 나온다.</p>
*/
@SuppressWarnings("unchecked")
public List<ApiOptionDTO> getSelectableApis() {
Object apis = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
if (!(apis instanceof List)) {
return Collections.emptyList();
}
return ((List<ApiSpecInfoDto>) apis).stream()
.filter(api -> StringUtils.isNotBlank(api.getApiId()))
.map(api -> new ApiOptionDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getApiGroupName()))
.sorted(Comparator.comparing(ApiOptionDTO::getApiName,
Comparator.nullsLast(Comparator.naturalOrder())))
.collect(Collectors.toList());
}
/**
* 현재 사용자에게 공개된 API 의 {API ID → 노출명} 맵.
*
* <p>GW 인터페이스는 전부 이슈 데이터(DJB_APISTATUS_INCIDENT_API)에 들어오지만
* 개발자포탈에 게시되는 것은 그 일부(= API)뿐이다. 화면은 이 맵에 있는 것만
* 개별 이름으로 노출하고 나머지는 건수로 묶는다.</p>
*
* <p>{@link #getSelectableApis()} 와 같은 경로라 역할·소속에 따른 공개 범위가 그대로 반영된다.</p>
*/
public Map<String, String> getVisibleApiNames() {
Map<String, String> names = new LinkedHashMap<>();
for (ApiOptionDTO api : getSelectableApis()) {
names.put(api.getApiId(), api.getApiName());
}
return names;
}
/**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
*
* <p>Job 이름은 PTL_PROPERTY({@code Portal} / {@code djb.apistatus.monitor.job-name})로
* 바꿀 수 있으며, 최초 접근 시 기본값으로 row 가 자동 생성된다.
* Quartz 메타(QRTZ_TRIGGERS)는 EMSAPP 스키마라 EMS EntityManager 로 직접 읽는다.</p>
*
* <p>readOnly 를 풀어야 최초 접근 시 프로퍼티 row 자동 생성(INSERT)이 가능하다.</p>
*/
@Transactional
public LocalDateTime getLastMonitorFireAt() {
try {
String jobName = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_MONITOR_JOB_NAME, DEFAULT_MONITOR_JOB_NAME,
"API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME)");
if (StringUtils.isBlank(jobName)) {
jobName = DEFAULT_MONITOR_JOB_NAME;
}
Object fireTime = entityManager.createNativeQuery(
"SELECT MAX(PREV_FIRE_TIME) FROM QRTZ_TRIGGERS WHERE JOB_NAME = :jobName")
.setParameter("jobName", jobName)
.getSingleResult();
if (!(fireTime instanceof Number)) {
return null;
}
long epochMillis = ((Number) fireTime).longValue();
if (epochMillis <= 0) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ApiStatusSupport.ZONE);
} catch (Exception e) {
// 스케줄 메타 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 모니터링 실행 시각 조회 실패", e);
return null;
}
}
/**
* 상태/이력 조회 기간(일). PTL_PROPERTY({@code Portal} / {@code djb.apistatus.window-days})로
* 조절하며 1~365 로 clamp 한다. 값이 이상하면 기본 90일.
*/
@Transactional
public int getWindowDays() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_WINDOW_DAYS,
String.valueOf(ApiStatusSupport.DEFAULT_WINDOW_DAYS),
"API Status 조회 기간(일). 가동률 바·이슈 인덱스·날짜 선택 범위에 적용 (1~365)");
int days = Integer.parseInt(StringUtils.trimToEmpty(value));
return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, days));
} catch (Exception e) {
log.warn("조회 기간 프로퍼티 해석 실패 - 기본값 {}일 사용", ApiStatusSupport.DEFAULT_WINDOW_DAYS, e);
return ApiStatusSupport.DEFAULT_WINDOW_DAYS;
}
}
/**
* OPEN API 목록(/apis) 카드에 현재 상태 태그를 노출할지 여부.
* PTL_PROPERTY({@code Portal} / {@code djb.apistatus.api-list-status-badge}) 로 켜고 끈다.
*
* <p>{@code true/Y/1} 이면 노출, {@code false/N/0} 이면 숨김. 값이 비었거나 이상하면 기본 노출.</p>
*/
@Transactional
public boolean isApiListStatusBadgeEnabled() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_API_LIST_STATUS_BADGE,
String.valueOf(DEFAULT_API_LIST_STATUS_BADGE),
"OPEN API 목록 카드에 현재 상태(정상/점검/지연/장애) 태그 노출 여부 (true/false)");
String normalized = StringUtils.trimToEmpty(value);
if (StringUtils.equalsAnyIgnoreCase(normalized, "false", "n", "0")) {
return false;
}
if (StringUtils.equalsAnyIgnoreCase(normalized, "true", "y", "1")) {
return true;
}
return DEFAULT_API_LIST_STATUS_BADGE;
} catch (Exception e) {
log.warn("API 목록 상태 태그 노출 프로퍼티 조회 실패 - 기본값 {} 사용", DEFAULT_API_LIST_STATUS_BADGE, e);
return DEFAULT_API_LIST_STATUS_BADGE;
}
}
/**
* API 상태가 마지막으로 <b>변경</b>된 시각 (AGWAPP.API_STATUS 최근 upsert).
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
*/
public LocalDateTime getLastStatusChangedAt() {
try {
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) {
log.warn("API 상태 변경 시각 조회 실패", e);
return null;
}
}
}
@@ -0,0 +1,159 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 전체 이슈 이력 페이지 조회 (ADR-F16/F17). 필터는 날짜와 API 두 축만 지원한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusIssueHistoryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/**
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
*/
public List<IssueDateEntryDTO> getIssueDates(int days, String apiId, String kind) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
LocalDateTime windowStart = from.atStartOfDay();
LocalDateTime windowEnd = today.plusDays(1).atStartOfDay();
List<DjbApistatusIncident> incidents = findOverlapping(windowStart, windowEnd, apiId, kind);
Map<LocalDate, IssueDateEntryDTO> byDate = new LinkedHashMap<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
IssueDateEntryDTO entry = new IssueDateEntryDTO();
entry.setDate(date);
byDate.put(date, entry);
}
for (DjbApistatusIncident incident : incidents) {
if (incident.getStartedAt() == null) {
continue;
}
LocalDate start = incident.getStartedAt().toLocalDate();
LocalDateTime effectiveEnd = incident.getEndAt() == null ? now : incident.getEndAt();
LocalDate end = effectiveEnd.toLocalDate();
LocalDate cursor = start.isBefore(from) ? from : start;
LocalDate last = end.isAfter(today) ? today : end;
while (!cursor.isAfter(last)) {
IssueDateEntryDTO entry = byDate.get(cursor);
if (entry != null) {
tally(entry, incident);
}
cursor = cursor.plusDays(1);
}
}
return new ArrayList<>(byDate.values());
}
/**
* 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
*/
private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
IncidentKind kind = incident.getKind();
if (kind == null) {
return;
}
switch (kind) {
case MAINTENANCE: entry.setMntCount(entry.getMntCount() + 1); break;
case DELAY: entry.setDlyCount(entry.getDlyCount() + 1); break;
default: entry.setIncCount(entry.getIncCount() + 1); break;
}
if (!entry.getKinds().contains(kind.name())) {
entry.getKinds().add(kind.name());
}
}
/**
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
*/
public Page<PastIssueCardDTO> getIssues(LocalDate date, String apiId, String kind, Pageable pageable) {
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime from;
LocalDateTime to;
if (date == null) {
LocalDate today = now.toLocalDate();
from = today.minusDays(catalogService.getWindowDays() - 1L).atStartOfDay();
to = today.plusDays(1).atStartOfDay();
} else {
from = date.atStartOfDay();
to = date.plusDays(1).atStartOfDay();
}
List<DjbApistatusIncident> incidents = findOverlapping(from, to, apiId, kind);
if (incidents.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int offset = (int) pageable.getOffset();
if (offset >= incidents.size()) {
return new PageImpl<>(Collections.emptyList(), pageable, incidents.size());
}
int end = Math.min(offset + pageable.getPageSize(), incidents.size());
List<PastIssueCardDTO> content = assembler.toPastIssueCards(incidents.subList(offset, end));
return new PageImpl<>(content, pageable, incidents.size());
}
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
return incidents;
}
return incidents.stream().filter(filter).collect(Collectors.toList());
}
/**
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
*/
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null;
}
try {
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -0,0 +1,59 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* API Status 메인 화면 조회 (진행 중 장애 / 점검 사항 / 지난 이슈 / 이슈 상세).
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
return assembler.toActiveIncidents(incidents, now);
}
/** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
return assembler.toMaintenanceCards(incidents);
}
/** P6 - 종결된 이슈 최근 N건 */
public List<PastIssueCardDTO> getRecentClosedIssues(int size) {
int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit))
.getContent();
return assembler.toPastIssueCards(incidents);
}
/** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId)
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
}
}
@@ -0,0 +1,132 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* API Status 조회 서비스 공통 상수/헬퍼.
*/
public final class ApiStatusSupport {
/** 일자 경계 기준 timezone (OQ-10) */
public static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
/** 90일 가동률/이력 인덱스 기본 조회 일수 */
public static final int DEFAULT_WINDOW_DAYS = 90;
/** 장애 종결 상태 */
public static final List<IncidentState> CLOSED_STATES =
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
public static final String STATUS_NORMAL = "NORMAL";
public static final String STATUS_DEGRADED = "DEGRADED";
public static final String STATUS_OUTAGE = "OUTAGE";
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
private ApiStatusSupport() {
}
public static LocalDateTime now() {
return LocalDateTime.now(ZONE);
}
/** 지연 건인지 여부 (장애와 구분) */
public static boolean isDelay(DjbApistatusIncident incident) {
return incident != null && incident.getKind() == IncidentKind.DELAY;
}
/** 이슈 종류에 대응하는 현재 상태 코드 */
public static String statusOf(IncidentKind kind) {
if (kind == null) {
return STATUS_NORMAL;
}
switch (kind) {
case INCIDENT: return STATUS_OUTAGE;
case DELAY: return STATUS_DEGRADED;
case MAINTENANCE: return STATUS_MAINTENANCE;
default: return STATUS_NORMAL;
}
}
/**
* 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
*/
public static int statusRank(String status) {
if (status == null) {
return 9;
}
switch (status) {
case STATUS_OUTAGE: return 0;
case STATUS_MAINTENANCE: return 1;
case STATUS_DEGRADED: return 2;
default: return 3;
}
}
public static String stateLabel(IncidentState state) {
if (state == null) {
return null;
}
switch (state) {
case INVESTIGATING: return "발생";
case IDENTIFIED: return "원인 확인";
case MONITORING: return "모니터링";
case RESOLVED: return "해소";
case CANCELED: return "취소";
default: return state.name();
}
}
public static String statusLabel(String status) {
if (status == null) {
return null;
}
switch (status) {
case STATUS_NORMAL: return "정상";
case STATUS_DEGRADED: return "지연";
case STATUS_OUTAGE: return "장애";
case STATUS_MAINTENANCE: return "점검";
default: return status;
}
}
/**
* 두 시각 사이의 분. 종료가 없거나 역순이면 0.
*/
public static long minutesBetween(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)) {
return 0L;
}
return Duration.between(from, to).toMinutes();
}
/**
* "N분 전" 형태의 상대 시간 표기.
*/
public static String relativeTime(LocalDateTime past, LocalDateTime now) {
if (past == null || now == null) {
return null;
}
long minutes = Duration.between(past, now).toMinutes();
if (minutes < 1) {
return "방금 전";
}
if (minutes < 60) {
return minutes + "분 전";
}
long hours = minutes / 60;
if (hours < 24) {
long restMinutes = minutes % 60;
return restMinutes == 0 ? hours + "시간 전" : hours + "시간 " + restMinutes + "분 전";
}
return (hours / 24) + "일 전";
}
}
@@ -0,0 +1,236 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 90일 가동률 집계. 일별 캐시 테이블(DJB_APISTATUS_DAILY_STAT) 없이 이슈 구간을 그때그때 합산한다.
*
* <p>이슈 건수가 연 1천 건 미만이므로 90일 구간을 메모리에서 합산해도 부담이 없다.
* 같은 시간대에 겹치는 이슈는 구간 합집합으로 계산해 중복 차감을 막는다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
/** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
result.add(buildDailyStat(date, incidents, now));
}
return result;
}
/**
* API 별 90일 가동률. 장애(INCIDENT) 구간만 차감한다.
*/
public Map<String, Double> getApiUptimeRatios(Set<String> apiIds, int days) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
if (!incidents.isEmpty()) {
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
LocalDateTime start = max(incident.getStartedAt(), windowStart);
// 개별 API 복구 시각이 있으면 그 시점까지만 장애로 본다
LocalDateTime end = min(firstNonNull(api.getRecoveredAt(), incident.getEndAt(), now), now);
long minutes = ApiStatusSupport.minutesBetween(start, end);
if (minutes <= 0) {
continue;
}
intervalsByApi.computeIfAbsent(api.getApiId(), key -> new ArrayList<>())
.add(new long[]{toEpochMinute(start), toEpochMinute(end)});
}
}
long totalMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(windowStart, now));
Map<String, Double> ratios = new HashMap<>();
for (String apiId : apiIds) {
long down = unionMinutes(intervalsByApi.get(apiId));
double ratio = (double) (totalMinutes - Math.min(down, totalMinutes)) / totalMinutes;
ratios.put(apiId, round4(ratio));
}
return ratios;
}
private DailyStatDTO buildDailyStat(LocalDate date, List<DjbApistatusIncident> incidents, LocalDateTime now) {
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = min(date.plusDays(1).atStartOfDay(), now);
DailyStatDTO dto = new DailyStatDTO();
dto.setStatDate(date);
if (!dayEnd.isAfter(dayStart)) {
// 아직 시작되지 않은 날짜 (오늘 자정 직후 등)
dto.setUptimeRatio(1.0d);
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
return dto;
}
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
List<long[]> incidentIntervals = new ArrayList<>();
List<long[]> outageIntervals = new ArrayList<>();
List<long[]> delayIntervals = new ArrayList<>();
List<long[]> maintenanceIntervals = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DjbApistatusIncident incident : incidents) {
LocalDateTime start = max(incident.getStartedAt(), dayStart);
LocalDateTime end = min(firstNonNull(incident.getEndAt(), now), dayEnd);
if (!end.isAfter(start)) {
continue;
}
long[] interval = new long[]{toEpochMinute(start), toEpochMinute(end)};
if (incident.getKind() == IncidentKind.MAINTENANCE) {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
if (incident.getKind() == IncidentKind.DELAY) {
delayIntervals.add(interval);
} else {
outageIntervals.add(interval);
}
}
if (seen.add(incident.getIncidentId())) {
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
incident.getKind() == null ? null : incident.getKind().name(),
incident.getTitle()));
}
}
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
long incidentMinutes = unionMinutes(incidentIntervals);
long outageMinutes = unionMinutes(outageIntervals);
long delayMinutes = unionMinutes(delayIntervals);
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
dto.setIncidentMinutes(incidentMinutes);
dto.setDelayMinutes(delayMinutes);
dto.setMaintenanceMinutes(maintenanceMinutes);
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
if (outageMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
} else if (maintenanceMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
} else if (delayMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
} else {
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
}
return dto;
}
/**
* 겹치는 구간을 합쳐 총 분을 구한다.
*/
private long unionMinutes(List<long[]> intervals) {
if (intervals == null || intervals.isEmpty()) {
return 0L;
}
intervals.sort((left, right) -> Long.compare(left[0], right[0]));
long total = 0L;
long currentStart = intervals.get(0)[0];
long currentEnd = intervals.get(0)[1];
for (int index = 1; index < intervals.size(); index++) {
long[] interval = intervals.get(index);
if (interval[0] > currentEnd) {
total += currentEnd - currentStart;
currentStart = interval[0];
currentEnd = interval[1];
} else if (interval[1] > currentEnd) {
currentEnd = interval[1];
}
}
total += currentEnd - currentStart;
return total;
}
private long toEpochMinute(LocalDateTime dateTime) {
return dateTime.atZone(ApiStatusSupport.ZONE).toEpochSecond() / 60L;
}
private LocalDateTime max(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isAfter(right) ? left : right;
}
private LocalDateTime min(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isBefore(right) ? left : right;
}
private LocalDateTime firstNonNull(LocalDateTime... candidates) {
for (LocalDateTime candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
}
private double round4(double value) {
double clamped = Math.max(0.0d, Math.min(1.0d, value));
return Math.round(clamped * 10000.0d) / 10000.0d;
}
}
@@ -0,0 +1,94 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
*
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MyApiStatusQueryService {
private final CredentialRepository credentialRepository;
private final ApiCurrentStatusService apiCurrentStatusService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
public List<MyApiStatusDTO> getMyApiStatuses() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null || StringUtils.isBlank(user.getPortalOrg().getId())) {
return Collections.emptyList();
}
Map<String, String> myApis = collectMyApis(user.getPortalOrg().getId());
if (myApis.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
Map<String, Double> uptimes =
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
List<MyApiStatusDTO> result = new ArrayList<>();
for (Map.Entry<String, String> entry : myApis.entrySet()) {
String apiId = entry.getKey();
ApiCurrentStatusDTO status = statuses.get(apiId);
MyApiStatusDTO dto = new MyApiStatusDTO();
dto.setApiId(apiId);
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
dto.setApiName(entry.getValue());
dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
result.sort(Comparator
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
return result;
}
/**
* 기관이 보유한 앱들의 API 목록 (중복 제거, API 명 기준 정렬)
*/
private Map<String, String> collectMyApis(String orgId) {
Map<String, String> myApis = new TreeMap<>();
for (Credential credential : credentialRepository.findAllByOrgid(orgId)) {
if (credential.getApiList() == null) {
continue;
}
for (ApiSpecInfo api : credential.getApiList()) {
if (StringUtils.isBlank(api.getApiId())) {
continue;
}
myApis.put(api.getApiId(), StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()));
}
}
return myApis;
}
}
@@ -3,20 +3,13 @@ package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
*
* <p>역할 기반 알림 대상 조회는
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
*/
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -1,57 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommunityAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
if (admins == null || admins.isEmpty()) {
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
return;
}
for (UserInfo admin : admins) {
try {
MessageRecipient recipient = toRecipient(admin);
messageHandlerService.publishEvent(code, recipient, params);
} catch (Exception e) {
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
admin.getUserid(), code.name(), e);
}
}
} catch (Exception e) {
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
}
}
private MessageRecipient toRecipient(UserInfo admin) {
MessageRecipient r = new MessageRecipient();
r.setUsername(admin.getUsername());
r.setUserId(admin.getEmad());
r.setPhone(admin.getCphnno());
r.setMessengerId(admin.getUserid());
return r;
}
}
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -47,7 +48,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final CommunityAdminNotifier adminNotifier;
private final SwingNotifier swingNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@@ -107,7 +108,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
@@ -1,10 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbAdminRole {
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
public static final String PORTAL_ADMIN = "portal-admin";
private DjbAdminRole() {
}
}
@@ -0,0 +1,70 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 메뉴 접근 권한(ACCESS_ROLES) 서버측 집행.
*
* <p>요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다
* (하위 경로는 기존 안전망 @Secured / PageRoute.role 에 위임 — 사용자 결정).
* 미충족 시 익명은 로그인으로, 인증 사용자는 홈으로 리다이렉트한다.
* 메뉴에 등록되지 않은 경로는 통과시킨다.
*/
@Slf4j
@RequiredArgsConstructor
public class MenuAccessInterceptor implements HandlerInterceptor {
private final MenuService menuService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String path = currentPath(request);
List<String> requiredRoles = menuService.getSnapshot().getAccessRolesByPath().get(path);
if (requiredRoles == null || requiredRoles.isEmpty()) {
return true;
}
if (isAllowed(requiredRoles)) {
return true;
}
if (!SecurityUtil.isAuthenticated()) {
response.sendRedirect(request.getContextPath() + "/login");
} else {
log.info("메뉴 접근 권한 미충족 - path: {}, 필요 권한: {}, 사용자: {}",
path, requiredRoles, SecurityUtil.getCurrentLoginId());
response.sendRedirect(request.getContextPath() + "/");
}
return false;
}
private boolean isAllowed(List<String> requiredRoles) {
for (String role : requiredRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (SecurityUtil.isAuthenticated()) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
private String currentPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return uri;
}
}
@@ -0,0 +1,99 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
*
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
*
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/menu")
@RequiredArgsConstructor
public class MenuInternalController {
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "menu.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
private final MenuService menuService;
private final PortalPropertyService portalPropertyService;
@PostMapping("/reload")
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
}
MenuService.MenuSnapshot snapshot = menuService.reload();
log.info("메뉴 캐시 리로드 명령 수신 - from: {}", request.getRemoteAddr());
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "OK");
body.put("itemCount", snapshot.getItemCount());
body.put("reloadedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return ResponseEntity.ok(body);
}
/**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/
private boolean isAllowed(HttpServletRequest request) {
String remote = 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(MenuInternalController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(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, "메뉴 내부 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;
}
}
@@ -0,0 +1,94 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* 모든 뷰 요청에 role 필터가 적용된 {@code menuView} 를 주입한다
* (breadcrumb 의 GlobalControllerAdvice 와 동일한 방식).
*
* <p>노출 판정: EXPOSE_ROLES 비어있음(전체) ∥ AUTHENTICATED(로그인) ∥
* 역할 any-of. 경로 없는 그룹은 노출 자식이 하나도 없으면 제외한다.
*/
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class MenuModelAdvice {
private final MenuService menuService;
@ModelAttribute("menuView")
public MenuView menuView(HttpServletRequest request) {
try {
MenuService.MenuSnapshot snapshot = menuService.getSnapshot();
boolean authenticated = SecurityUtil.isAuthenticated();
List<MenuNode> gnb = new ArrayList<>();
MenuNode mypage = null;
for (MenuNode root : snapshot.getRoots()) {
MenuNode filtered = filter(root, authenticated);
if (filtered == null) {
continue;
}
if (PortalMenuItem.SECTION_MYPAGE.equals(filtered.getSection())) {
if (mypage == null) {
mypage = filtered;
}
} else {
gnb.add(filtered);
}
}
return new MenuView(gnb, mypage, request.getRequestURI());
} catch (Exception e) {
// 메뉴 조회 실패가 화면 전체를 막지 않도록 빈 메뉴로 폴백
log.error("메뉴 뷰 구성 실패 - 빈 메뉴로 렌더링합니다.", e);
return MenuView.empty();
}
}
private MenuNode filter(MenuNode node, boolean authenticated) {
if (!isExposed(node, authenticated)) {
return null;
}
MenuNode copy = node.copyWithoutChildren();
List<MenuNode> children = new ArrayList<>();
for (MenuNode child : node.getChildren()) {
MenuNode filteredChild = filter(child, authenticated);
if (filteredChild != null) {
children.add(filteredChild);
}
}
copy.setChildren(children);
// 경로 없는 그룹은 노출할 자식이 없으면 통째로 제외
if (copy.isGroup() && !copy.hasPath() && children.isEmpty()) {
return null;
}
return copy;
}
private boolean isExposed(MenuNode node, boolean authenticated) {
List<String> exposeRoles = node.getExposeRoles();
if (exposeRoles.isEmpty()) {
return true;
}
for (String role : exposeRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (authenticated) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 렌더용 메뉴 노드. {@link MenuService} 스냅샷 트리와
* {@link MenuModelAdvice} 의 요청별 필터 사본 양쪽에 사용한다.
*/
@Data
public class MenuNode {
private String menuId;
private String name;
/** 이동 경로 (null = 클릭 없는 그룹) */
private String path;
private boolean group;
/** GNB | MYPAGE */
private String section;
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuNode> children = new ArrayList<>();
/** 필터 사본 생성 (children 제외 필드 복사) */
public MenuNode copyWithoutChildren() {
MenuNode copy = new MenuNode();
copy.menuId = menuId;
copy.name = name;
copy.path = path;
copy.group = group;
copy.section = section;
copy.icon = icon;
copy.newWindow = newWindow;
copy.exposeRoles = exposeRoles;
copy.accessRoles = accessRoles;
return copy;
}
public boolean hasPath() {
return path != null && !path.isEmpty();
}
}
@@ -0,0 +1,360 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.entity.PortalRole;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthority;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId;
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleAuthorityRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleRepository;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 부팅 시 menu.yml / roles.yml → DB 적재.
*
* <ul>
* <li>역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)</li>
* <li>메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고,
* 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다</li>
* <li>배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩</li>
* <li>yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다</li>
* </ul>
*
* 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로,
* 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다
* (AuditingHandler 는 auditor 부재 시 기존 값을 보존).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MenuSeeder implements ApplicationListener<ApplicationReadyEvent> {
static final String SYSTEM_AUDITOR = "SYSTEM";
private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$");
private final PortalMenuYmlProperties menuYmlProperties;
private final PortalRolesProperties rolesProperties;
private final PortalMenuItemRepository menuItemRepository;
private final PortalMenuPlacementRepository menuPlacementRepository;
private final PortalRoleRepository roleRepository;
private final PortalRoleAuthorityRepository roleAuthorityRepository;
private final PortalMenuDataService menuDataService;
private final MenuService menuService;
@Override
@Transactional
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
seedRoles();
seedMenuItems();
seedPlacementsIfEmpty();
menuService.reload();
} catch (Exception e) {
// 시딩 실패가 기동 자체를 막지 않도록 로그만 남긴다 (메뉴는 기존 DB 값으로 동작)
log.error("메뉴/역할 시딩 실패 - 기존 DB 데이터로 동작합니다.", e);
}
}
// ─────────────── 역할 ───────────────
private void seedRoles() {
int sortOrder = 10;
int upserted = 0;
Set<String> definedCodes = new HashSet<>();
for (PortalRolesProperties.RoleDef roleDef : rolesProperties.getRoles()) {
upserted += upsertRole(roleDef.getCode(), roleDef.getName(), PortalRole.TYPE_BASE, sortOrder) ? 1 : 0;
definedCodes.add(roleDef.getCode());
sortOrder += 10;
}
for (Map.Entry<String, String> entry : rolesProperties.getAuthorityNames().entrySet()) {
if (definedCodes.contains(entry.getKey())) {
continue;
}
upserted += upsertRole(entry.getKey(), entry.getValue(), PortalRole.TYPE_AUTHORITY, sortOrder) ? 1 : 0;
sortOrder += 10;
}
// 역할→권한 매핑은 순수 미러 — 통째로 재구축
roleAuthorityRepository.deleteAllInBatch();
roleAuthorityRepository.flush();
List<PortalRoleAuthority> mappings = rolesProperties.getRoles().stream()
.flatMap(roleDef -> roleDef.getAuthorities().stream()
.map(authority -> {
PortalRoleAuthority mapping = new PortalRoleAuthority();
mapping.setId(new PortalRoleAuthorityId(roleDef.getCode(), authority));
mapping.setCreatedBy(SYSTEM_AUDITOR);
return mapping;
}))
.collect(Collectors.toList());
roleAuthorityRepository.saveAll(mappings);
log.info("역할 시딩 완료 - PTL_ROLE upsert {}건, PTL_ROLE_AUTHORITY {}건 재구축", upserted, mappings.size());
}
private boolean upsertRole(String code, String name, String type, int sortOrder) {
PortalRole role = roleRepository.findById(code).orElse(null);
if (role == null) {
role = new PortalRole();
role.setRoleCode(code);
role.setCreatedBy(SYSTEM_AUDITOR);
} else if (Objects.equals(role.getRoleName(), name)
&& Objects.equals(role.getRoleType(), type)
&& Objects.equals(role.getSortOrder(), sortOrder)) {
return false;
}
role.setRoleName(name);
role.setRoleType(type);
role.setSortOrder(sortOrder);
roleRepository.save(role);
return true;
}
// ─────────────── 메뉴 항목 ───────────────
private void seedMenuItems() {
List<FlatMenuDef> flatDefs = flatten(menuYmlProperties.getItems());
validate(flatDefs);
int inserted = 0;
int updated = 0;
for (FlatMenuDef def : flatDefs) {
PortalMenuItem existing = menuItemRepository.findById(def.id).orElse(null);
if (existing == null) {
menuItemRepository.save(newItem(def));
inserted++;
} else if (updateItem(existing, def)) {
menuItemRepository.save(existing);
updated++;
}
}
// yml 에서 사라진 PORTAL 항목 경고 (자동 삭제 금지)
Set<String> ymlIds = flatDefs.stream().map(def -> def.id).collect(Collectors.toSet());
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL).stream()
.map(PortalMenuItem::getMenuId)
.filter(id -> !ymlIds.contains(id))
.forEach(id -> log.warn("menu.yml 에 없는 기본 메뉴 항목이 DB에 남아 있습니다 (수동 정리 필요): {}", id));
log.info("메뉴 항목 시딩 완료 - 신규 {}건, 갱신 {}건, 유지 {}건",
inserted, updated, flatDefs.size() - inserted - updated);
}
private PortalMenuItem newItem(FlatMenuDef def) {
PortalMenuItem item = new PortalMenuItem();
item.setMenuId(def.id);
item.setSourceType(PortalMenuItem.SOURCE_PORTAL);
item.setCreatedBy(SYSTEM_AUDITOR);
item.setMenuName(def.name());
item.setMenuPath(def.path());
item.setExposeRoles(def.exposeCsv());
item.setAccessRoles(def.accessCsv());
applyStructure(item, def);
applyDefaults(item, def);
return item;
}
/**
* 기존 항목 upsert 갱신. 내용 필드는 "관리자 미수정(현재값==구 기본값) 시에만"
* 새 기본값을 따라가고, 구조 필드(group/section/icon/new-window)와 DFLT_* 는
* 항상 yml 값으로 맞춘다.
*
* @return 변경이 있었으면 true
*/
private boolean updateItem(PortalMenuItem item, FlatMenuDef def) {
boolean[] changed = {false};
followDefault(item, changed, def.name(), PortalMenuItem::getDfltMenuName,
PortalMenuItem::getMenuName, PortalMenuItem::setMenuName, PortalMenuItem::setDfltMenuName);
followDefault(item, changed, def.path(), PortalMenuItem::getDfltMenuPath,
PortalMenuItem::getMenuPath, PortalMenuItem::setMenuPath, PortalMenuItem::setDfltMenuPath);
followDefault(item, changed, def.exposeCsv(), PortalMenuItem::getDfltExposeRoles,
PortalMenuItem::getExposeRoles, PortalMenuItem::setExposeRoles, PortalMenuItem::setDfltExposeRoles);
followDefault(item, changed, def.accessCsv(), PortalMenuItem::getDfltAccessRoles,
PortalMenuItem::getAccessRoles, PortalMenuItem::setAccessRoles, PortalMenuItem::setDfltAccessRoles);
String groupYn = def.source.isGroup() ? "Y" : "N";
String section = def.section();
String newWindowYn = def.source.isNewWindow() ? "Y" : "N";
if (!Objects.equals(item.getGroupYn(), groupYn)
|| !Objects.equals(item.getMenuSection(), section)
|| !Objects.equals(item.getIconClass(), def.source.getIcon())
|| !Objects.equals(item.getNewWindowYn(), newWindowYn)) {
applyStructure(item, def);
changed[0] = true;
}
if (!Objects.equals(item.getDfltParentId(), def.parentId)
|| !Objects.equals(item.getDfltSortOrder(), def.sortOrder)) {
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
changed[0] = true;
}
return changed[0];
}
/**
* 내용 필드 1개에 대한 기본값 추종 처리: yml 기본값이 바뀌었을 때
* 현재값이 구 기본값과 같으면(관리자 미수정) 현재값도 새 기본값으로 갱신한다.
*/
private void followDefault(PortalMenuItem item, boolean[] changed, String newDefault,
Function<PortalMenuItem, String> defaultGetter,
Function<PortalMenuItem, String> currentGetter,
BiConsumer<PortalMenuItem, String> currentSetter,
BiConsumer<PortalMenuItem, String> defaultSetter) {
String oldDefault = defaultGetter.apply(item);
if (Objects.equals(oldDefault, newDefault)) {
return;
}
if (Objects.equals(currentGetter.apply(item), oldDefault)) {
currentSetter.accept(item, newDefault);
}
defaultSetter.accept(item, newDefault);
changed[0] = true;
}
private void applyStructure(PortalMenuItem item, FlatMenuDef def) {
item.setGroupYn(def.source.isGroup() ? "Y" : "N");
item.setMenuSection(def.section());
item.setIconClass(def.source.getIcon());
item.setNewWindowYn(def.source.isNewWindow() ? "Y" : "N");
}
private void applyDefaults(PortalMenuItem item, FlatMenuDef def) {
item.setDfltMenuName(def.name());
item.setDfltMenuPath(def.path());
item.setDfltExposeRoles(def.exposeCsv());
item.setDfltAccessRoles(def.accessCsv());
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
item.setDfltVisibleYn("Y");
}
// ─────────────── 배치 ───────────────
private void seedPlacementsIfEmpty() {
if (menuPlacementRepository.count() > 0) {
log.info("메뉴 배치 존재 - 최초 시딩 건너뜀 (관리자 배치 보존)");
return;
}
List<PortalMenuItem> portalItems =
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL);
List<PortalMenuPlacement> placements = menuDataService.buildDefaultPlacements(portalItems);
placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR));
menuPlacementRepository.saveAll(placements);
log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size());
}
// ─────────────── 평탄화 / 검증 ───────────────
private List<FlatMenuDef> flatten(List<PortalMenuYmlProperties.MenuItemDef> roots) {
List<FlatMenuDef> result = new ArrayList<>();
int sortOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef root : roots) {
result.add(new FlatMenuDef(root, null, sortOrder, null));
int childOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef child : root.getChildren()) {
result.add(new FlatMenuDef(child, root.getId(), childOrder, root));
childOrder += 10;
}
sortOrder += 10;
}
return result;
}
private void validate(List<FlatMenuDef> defs) {
Set<String> seen = new HashSet<>();
for (FlatMenuDef def : defs) {
if (def.id == null || !MENU_ID_PATTERN.matcher(def.id).matches()) {
throw new IllegalStateException("menu.yml 항목 id 형식 오류(kebab-case 필수): " + def.id);
}
if (!seen.add(def.id)) {
throw new IllegalStateException("menu.yml 항목 id 중복: " + def.id);
}
}
}
/** yml 트리 항목의 평탄화 뷰 (부모/기본 정렬 포함) */
private static class FlatMenuDef {
final PortalMenuYmlProperties.MenuItemDef source;
final String parentId;
final Integer sortOrder;
final PortalMenuYmlProperties.MenuItemDef parent;
final String id;
FlatMenuDef(PortalMenuYmlProperties.MenuItemDef source, String parentId, Integer sortOrder,
PortalMenuYmlProperties.MenuItemDef parent) {
this.source = source;
this.parentId = parentId;
this.sortOrder = sortOrder;
this.parent = parent;
this.id = source.getId();
}
String name() {
return source.getName();
}
String path() {
return trimToNull(source.getPath());
}
/** 자식은 섹션 미지정 시 부모 섹션 상속 */
String section() {
String own = trimToNull(source.getSection());
if (own != null) {
return own;
}
if (parent != null) {
String parentSection = trimToNull(parent.getSection());
if (parentSection != null) {
return parentSection;
}
}
return PortalMenuItem.SECTION_GNB;
}
String exposeCsv() {
return toCsv(source.getExposeRoles());
}
String accessCsv() {
return toCsv(source.getAccessRoles());
}
private static String toCsv(List<String> roles) {
if (roles == null || roles.isEmpty()) {
return null;
}
return String.join(",", roles);
}
private static String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}
}
@@ -0,0 +1,179 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 메뉴 트리 스냅샷 캐시.
*
* <p>매 요청 DB 조회를 피하기 위해 role 비의존 트리를 메모리에 유지한다
* (기존 수제 캐시 관례: PageService 의 volatile 필드). TTL 은
* PTL_PROPERTY {@code Portal / menu.cache.ttl-seconds} (기본 3600초)이며,
* eapim-admin 의 reload 명령(/internal/menu/reload)으로 즉시 갱신된다.
* 요청별 role 필터링은 {@link MenuModelAdvice} 가 수행한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MenuService {
static final String PROP_GROUP = "Portal";
static final String PROP_CACHE_TTL = "menu.cache.ttl-seconds";
static final String DEFAULT_CACHE_TTL = "3600";
/** 스냅샷이 비어있을 때의 재시도 TTL(초) — 시딩 전 기동 직후 요청 대비 */
private static final long EMPTY_RETRY_TTL_SECONDS = 60;
private final PortalMenuDataService menuDataService;
private final PortalPropertyService portalPropertyService;
private volatile MenuSnapshot snapshot;
public MenuSnapshot getSnapshot() {
MenuSnapshot current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
synchronized (this) {
current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
return reload();
}
}
/**
* DB 에서 트리를 재구축한다. 시더 완료 시점과 admin reload 명령이 호출한다.
*/
public synchronized MenuSnapshot reload() {
long ttlSeconds = resolveTtlSeconds();
List<PortalMenuItem> items = menuDataService.loadAllItems();
List<PortalMenuPlacement> placements = menuDataService.loadAllPlacements();
Map<String, PortalMenuItem> itemsById = items.stream()
.collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new));
// 가시(visible=Y) 배치만 렌더 트리에 포함
Map<String, List<PortalMenuPlacement>> byParent = placements.stream()
.filter(PortalMenuPlacement::isVisible)
.filter(placement -> itemsById.containsKey(placement.getMenuId()))
.collect(Collectors.groupingBy(
placement -> placement.getParentId() == null ? "" : placement.getParentId(),
LinkedHashMap::new, Collectors.toList()));
List<MenuNode> roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent);
// 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지)
Map<String, List<String>> accessRolesByPath = new LinkedHashMap<>();
for (PortalMenuItem item : items) {
if (item.getMenuPath() != null && item.getAccessRoles() != null
&& !item.getAccessRoles().trim().isEmpty()) {
accessRolesByPath.put(normalizePath(item.getMenuPath()), splitCsv(item.getAccessRoles()));
}
}
if (roots.isEmpty()) {
ttlSeconds = Math.min(ttlSeconds, EMPTY_RETRY_TTL_SECONDS);
}
MenuSnapshot rebuilt = new MenuSnapshot(roots, accessRolesByPath, items.size(),
System.currentTimeMillis(), ttlSeconds);
snapshot = rebuilt;
log.info("메뉴 캐시 갱신 - 항목 {}건, 최상위 {}건, TTL {}초", items.size(), roots.size(), ttlSeconds);
return rebuilt;
}
private List<MenuNode> buildNodes(List<PortalMenuPlacement> placements,
Map<String, PortalMenuItem> itemsById,
Map<String, List<PortalMenuPlacement>> byParent) {
List<MenuNode> nodes = new ArrayList<>();
for (PortalMenuPlacement placement : placements) {
PortalMenuItem item = itemsById.get(placement.getMenuId());
MenuNode node = toNode(item);
node.setChildren(buildNodes(
byParent.getOrDefault(item.getMenuId(), Collections.emptyList()), itemsById, byParent));
nodes.add(node);
}
return nodes;
}
private MenuNode toNode(PortalMenuItem item) {
MenuNode node = new MenuNode();
node.setMenuId(item.getMenuId());
node.setName(item.getMenuName());
node.setPath(normalizePath(item.getMenuPath()));
node.setGroup(item.isGroup());
node.setSection(item.getMenuSection());
node.setIcon(item.getIconClass());
node.setNewWindow("Y".equals(item.getNewWindowYn()));
node.setExposeRoles(splitCsv(item.getExposeRoles()));
node.setAccessRoles(splitCsv(item.getAccessRoles()));
return node;
}
private long resolveTtlSeconds() {
try {
String value = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_CACHE_TTL,
DEFAULT_CACHE_TTL, "포탈 메뉴 캐시 TTL(초)");
return Long.parseLong(value.trim());
} catch (Exception e) {
log.warn("메뉴 캐시 TTL 조회 실패 - 기본값 {}초 사용", DEFAULT_CACHE_TTL, e);
return Long.parseLong(DEFAULT_CACHE_TTL);
}
}
private static List<String> splitCsv(String csv) {
if (csv == null || csv.trim().isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(token -> !token.isEmpty())
.collect(Collectors.toList());
}
private static String normalizePath(String path) {
if (path == null || path.trim().isEmpty()) {
return null;
}
String normalized = path.trim();
int queryIndex = normalized.indexOf('?');
return queryIndex >= 0 ? normalized.substring(0, queryIndex) : normalized;
}
/** role 비의존 메뉴 스냅샷 (불변 취급) */
@Getter
public static class MenuSnapshot {
private final List<MenuNode> roots;
private final Map<String, List<String>> accessRolesByPath;
private final int itemCount;
private final long loadedAt;
private final long ttlSeconds;
MenuSnapshot(List<MenuNode> roots, Map<String, List<String>> accessRolesByPath,
int itemCount, long loadedAt, long ttlSeconds) {
this.roots = roots;
this.accessRolesByPath = accessRolesByPath;
this.itemCount = itemCount;
this.loadedAt = loadedAt;
this.ttlSeconds = ttlSeconds;
}
boolean isExpired() {
return System.currentTimeMillis() - loadedAt > ttlSeconds * 1000L;
}
}
}
@@ -0,0 +1,108 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Getter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 요청별(role 필터 적용) 메뉴 뷰 — 템플릿 모델 {@code menuView}.
*
* <ul>
* <li>{@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)</li>
* <li>{@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)</li>
* <li>{@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색</li>
* </ul>
*/
@Getter
public class MenuView {
/**
* service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지.
* 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵.
*/
private static final Map<String, String> LEGACY_ACTIVE_KEYS = new HashMap<>();
static {
LEGACY_ACTIVE_KEYS.put("intro", "service-intro");
LEGACY_ACTIVE_KEYS.put("guide", "service-guide");
LEGACY_ACTIVE_KEYS.put("oauth2", "service-oauth2-guide");
LEGACY_ACTIVE_KEYS.put("webhookGuide", "service-webhook-dev-guide");
LEGACY_ACTIVE_KEYS.put("notice", "support-notice");
LEGACY_ACTIVE_KEYS.put("faq", "support-faq");
LEGACY_ACTIVE_KEYS.put("qna", "support-qna");
LEGACY_ACTIVE_KEYS.put("feedback", "support-partnership");
LEGACY_ACTIVE_KEYS.put("users", "my-page-users");
LEGACY_ACTIVE_KEYS.put("apiKey", "my-page-clients");
LEGACY_ACTIVE_KEYS.put("webhook", "my-page-webhook");
LEGACY_ACTIVE_KEYS.put("statistics", "my-page-statistics");
LEGACY_ACTIVE_KEYS.put("profile", "my-page-profile");
LEGACY_ACTIVE_KEYS.put("password", "my-page-password");
}
private final List<MenuNode> gnb;
private final MenuNode mypage;
private final String currentPath;
public MenuView(List<MenuNode> gnb, MenuNode mypage, String currentPath) {
this.gnb = gnb;
this.mypage = mypage;
this.currentPath = currentPath;
}
/**
* 사이드바용 활성 그룹. legacy activeMenu 키 → 메뉴 ID 우선,
* 실패 시 현재 경로(정확/prefix) 매칭으로 폴백.
*/
public MenuNode activeGroup(String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
MenuNode byId = targetId == null ? null : findRootContaining(node -> targetId.equals(node.getMenuId()));
if (byId != null) {
return byId;
}
return findRootContaining(this::matchesCurrentPath);
}
/**
* 사이드바 항목 활성 여부: legacy 키 매칭 우선, 경로 매칭 폴백.
*/
public boolean isActive(MenuNode node, String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
if (targetId != null) {
return targetId.equals(node.getMenuId());
}
return matchesCurrentPath(node);
}
private MenuNode findRootContaining(java.util.function.Predicate<MenuNode> predicate) {
for (MenuNode root : allRoots()) {
if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) {
return root;
}
}
return null;
}
private List<MenuNode> allRoots() {
if (mypage == null) {
return gnb;
}
java.util.ArrayList<MenuNode> roots = new java.util.ArrayList<>(gnb);
roots.add(mypage);
return roots;
}
private boolean matchesCurrentPath(MenuNode node) {
if (!node.hasPath() || currentPath == null) {
return false;
}
return currentPath.equals(node.getPath())
|| currentPath.startsWith(node.getPath() + "/");
}
public static MenuView empty() {
return new MenuView(Collections.emptyList(), null, null);
}
}
@@ -0,0 +1,38 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* menu.yml 바인딩 (spring.config.import 로 로드).
* 트리(children 중첩) 형태 그대로 바인딩하며, 평탄화는 {@link MenuSeeder} 가 수행한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-menu")
public class PortalMenuYmlProperties {
private List<MenuItemDef> items = new ArrayList<>();
@Data
public static class MenuItemDef {
/** kebab-case 자연키 (^[a-z0-9-]+$) */
private String id;
private String name;
private String path;
/** true = 클릭 없는 상위 그룹 */
private boolean group;
/** GNB(기본) | MYPAGE */
private String section;
/** FontAwesome 아이콘 클래스 (마이페이지 드롭다운) */
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuItemDef> children = new ArrayList<>();
}
}
@@ -0,0 +1,47 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* roles.yml 바인딩 (spring.config.import 로 로드).
* 기존 application.yml 의 {@code portal.portal_security} 를 대체한다.
* 로그인 시 권한 확장(PortalUserAuthService)이 직접 사용하며,
* DB 미러(PTL_ROLE / PTL_ROLE_AUTHORITY)는 {@link MenuSeeder} 가 적재한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-roles")
public class PortalRolesProperties {
private List<RoleDef> roles = new ArrayList<>();
/** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */
private Map<String, String> authorityNames = new LinkedHashMap<>();
@Data
public static class RoleDef {
private String code;
private String name;
private List<String> authorities = new ArrayList<>();
}
/**
* 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록.
*/
public List<String> getAuthorities(RoleCode roleCode) {
return roles.stream()
.filter(role -> role.getCode().equals(roleCode.name()))
.findFirst()
.map(RoleDef::getAuthorities)
.orElse(Collections.emptyList());
}
}
@@ -0,0 +1,40 @@
package com.eactive.apim.portal.djb.swing;
/**
* Swing 메신저 수신자 ID 가 행번(직원번호)이 아닐 때의 처리 정책.
*
* <p>Swing 메신저는 {@code TSEAIRM02.USERID} 를 수신자 코드로 사용하며 운영 직원은 숫자 행번을 쓴다.
* portal-admin 역할이 부여된 개발용 계정(영문 ID 등)은 Swing 에 존재하지 않아 발송이 무의미하므로
* 이 정책으로 처리 방식을 선택한다. PTL_PROPERTY
* {@code Portal / djb.swing.notify.non-employee.policy} 로 지정한다.</p>
*/
public enum EmployeeIdPolicy {
/** 정상 발송 (PTL_MESSAGE_REQUEST 에 PENDING 적재) */
SEND,
/** 발송 + WARN 로그 */
SEND_WARN,
/** 무시 처리 — REQUEST_STATUS='SKIPPED' 로 적재해 이력만 남기고 배치는 수집하지 않음 */
SKIP,
/** 거부 — PTL_MESSAGE_REQUEST 에 적재하지 않음 + INFO 로그 */
REJECT_LOG,
/** 완전 거부 — 적재하지 않고 로그도 남기지 않음 */
REJECT;
/** 프로퍼티 문자열 → enum. 값이 없거나 알 수 없으면 기본값 {@link #SKIP}. */
public static EmployeeIdPolicy from(String value) {
if (value != null) {
String trimmed = value.trim();
for (EmployeeIdPolicy policy : values()) {
if (policy.name().equalsIgnoreCase(trimmed)) {
return policy;
}
}
}
return SKIP;
}
}
@@ -0,0 +1,160 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.entity.MessageTemplate;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Swing 메신저 발송 요청을 PTL_MESSAGE_REQUEST 에 직접 적재한다.
*
* <p>TSEAIRM02(직원) → PTL_MESSAGE_TEMPLATE(본문) → PTL_MESSAGE_REQUEST(발송 요청) 순으로
* 처리하며, 실제 발송은 기존과 동일하게 eapim-admin 의 {@code UmsDispatchJob}(5초 주기)이
* {@code REQUEST_STATUS='PENDING'} 행을 수집해 수행한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SwingMessageWriter {
private static final String MESSAGE_TYPE_MESSENGER = "MESSENGER";
private static final String STATUS_PENDING = "PENDING";
/** 행번 아닌 ID 를 무시 처리할 때 쓰는 신설 상태값. 배치(PENDING 수집)가 건드리지 않는다. */
private static final String STATUS_SKIPPED = "SKIPPED";
private static final String ENABLED = "Y";
private final SwingStaffRepository swingStaffRepository;
private final MessageTemplateRepository messageTemplateRepository;
private final MessageRequestRepository messageRequestRepository;
private final SwingNotifyProperties properties;
@Transactional
public void write(MessageCode code, Map<String, Object> params) {
String role = properties.getTargetRole();
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
if (staffs == null || staffs.isEmpty()) {
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
return;
}
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
if (template == null) {
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
return;
}
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
code.name(), template.getEnableMessenger());
return;
}
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
for (UserInfo staff : staffs) {
try {
writeOne(code, template, params, staff, umsIds, policy);
} catch (Exception e) {
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
}
}
}
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
EmployeeIdPolicy policy) {
String messengerId = staff.getUserid();
String requestStatus = STATUS_PENDING;
if (!properties.isEmployeeId(messengerId)) {
switch (policy) {
case REJECT:
return;
case REJECT_LOG:
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
return;
case SKIP:
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
STATUS_SKIPPED, messengerId, code.name());
requestStatus = STATUS_SKIPPED;
break;
case SEND_WARN:
log.warn("행번이 아닌 ID — 발송 진행. Swing 미등록 계정이면 발송 실패한다. userid={}, code={}",
messengerId, code.name());
break;
case SEND:
default:
break;
}
}
Map<String, String> messageParams = toStringParams(params);
messageParams.put("userId", messengerId);
messageParams.putIfAbsent("USER_ID", messengerId);
if (StringUtils.hasText(staff.getUsername())) {
messageParams.put("userName", staff.getUsername());
messageParams.putIfAbsent("USER_NAME", staff.getUsername());
}
MessageRequest request = new MessageRequest();
request.setMessageCode(code);
request.setMessageType(MESSAGE_TYPE_MESSENGER);
request.setSubject(buildMessage(template.getSubjectTemplate(), messageParams));
request.setMessage(buildMessage(template.getMessengerTemplate(), messageParams));
request.setUsername(staff.getUsername());
request.setUserId(messengerId);
request.setMessengerId(messengerId);
request.setEaiInterfaceId(umsIds.getInterfaceId());
request.setServiceId(umsIds.getServiceId());
request.setRequestDate(LocalDateTime.now());
request.setRequestStatus(requestStatus);
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
messageRequestRepository.save(request);
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
}
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
private static Map<String, String> toStringParams(Map<String, Object> params) {
Map<String, String> result = new HashMap<>();
if (params != null) {
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
result.put(entry.getKey(), String.valueOf(entry.getValue()));
}
}
return result;
}
/** 템플릿 본문의 {@code %KEY%} 플레이스홀더를 치환한다(정규식 아닌 리터럴 치환). */
private static String buildMessage(String contents, Map<String, String> params) {
if (!StringUtils.hasText(contents)) {
return "";
}
String result = contents;
for (Map.Entry<String, String> entry : params.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
result = result.replace("%" + entry.getKey() + "%", entry.getValue());
}
return result;
}
}
@@ -0,0 +1,43 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.djb.swing.config.SwingAsyncConfig;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* DjBankSwing(사내 메신저) 전용 알림 발행자.
*
* <p>포털 사용자용 범용 발송 경로({@code MessageHandlerService} → {@code MessageSendService})를
* 타지 않고 TSEAIRM02 / PTL_MESSAGE_TEMPLATE / PTL_MESSAGE_REQUEST 를 직접 다룬다.
* 내부 직원은 수신자 ID 체계(행번)와 채널(메신저 단일)이 포털 사용자와 완전히 달라 분리했다.</p>
*
* <p>비동기 실행이므로 게시물 등록 트랜잭션과 분리된다. 발송 적재 실패가 등록을 되돌리지 않는 대신,
* 등록이 롤백돼도 알림은 남을 수 있다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SwingNotifier {
private final SwingMessageWriter swingMessageWriter;
/**
* 대상 역할(PTL_PROPERTY {@code djb.swing.notify.role}) 직원 전원에게 메신저 알림을 적재한다.
*
* @param code 메세지 코드 (PTL_MESSAGE_TEMPLATE.MESSAGE_CODE 와 동일)
* @param params 템플릿 {@code %KEY%} 치환 파라미터
*/
@Async(SwingAsyncConfig.EXECUTOR)
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
swingMessageWriter.write(code, params);
} catch (Exception e) {
log.warn("Swing 알림 발행 실패 — code={}", code == null ? null : code.name(), e);
}
}
}
@@ -0,0 +1,112 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Swing 알림 관련 PTL_PROPERTY 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다.
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
* 생성하므로 별도 초기 데이터 없이도 동작한다.</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SwingNotifyProperties {
public static final String GROUP = "Portal";
public static final String KEY_TARGET_ROLE = "djb.swing.notify.role";
public static final String KEY_EMPLOYEE_ID_PATTERN = "djb.swing.notify.employee-id.pattern";
public static final String KEY_NON_EMPLOYEE_POLICY = "djb.swing.notify.non-employee.policy";
/** UMS 메신저 연계 식별자. 기존 발송 경로(MessageSendService)와 같은 키를 그대로 읽는다. */
public static final String KEY_MESSENGER_IF_ID = "djb.ums.messenger.if_id";
public static final String KEY_MESSENGER_TX_ID = "djb.ums.messenger.tx_id";
private static final String DEFAULT_TARGET_ROLE = "portal-admin";
private static final String DEFAULT_EMPLOYEE_ID_PATTERN = "^[0-9]+$";
private final PortalPropertyService portalPropertyService;
/** 컴파일된 행번 정규식 캐시. 프로퍼티 값이 바뀌면 다시 컴파일한다. */
private volatile String cachedPatternValue;
private volatile Pattern cachedPattern;
/** 알림 대상 직원을 식별하는 TSEAIRM02.ROLEIDNFINAME 역할명 */
public String getTargetRole() {
return resolve(KEY_TARGET_ROLE, DEFAULT_TARGET_ROLE,
"Swing 알림 대상 직원 역할명 (TSEAIRM02.ROLEIDNFINAME 의 콤마 구분 토큰)");
}
/** 행번 아닌 ID 처리 정책 */
public EmployeeIdPolicy getNonEmployeePolicy() {
return EmployeeIdPolicy.from(resolve(KEY_NON_EMPLOYEE_POLICY, EmployeeIdPolicy.SKIP.name(),
"행번 아닌 ID 처리 정책 (SEND/SEND_WARN/SKIP/REJECT_LOG/REJECT)"));
}
/**
* 행번(직원번호) 형식인지 여부. 정규식이 잘못 지정된 경우 기본 정규식으로 폴백한다.
*
* @param userId TSEAIRM02.USERID
*/
public boolean isEmployeeId(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return false;
}
return employeeIdPattern().matcher(userId.trim()).matches();
}
/** UMS 메신저 연계 식별자(EAI 인터페이스 ID / 서비스 ID) */
public UmsMessengerIds getUmsMessengerIds() {
// 환경별 실제 연계값이라 임의 기본값을 만들면 위험 — getOrCreateProperty 대신 조회만 한다.
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap(GROUP);
return new UmsMessengerIds(
portalProperties.get(KEY_MESSENGER_IF_ID),
portalProperties.get(KEY_MESSENGER_TX_ID));
}
private Pattern employeeIdPattern() {
String value = resolve(KEY_EMPLOYEE_ID_PATTERN, DEFAULT_EMPLOYEE_ID_PATTERN,
"행번(직원번호) 판별 정규식. 미매치 시 개발용 ID 로 간주");
Pattern cached = this.cachedPattern;
if (cached != null && value != null && value.equals(this.cachedPatternValue)) {
return cached;
}
Pattern compiled;
try {
compiled = Pattern.compile(value);
} catch (Exception e) {
log.warn("행번 판별 정규식이 잘못되어 기본값으로 대체합니다 — pattern={}", value, e);
compiled = Pattern.compile(DEFAULT_EMPLOYEE_ID_PATTERN);
}
this.cachedPatternValue = value;
this.cachedPattern = compiled;
return compiled;
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
/** UMS 메신저 연계 식별자 묶음 */
@Getter
public static class UmsMessengerIds {
private final String interfaceId;
private final String serviceId;
public UmsMessengerIds(String interfaceId, String serviceId) {
this.interfaceId = interfaceId;
this.serviceId = serviceId;
}
}
}
@@ -0,0 +1,34 @@
package com.eactive.apim.portal.djb.swing.config;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Swing 알림 전용 비동기 실행기.
*
* <p>게시물 등록 트랜잭션이 메신저 알림 적재를 기다리지 않도록 분리한다.
* eapim-admin 의 {@code WebhookAsyncConfig} 와 동일한 구성.</p>
*/
@Configuration
@EnableAsync
public class SwingAsyncConfig {
public static final String EXECUTOR = "swingNotifyExecutor";
@Bean(name = EXECUTOR)
public Executor swingNotifyExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("swing-notify-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
@@ -0,0 +1,27 @@
package com.eactive.apim.portal.djb.swing.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Swing 알림 대상 직원(TSEAIRM02) 조회 전용 리포지토리.
*/
@Repository
@EMSDataSource
public interface SwingStaffRepository extends JpaRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRole(@Param("role") String role);
}
@@ -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.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
@@ -163,8 +164,8 @@ public class WebhookController {
redirectAttributes.addFlashAttribute("registrationSuccess", true);
return new ModelAndView("redirect:/webhook/register/step3");
} catch (RuntimeException e) {
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
log.warn("Webhook 신청 실패 orgId={}", currentOrgId(), e);
redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
return new ModelAndView("redirect:/webhook/register/step2");
}
}
@@ -270,8 +271,8 @@ public class WebhookController {
redirectAttributes.addFlashAttribute("modifySuccess", true);
return new ModelAndView("redirect:/webhook/modify/step3");
} catch (RuntimeException e) {
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
log.warn("Webhook 수정 실패 orgId={}", currentOrgId(), e);
redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
return new ModelAndView("redirect:/webhook/modify/step2");
}
}
+13 -21
View File
@@ -2,7 +2,7 @@ server:
servlet:
context-path: /
session:
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# 세션 타임아웃 10분 고정
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
timeout: 10m
@@ -23,6 +23,10 @@ server:
spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
data:
web:
pageable:
@@ -218,26 +222,7 @@ portal:
view-name: apps/apis/static/tokenApiSpec
bean: apiHandler
portal_security:
ROLE_USER:
- ROLE_INQUIRY
- ROLE_ACCOUNT
ROLE_CORP_USER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
ROLE_CORP_MANAGER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# portal_security 는 roles.yml (portal-roles) 로 이동
page:
home:
name: "Home"
@@ -318,6 +303,13 @@ page:
api_testbed:
name: "테스트베드"
path: "/apis/detail/testbed"
apistatus:
name: "API Status"
path: "/apistatus"
children:
issues:
name: "전체 이슈 이력"
path: "/apistatus/issues"
community:
name: 고객지원
path: "#"
+55
View File
@@ -0,0 +1,55 @@
# ------------------------------------------------------------------------------
# 포탈 GNB 메뉴 정의 (menu.yml)
#
# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면
# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다.
# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다.
# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행.
# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지.
# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작.
# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운)
# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자,
# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of)
# ------------------------------------------------------------------------------
portal-menu:
items:
- id: service
name: "서비스 소개"
group: true
children:
- { id: service-intro, name: "API 포탈 소개", path: /service/intro }
- { id: service-guide, name: "회원가입 안내", path: /service/guide }
- { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide }
- { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide }
- { id: open-api, name: "오픈 API", path: /apis }
- id: support
name: "고객지원"
group: true
children:
- { id: support-notice, name: "공지사항", path: /portalnotice }
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: support-qna, name: "Q&A", path: /inquiry }
- { id: support-partnership, name: "피드백/개선요청", path: /partnership }
- { id: api-status, name: "API Status", path: /apistatus }
- id: my-page
name: "마이페이지"
group: true
section: MYPAGE
expose-roles: [AUTHENTICATED]
children:
- { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users,
expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] }
- { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
- { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
- { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
+47
View File
@@ -0,0 +1,47 @@
# ------------------------------------------------------------------------------
# 포탈 역할 정의 (roles.yml)
#
# - 기존 application.yml 의 portal.portal_security 를 이동한 파일.
# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다.
# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며,
# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다.
# ------------------------------------------------------------------------------
portal-roles:
roles:
- code: ROLE_USER
name: "개인사용자"
authorities:
- ROLE_INQUIRY
- ROLE_ACCOUNT
- code: ROLE_CORP_USER
name: "법인사용자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- code: ROLE_CORP_MANAGER
name: "법인관리자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용
authority-names:
ROLE_INQUIRY: "문의 작성"
ROLE_ACCOUNT: "계정 관리"
ROLE_API_KEY_REQUEST: "인증키 신청"
ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회"
ROLE_APP: "앱/API 신청 관리"
ROLE_WEBHOOK: "웹훅 관리"
ROLE_CORP_API: "법인 API 조회"
ROLE_DASHBOARD: "대시보드"
ROLE_USER_MANAGER: "개발자 관리"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1,498 @@
(function () {
'use strict';
const page = document.getElementById('issueHistoryPage');
if (!page) return;
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
const PAGE_SIZE = 10;
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
const today = page.getAttribute('data-today') || '';
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
const minDate = page.getAttribute('data-min-date') || '';
// 진입 시 날짜 미지정 = 조회 기간 전체
const state = {
date: page.getAttribute('data-selected-date') || '',
apiId: page.getAttribute('data-selected-api') || '',
kind: page.getAttribute('data-selected-kind') || '',
page: 0
};
const indexBar = document.getElementById('issueIndexBar');
const listEl = document.getElementById('issueList');
const pagerEl = document.getElementById('issuePager');
const totalCountEl = document.getElementById('issueTotalCount');
const selectedDateLabel = document.getElementById('selectedDateLabel');
const dateField = document.getElementById('dateField');
const dateInput = document.getElementById('filterDateInput');
const dateClearBtn = document.getElementById('filterDateClear');
const kindSelect = document.getElementById('filterKindSelect');
const apiCombo = document.getElementById('apiCombo');
const apiInput = document.getElementById('filterApiInput');
const apiClearBtn = document.getElementById('filterApiClear');
const apiListEl = document.getElementById('filterApiList');
const unlistedBanner = document.getElementById('unlistedApiBanner');
const unlistedName = document.getElementById('unlistedApiName');
let apiOptions = [];
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
});
}
function parseDateTime(value) {
if (!value) return null;
const parsed = new Date(String(value).replace(' ', 'T'));
return isNaN(parsed.getTime()) ? null : parsed;
}
function pad(value) {
return value < 10 ? '0' + value : String(value);
}
function formatDate(value) {
const date = parseDateTime(value);
if (!date) return '-';
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function formatDateTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatDuration(minutes) {
if (minutes == null) return '';
if (minutes < 60) return minutes + '분';
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
}
function buildQuery(params) {
const query = [];
Object.keys(params).forEach(function (key) {
if (params[key] !== '' && params[key] != null) {
query.push(key + '=' + encodeURIComponent(params[key]));
}
});
return query.length ? '?' + query.join('&') : '';
}
function syncBrowserUrl() {
if (!window.history || !window.history.replaceState) return;
window.history.replaceState(null, '',
base + '/issues' + buildQuery({ date: state.date, apiId: state.apiId, kind: state.kind }));
}
/** 필터 변경 후 목록/인덱스 재조회 */
function applyFilters(reloadIndex) {
state.page = 0;
syncBrowserUrl();
renderIndexBarSelection();
if (reloadIndex) loadIndexBar();
loadIssues();
}
// ---------------- 90일 인덱스바 ----------------
function renderIndexBar(entries) {
if (!entries || !entries.length) {
indexBar.innerHTML = '';
return;
}
indexBar.innerHTML = entries.map(function (entry) {
const hasIncident = entry.incCount > 0;
const hasDelay = entry.dlyCount > 0;
const hasMaintenance = entry.mntCount > 0;
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
let cls = '';
if (kindCount > 1) cls = ' has-mixed';
else if (hasIncident) cls = ' has-incident';
else if (hasDelay) cls = ' has-degraded';
else if (hasMaintenance) cls = ' has-maintenance';
if (state.date && state.date === entry.date) cls += ' is-selected';
const parts = [];
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
return '<button type="button" class="as-index-cell' + cls + '"'
+ ' data-date="' + escapeHtml(entry.date) + '"'
+ ' title="' + escapeHtml(tooltip) + '"></button>';
}).join('');
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
cell.addEventListener('click', function () {
const clicked = cell.getAttribute('data-date');
state.date = state.date === clicked ? '' : clicked;
applyFilters(false);
});
});
}
function renderIndexBarSelection() {
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
cell.classList.toggle('is-selected', state.date === cell.getAttribute('data-date'));
});
selectedDateLabel.textContent = state.date || '전체 기간';
if (dateInput.value !== state.date) {
dateInput.value = state.date;
}
dateField.classList.toggle('has-value', !!state.date);
}
// ---------------- 이슈 카드 ----------------
/**
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(issue) {
if (!issue.noticeDetail) return '';
const link = issue.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
function issueCardHtml(issue) {
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
let inner;
if (issue.kind === 'MAINTENANCE') {
inner = '<p class="as-tl-body">' + escapeHtml(issue.summary || '점검 안내') + '</p>';
} else {
const items = (issue.timeline || []).map(function (entry) {
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
+ '</div>';
}).join('');
inner = items
? '<div class="as-tl-block">' + items + '</div>'
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
}
const period = formatDateTime(issue.startedAt)
+ (issue.endAt ? ' ~ ' + formatDateTime(issue.endAt) : ' ~ 진행 중')
+ (issue.durationMinutes != null ? ' (' + formatDuration(issue.durationMinutes) + ')' : '');
return '<article class="as-issue-card ' + kindClass + '">'
+ '<div class="as-issue-meta-row">'
+ ' <span class="as-badge ' + kindClass + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
+ ' <span>' + escapeHtml(period) + '</span>'
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ '</div>'
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
+ '</article>';
}
function renderPager(pageData) {
const totalPages = pageData.totalPages || 0;
if (totalPages <= 1) {
pagerEl.innerHTML = '';
return;
}
let html = '';
for (let index = 0; index < totalPages; index++) {
const current = index === pageData.number;
html += '<button type="button" class="as-btn' + (current ? ' is-current' : '') + '"'
+ ' data-page="' + index + '"' + (current ? ' disabled' : '') + '>' + (index + 1) + '</button>';
}
pagerEl.innerHTML = html;
pagerEl.querySelectorAll('button[data-page]').forEach(function (button) {
button.addEventListener('click', function () {
state.page = parseInt(button.getAttribute('data-page'), 10) || 0;
loadIssues();
window.scrollTo({ top: listEl.offsetTop - 80, behavior: 'smooth' });
});
});
}
function loadIssues() {
const query = buildQuery({
date: state.date,
apiId: state.apiId,
kind: state.kind,
page: state.page,
size: PAGE_SIZE
});
fetchJson(base + '/issues/list.json' + query)
.then(function (pageData) {
const content = pageData.content || [];
totalCountEl.textContent = pageData.totalElements != null ? pageData.totalElements : content.length;
listEl.innerHTML = content.length
? content.map(issueCardHtml).join('')
: '<div class="as-empty">조건에 해당하는 이슈가 없습니다.</div>';
renderPager(pageData);
})
.catch(function () {
listEl.innerHTML = '<div class="as-empty">이슈 목록을 불러올 수 없습니다.</div>';
pagerEl.innerHTML = '';
});
}
// ---------------- API 필터 (라이브서치 콤보박스) ----------------
/** 옵션 = 오픈 API 목록과 동일 기준(그룹 편성 + 사용자 공개). 표시는 API 명만. */
function loadApiOptions() {
return fetchJson(base + '/apis.json')
.then(function (apis) {
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
renderApiInput();
})
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
}
function findApiOption(apiId) {
if (!apiId) return null;
for (let index = 0; index < apiOptions.length; index++) {
if (apiOptions[index].apiId === apiId) return apiOptions[index];
}
return null;
}
/** 표시 형태: "[그룹명] API명" (그룹 없으면 API명만) */
function optionLabel(api) {
if (!api) return '';
return api.groupName ? '[' + api.groupName + '] ' + api.apiName : api.apiName;
}
function renderApiInput() {
const selected = findApiOption(state.apiId);
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
apiCombo.classList.toggle('has-value', !!state.apiId);
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
if (unlisted) {
unlistedName.textContent = urlApiName || state.apiId;
}
unlistedBanner.style.display = unlisted ? '' : 'none';
}
function closeApiList() {
apiListEl.hidden = true;
apiInput.setAttribute('aria-expanded', 'false');
}
function openApiList(keyword) {
const query = (keyword || '').trim().toLowerCase();
const matched = apiOptions.filter(function (api) {
if (!query) return true;
return optionLabel(api).toLowerCase().indexOf(query) >= 0;
});
if (!matched.length) {
apiListEl.innerHTML = '<li class="as-combo-empty">검색 결과가 없습니다</li>';
} else {
const items = matched.slice(0, API_LIST_LIMIT).map(function (api) {
return '<li role="option" class="as-combo-item" data-api-id="' + escapeHtml(api.apiId) + '">'
+ escapeHtml(optionLabel(api)) + '</li>';
});
if (matched.length > API_LIST_LIMIT) {
items.push('<li class="as-combo-empty">이하 '
+ (matched.length - API_LIST_LIMIT) + '건은 검색어를 입력해 좁혀주세요</li>');
}
apiListEl.innerHTML = '<li role="option" class="as-combo-item" data-api-id="">전체 API</li>'
+ items.join('');
}
apiListEl.hidden = false;
apiInput.setAttribute('aria-expanded', 'true');
}
function selectApi(apiId) {
state.apiId = apiId || '';
renderApiInput();
closeApiList();
applyFilters(true);
}
function loadIndexBar() {
fetchJson(base + '/issues/dates.json'
+ buildQuery({ days: windowDays, apiId: state.apiId, kind: state.kind }))
.then(function (entries) {
renderIndexBar(entries);
renderIndexBarSelection();
})
.catch(function () { indexBar.innerHTML = ''; });
}
// ---------------- 필터 이벤트 ----------------
dateInput.addEventListener('change', function () {
const value = dateInput.value;
// 조회 기간(90일) 밖 날짜는 되돌린다
if (value && ((minDate && value < minDate) || (today && value > today))) {
dateInput.value = state.date;
return;
}
state.date = value;
applyFilters(false);
});
dateClearBtn.addEventListener('click', function () {
state.date = '';
dateInput.value = '';
applyFilters(false);
});
kindSelect.addEventListener('change', function () {
state.kind = kindSelect.value;
applyFilters(true);
});
// readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
// 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
function unlockApiInput() {
if (apiInput.hasAttribute('readonly')) {
apiInput.removeAttribute('readonly');
}
}
apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
apiInput.addEventListener('mousedown', unlockApiInput);
apiInput.addEventListener('focus', function () {
unlockApiInput();
openApiList('');
});
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
apiInput.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
renderApiInput();
closeApiList();
} else if (event.key === 'Enter') {
event.preventDefault();
const first = apiListEl.querySelector('.as-combo-item');
if (first) selectApi(first.getAttribute('data-api-id'));
}
});
apiListEl.addEventListener('mousedown', function (event) {
const item = event.target.closest('.as-combo-item');
if (!item) return;
event.preventDefault();
selectApi(item.getAttribute('data-api-id'));
});
apiClearBtn.addEventListener('click', function () {
apiInput.value = '';
selectApi('');
});
document.addEventListener('click', function (event) {
if (!apiCombo.contains(event.target)) {
renderApiInput();
closeApiList();
}
});
document.getElementById('filterResetBtn').addEventListener('click', function () {
state.date = '';
state.apiId = '';
state.kind = '';
dateInput.value = '';
kindSelect.value = '';
renderApiInput();
closeApiList();
applyFilters(true);
});
// ---------------- 초기 로딩 ----------------
dateInput.value = state.date;
kindSelect.value = state.kind;
renderIndexBarSelection();
loadIndexBar();
// 영향 API 태그의 링크 여부 판단에 오픈 API 목록이 필요하므로 옵션을 먼저 받는다
loadApiOptions().then(loadIssues);
})();
@@ -0,0 +1,399 @@
(function () {
'use strict';
const page = document.getElementById('apiStatusPage');
if (!page) return;
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const authenticated = page.classList.contains('is-authenticated');
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
const ACTIVE_INCIDENT_LIMIT = 1;
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
});
}
/** "2026-07-30T09:15:00" → Date. 서버는 서버 timezone(KST) 기준 LocalDateTime 을 보낸다. */
function parseDateTime(value) {
if (!value) return null;
const parsed = new Date(String(value).replace(' ', 'T'));
return isNaN(parsed.getTime()) ? null : parsed;
}
function pad(value) {
return value < 10 ? '0' + value : String(value);
}
function formatDate(value) {
const date = parseDateTime(value);
if (!date) return '-';
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function formatDateTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatDuration(minutes) {
if (minutes == null) return '';
if (minutes < 60) return minutes + '분';
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
}
function formatPercent(ratio) {
if (ratio == null) return '-';
return (ratio * 100).toFixed(2);
}
function issuesHref(params) {
const query = [];
if (params.date) query.push('date=' + encodeURIComponent(params.date));
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
if (params.apiName) query.push('apiName=' + encodeURIComponent(params.apiName));
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
}
/**
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis, label, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
const hiddenPill = hidden > 0
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
+ 'GW 인터페이스 ' + hidden + '건</span>'
: '';
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
}
/**
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
*/
function noticeHtml(incident) {
if (!incident.noticeDetail) return '';
const link = incident.noticeId
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
: '';
return '<div class="as-alert-notice">'
+ '<div class="as-alert-notice-head">'
+ '<span class="as-alert-notice-label">공지사항</span>'
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
+ '</div>'
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
+ link
+ '</div>';
}
/**
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
*/
function splitHtml(left, right) {
if (!left) return right;
if (!right) return left;
return '<div class="as-card-split">' + left + right + '</div>';
}
// ---------------- ❶ 진행 중 장애 ----------------
function renderActiveIncidents(incidents) {
const container = document.getElementById('activeIncidents');
if (!incidents || !incidents.length) {
container.innerHTML = '<section class="as-card">'
+ '<div class="as-card-head"><div><h2>현재 진행 중인 장애가 없습니다</h2>'
+ '<p class="as-desc">모든 API 가 정상 동작 중입니다.</p></div>'
+ '<span class="as-badge is-normal">정상</span></div></section>';
return;
}
const visible = incidents.slice(0, ACTIVE_INCIDENT_LIMIT);
const hidden = incidents.length - visible.length;
const cards = visible.map(function (incident) {
const timeline = (incident.recentTimeline || []).map(function (entry) {
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
return '<div class="as-tl-row">'
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
+ '<span>' + escapeHtml(entry.body) + '</span>'
+ '</div>';
}).join('');
return '<section class="as-card as-alert-card">'
+ '<div class="as-alert-head">'
+ ' <div><span class="as-badge is-incident">장애</span>'
+ (incident.stateLabel ? ' <span class="as-badge is-muted">' + escapeHtml(incident.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <div class="as-meta">시작 <strong>' + escapeHtml(formatDateTime(incident.startedAt)) + '</strong>'
+ ' · 경과 <strong>' + escapeHtml(formatDuration(incident.elapsedMinutes)) + '</strong></div>'
+ '</div>'
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
noticeHtml(incident))
+ '</section>';
}).join('');
const overflow = hidden > 0
? '<div class="as-alert-overflow">'
+ '<span>진행 중인 장애가 ' + escapeHtml(String(hidden)) + '건 더 있습니다.</span>'
+ '<a href="' + issuesHref({}) + '">전체 이슈 이력에서 보기 →</a>'
+ '</div>'
: '';
container.innerHTML = cards + overflow;
}
// ---------------- ❷ 90일 서비스 상태 ----------------
function renderUptime(stats) {
const chart = document.getElementById('uptimeChart');
const fromLabel = document.getElementById('uptimeFromLabel');
if (!stats || !stats.length) {
chart.innerHTML = '';
return;
}
const STATUS_LABEL = { OUTAGE: '장애', MAINTENANCE: '점검', DEGRADED: '지연' };
chart.innerHTML = stats.map(function (stat) {
const statusClass = stat.status === 'OUTAGE' ? ' is-outage'
: stat.status === 'MAINTENANCE' ? ' is-maintenance'
: stat.status === 'DEGRADED' ? ' is-degraded' : '';
const titles = (stat.issues || []).map(function (issue) {
return (KIND_LABEL[issue.kind] || '') + ' ' + issue.title;
});
const tooltip = stat.statDate + ' · ' + (STATUS_LABEL[stat.status] || '정상')
+ (titles.length ? '\n' + titles.join('\n') : '');
return '<button type="button" class="as-bar' + statusClass + '"'
+ ' data-date="' + escapeHtml(stat.statDate) + '"'
+ ' title="' + escapeHtml(tooltip) + '"></button>';
}).join('');
if (fromLabel) fromLabel.textContent = stats[0].statDate;
chart.querySelectorAll('.as-bar').forEach(function (bar) {
bar.addEventListener('click', function () {
window.location.href = issuesHref({ date: bar.getAttribute('data-date') });
});
});
}
// ---------------- ❸ My APIs ----------------
function renderMyApis(apis) {
const card = document.getElementById('myApisCard');
if (!card) return;
if (!apis || !apis.length) {
card.style.display = 'none';
return;
}
const badgeClass = {
OUTAGE: 'is-outage',
DEGRADED: 'is-degraded',
MAINTENANCE: 'is-maintenance',
NORMAL: 'is-normal'
};
document.getElementById('myApisCount').textContent = apis.length + '건';
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
const cls = badgeClass[api.currentStatus] || 'is-muted';
return '<tr data-api-id="' + escapeHtml(api.apiId) + '" data-api-name="' + escapeHtml(api.apiName) + '">'
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
+ '</tr>';
}).join('');
card.style.display = '';
card.querySelectorAll('tbody tr').forEach(function (row) {
row.addEventListener('click', function () {
window.location.href = issuesHref({
apiId: row.getAttribute('data-api-id'),
apiName: row.getAttribute('data-api-name')
});
});
});
}
// ---------------- ❹ 점검 사항 ----------------
function renderMaintenance(cards) {
const list = document.getElementById('maintenanceList');
const count = document.getElementById('maintenanceCount');
if (!cards || !cards.length) {
count.textContent = '0건';
list.innerHTML = '<div class="as-empty">예정된 점검이 없습니다.</div>';
return;
}
const now = new Date();
count.textContent = cards.length + '건';
list.innerHTML = cards.map(function (card) {
const start = parseDateTime(card.startedAt);
const ongoing = start && start <= now;
const phase = ongoing ? '진행중' : '예정';
const range = formatDateTime(card.startedAt)
+ (card.endAt ? ' ~ ' + formatDateTime(card.endAt) : '')
+ (card.durationMinutes != null ? ' (' + formatDuration(card.durationMinutes) + ')' : '');
return '<article class="as-maint-card' + (ongoing ? ' is-ongoing' : '') + '">'
+ '<div class="as-maint-head">'
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
+ '</div>'
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
noticeHtml(card))
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
+ '</div>'
+ '</article>';
}).join('');
}
// ---------------- ❺ 지난 이슈 사항 ----------------
function issueCardHtml(issue) {
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
const kindBadge = kindClass;
let inner;
if (issue.kind === 'MAINTENANCE') {
inner = '<div class="as-tl-block"><div class="as-tl-item state-RESOLVED">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">점검 완료</p>'
+ (issue.summary ? '<p class="as-tl-body">' + escapeHtml(issue.summary) + '</p>' : '')
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(issue.startedAt))
+ ' ~ ' + escapeHtml(formatDateTime(issue.endAt)) + '</p>'
+ '</div></div>';
} else {
const items = (issue.timeline || []).map(function (entry) {
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
+ '</div>';
}).join('');
inner = items
? '<div class="as-tl-block">' + items + '</div>'
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
}
const duration = issue.durationMinutes != null ? ' · ' + formatDuration(issue.durationMinutes) : '';
return '<div class="as-issue-group">'
+ '<div class="as-date-head">' + escapeHtml(formatDate(issue.startedAt)) + '</div>'
+ '<article class="as-issue-card ' + kindClass + '">'
+ ' <div class="as-issue-meta-row">'
+ ' <span class="as-badge ' + kindBadge + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
+ ' <span>' + escapeHtml(formatDateTime(issue.startedAt) + duration) + '</span>'
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
+ '</article>'
+ '</div>';
}
function renderRecentIssues(issues) {
const list = document.getElementById('recentIssueList');
if (!issues || !issues.length) {
list.innerHTML = '<div class="as-empty">종결된 이슈가 없습니다.</div>';
return;
}
list.innerHTML = issues.map(issueCardHtml).join('');
}
function handleError(target, message) {
const element = document.getElementById(target);
if (element) element.innerHTML = '<div class="as-empty">' + escapeHtml(message) + '</div>';
}
// ---------------- 초기 로딩 ----------------
// 영향 API 태그의 링크 여부를 판단해야 하므로 오픈 API 목록을 먼저 받는다
fetchJson(base + '/apis.json')
.then(function (apis) {
(apis || []).forEach(function (api) {
if (api.apiId) linkableApiIds.add(api.apiId);
});
})
.catch(function () { /* 실패 시 전부 링크 없이 표시된다 */ })
.then(loadSections);
function loadSections() {
fetchJson(base + '/active.json')
.then(renderActiveIncidents)
.catch(function () { handleError('activeIncidents', '진행 중 장애 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/uptime.json?days=' + windowDays)
.then(renderUptime)
.catch(function () { handleError('uptimeChart', '서비스 상태 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/maintenance.json')
.then(renderMaintenance)
.catch(function () { handleError('maintenanceList', '점검 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/recent-issues.json?size=5')
.then(renderRecentIssues)
.catch(function () { handleError('recentIssueList', '지난 이슈를 불러올 수 없습니다.'); });
if (authenticated) {
fetchJson(base + '/my-apis.json')
.then(renderMyApis)
.catch(function () { /* 비인증/권한 없음은 조용히 숨긴다 */ });
}
}
})();
+60 -10
View File
@@ -2,8 +2,10 @@
* 비밀번호 문자열 정책 라이브 검증 (공용)
*
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
* 규칙을 클라이언트로 포팅한다. 아이디/휴대전화 포함 규칙(noid/nomobile)은
* 사용자가 폼에 직접 입력한 값이 페이지에 이미 있을 때만 ul 의
* data-context-loginid/data-context-mobile 로 연결해 쓴다 — DB 값을 새로
* 내려받아야 하는 화면(비밀번호 변경)은 서버 AJAX(/password/content-check)로 판정한다.
*
* 사용법(마크업 구동):
* <ul class="password-policy-checklist" data-password-input="newPassword">
@@ -31,7 +33,7 @@
return false;
}
// 규칙별 판정 함수 (통과=true)
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
@@ -39,23 +41,46 @@
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); }
noseq: function (pw) { return !hasSequential(pw); },
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
noid: function (pw, ctx) {
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
return !id || pw.toUpperCase().indexOf(id) === -1;
},
// 휴대전화 하이픈 세그먼트 포함 금지 — 서버 containsMobileSegment 포팅
nomobile: function (pw, ctx) {
var m = ctx && ctx.mobile ? String(ctx.mobile) : '';
if (!m) { return true; }
var up = pw.toUpperCase();
var parts = m.split('-');
for (var i = 0; i < parts.length; i++) {
if (parts[i] && up.indexOf(parts[i]) !== -1) {
return false;
}
}
return true;
}
};
// 전체 문자열 규칙 통과 여부
function isValid(pw) {
// 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
function isValid(pw, ctx) {
if (!pw) {
return false;
}
for (var key in RULES) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
return false;
}
}
return true;
}
// 체크리스트(ul) 하나를 대상 input 에 바인딩
// bind 된 체크리스트들의 update 함수 목록 (컨텍스트 값 변경 시 refresh 용)
var updaters = [];
// 체크리스트(ul) 하나를 대상 input 에 바인딩.
// ul 의 data-context-loginid / data-context-mobile 속성에 소스 input 의 id 를 주면
// noid/nomobile 규칙이 해당 값 기준으로 라이브 판정된다.
function bind(input, list) {
var $input = (input && input.jquery) ? input : $(input);
var $list = (list && list.jquery) ? list : $(list);
@@ -64,8 +89,18 @@
return;
}
function ctxValue(attr) {
var id = $list.attr(attr);
var el = id ? document.getElementById(id) : null;
return el ? el.value : '';
}
function update() {
var pw = $input.val() || '';
var ctx = {
loginId: ctxValue('data-context-loginid'),
mobile: ctxValue('data-context-mobile')
};
$items.each(function () {
var $li = $(this);
var rule = RULES[$li.attr('data-rule')];
@@ -76,15 +111,29 @@
if (pw.length === 0) {
$li.addClass('is-idle');
} else {
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
$li.addClass(rule(pw, ctx) ? 'is-pass' : 'is-fail');
}
});
}
// 컨텍스트 소스 input 이 직접 타이핑되는 경우도 즉시 반영
['data-context-loginid', 'data-context-mobile'].forEach(function (attr) {
var id = $list.attr(attr);
if (id && document.getElementById(id)) {
$(document.getElementById(id)).on('input.passwordPolicy change.passwordPolicy', update);
}
});
updaters.push(update);
$input.on('input.passwordPolicy', update);
update();
}
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
function refresh() {
updaters.forEach(function (u) { u(); });
}
// 마크업 구동 자동 초기화
function init(root) {
var $root = root ? $(root) : $(document);
@@ -101,7 +150,8 @@
RULES: RULES,
isValid: isValid,
bind: bind,
init: init
init: init,
refresh: refresh
};
$(function () {
@@ -142,7 +142,11 @@ const customPopups = {
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
// type 은 열려 있는 동안만 password 로 전환한다.
// 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
// 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
$('#passwordPopupInput').val('').removeClass('error')
.attr('type', 'password').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -219,8 +223,8 @@ const customPopups = {
// Body 스크롤 복원
$('body').css('overflow', '');
// 입력 필드 초기화
$('#passwordPopupInput').val('').removeClass('error');
// 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다)
$('#passwordPopupInput').val('').removeClass('error').attr('type', 'text');
$('#passwordPopupError').removeClass('show').text('');
// 이벤트 리스너 제거
@@ -246,6 +250,110 @@ const customPopups = {
$('#passwordPopupError').removeClass('show');
$('#passwordPopupInput').removeClass('error');
},
/**
* API 이용 해지 신청 팝업 표시 (경고문 + 사유 필수 — 본인 확인은 step-up 2FA가 담당)
* @param {Object} options - 팝업 옵션
* @param {Function} options.onConfirm - 해지 신청 버튼 클릭 시 호출되는 콜백 (파라미터: reason)
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showTerminateRequest: function (options) {
options = options || {};
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
// 입력 필드 및 에러 초기화
$('#terminateReasonInput').val('').removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
$('#terminateRequestPopup').show();
setTimeout(function() {
$('#terminateModalBackdrop').addClass('show');
$('#terminateModal').addClass('show');
}, 10);
// Body 스크롤 방지
$('body').css('overflow', 'hidden');
// 사유 입력 필드에 포커스
setTimeout(function() {
$('#terminateReasonInput').focus();
}, 350);
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#terminatePopupConfirmButton').off('click').on('click', function () {
const reason = $('#terminateReasonInput').val().trim();
if (!reason) {
customPopups.showTerminateError('해지 사유를 입력해주세요.');
$('#terminateReasonInput').addClass('error').focus();
return;
}
if (typeof onConfirm === 'function') {
onConfirm(reason);
}
});
// 취소 버튼 이벤트
$('#terminatePopupCancelButton').off('click').on('click', function () {
customPopups.hideTerminateRequest();
if (typeof onCancel === 'function') {
onCancel();
}
});
// 닫기 버튼 이벤트
$('#terminatePopupCloseButton').off('click').on('click', function () {
customPopups.hideTerminateRequest();
if (typeof onCancel === 'function') {
onCancel();
}
});
// 입력 시 에러 초기화
$('#terminateReasonInput').off('input').on('input', function () {
$(this).removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
});
},
/**
* 해지 신청 팝업 숨기기
*/
hideTerminateRequest: function () {
// Modal 숨김 애니메이션
$('#terminateModalBackdrop').removeClass('show');
$('#terminateModal').removeClass('show');
// 애니메이션 완료 후 숨김
setTimeout(function() {
$('#terminateRequestPopup').hide();
}, 300);
// Body 스크롤 복원
$('body').css('overflow', '');
// 입력 필드 초기화
$('#terminateReasonInput').val('').removeClass('error');
$('#terminatePopupError').removeClass('show').text('');
// 이벤트 리스너 제거
$('#terminatePopupConfirmButton').off('click');
$('#terminatePopupCancelButton').off('click');
$('#terminatePopupCloseButton').off('click');
$('#terminateReasonInput').off('input');
},
/**
* 해지 신청 팝업 에러 메시지 표시
* @param {string} message - 에러 메시지
*/
showTerminateError: function (message) {
$('#terminatePopupError').text(message).addClass('show');
},
/**
* 확인 팝업 표시
* @param {string} message - 확인 메시지
@@ -407,8 +515,12 @@ const customPopups = {
return;
}
// 하이픈 유무 무관 입력을 저장 표준(010-1234-5678)으로 통일해 전달
const formattedMobile = mobile.replace(/-/g, '')
.replace(/^(01[016-9])(\d{3,4})(\d{4})$/, '$1-$2-$3');
if (typeof onConfirm === 'function') {
onConfirm(mobile, notifyConsent);
onConfirm(formattedMobile, notifyConsent);
}
});
@@ -285,20 +285,40 @@
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
transform-origin: 400px 110px;
transform-box: fill-box;
animation: pulseLine 2s ease-in-out infinite;
}
@keyframes pulseLine {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 0.5;
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@@ -598,6 +598,14 @@
color: #fff;
}
&:disabled {
background: #cbd5e1 !important;
color: #94a3b8 !important;
cursor: not-allowed !important;
transform: none !important;
pointer-events: none !important;
}
&.md {
font-size: 14px;
padding: 11px 45px;
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background-color: #fcfcfc;
&::placeholder {
color: #7f8a95;
@@ -478,7 +478,7 @@ select.form-control {
&:disabled,
&.input-readonly {
background: $gray-bg;
background: #e9ecef !important;
color: $text-gray;
cursor: not-allowed;
}
@@ -1010,6 +1010,7 @@ select.form-control {
resize: none;
transition: border-color 0.2s;
min-height: 250px;
background-color: #fcfcfc;
&::placeholder {
color: #94A3B8;
@@ -49,6 +49,7 @@
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
white-space: nowrap;
&:hover {
background-color: var(--accent-light);
@@ -63,6 +64,7 @@
background-color: var(--primary-color);
padding: 10px 24px;
border-radius: 8px;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
transition: var(--transition-smooth);
@@ -199,7 +199,7 @@
p {
margin: 0;
& + p {
&+p {
margin-top: $spacing-md;
}
}
@@ -240,7 +240,6 @@
.btn,
.btn-modal-cancel,
.btn-modal-confirm {
flex: 1;
min-width: 0;
height: 46px;
font-size: 15px;
@@ -330,4 +329,4 @@
font-size: $font-size-base;
}
}
}
}
@@ -29,15 +29,15 @@
line-height: 1;
&::before {
content: "\2022"; // •
content: "";
}
}
&.is-idle {
color: #888;
color: #718096;
.policy-icon {
color: #b5b5b5;
color: #a0aec0;
}
}
@@ -71,5 +71,5 @@
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
color: #ef5555;
}
@@ -65,6 +65,19 @@
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
@@ -89,7 +89,7 @@
}
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
height: clamp(44px, 11.73vw, 60px);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
@@ -128,9 +128,31 @@
.header-left {
display: flex;
align-items: center;
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
flex-shrink: 0;
.logo {
height: 32px;
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
img { flex-shrink: 0; }
.mobile-logo-link {
align-items: flex-end;
flex-shrink: 0;
}
.mobile-logo-text {
align-items: flex-end;
line-height: 1;
padding-bottom: 2px;
// "API / Portal" 로 접히지 않게
white-space: nowrap;
flex-shrink: 0;
}
}
}
@@ -158,7 +180,7 @@
// Logo with modern styling
.logo {
display: block;
display: flex;
height: 32px;
vertical-align: middle;
z-index: 10;
@@ -202,7 +224,6 @@
// Logo link styles
.logo-link,
.mobile-logo-link,
.drawer-logo-link {
display: inline-block;
text-decoration: none;
@@ -252,7 +273,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: none;
}
}
@@ -263,7 +284,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: flex;
}
}
@@ -913,7 +934,7 @@
.nav-menu {
display: flex;
list-style: none;
gap: 28px;
gap: 24px;
margin: 0;
padding: 0;
@@ -925,6 +946,7 @@
>.nav-link {
background: var(--light-bg);
color: var(--primary-blue);
border-radius: 9px;
}
.sub-menu {
@@ -936,7 +958,7 @@
.sub-menu {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 17px);
left: 0;
min-width: 200px;
background: var(--white);
@@ -978,9 +1000,11 @@
font-size: 16px;
font-weight: 500;
color: var(--text-gray);
padding: 8px;
padding: 12px;
position: relative;
transition: var(--transition-smooth);
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
white-space: nowrap;
&:hover {
color: var(--primary-blue);
@@ -1095,7 +1119,7 @@
// Mobile Responsive Design
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
// ===========================
@media (max-width: 768px) {
@media (max-width: 1024px) {
.global-header {
// Figma: 44px 높이, 반투명 배경
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
@@ -1246,7 +1270,7 @@
}
// Mobile responsive
@media (max-width: 768px) {
@media (max-width: 1024px) {
.header-user-info {
display: none; // Hide on mobile, use drawer instead
}

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