Compare commits
50 Commits
68f8c07fc0
...
design
| Author | SHA1 | Date | |
|---|---|---|---|
| 63d4d7268c | |||
| c42a278c8e | |||
| 83352e3669 | |||
| 317b8d781d | |||
| 206e72edde | |||
| 34dce308dc | |||
| b3fc5c06bf | |||
| eb74a99a5a | |||
| 38e5a7f13c | |||
| 3b7c2e5a8f | |||
| 47099ec485 | |||
| 6dbf6af3de | |||
| 5585df9133 | |||
| 2c63fa1557 | |||
| d71af34950 | |||
| fbc145d145 | |||
| f36b1a6478 | |||
| 8f03209f8a | |||
| 8743592fde | |||
| a0685c8689 | |||
| c0ae60f738 | |||
| b45a7a162a | |||
| 07fbe1ba54 | |||
| 4916bdf4af | |||
| 35621b5174 | |||
| b97b0fcf7b | |||
| 6cb98d36bb | |||
| fd229fca43 | |||
| 5e8d08f1af | |||
| e7945233fe | |||
| 815e2bdd04 | |||
| 0629e842a3 | |||
| 6fa2167378 | |||
| 84e873a08b | |||
| 6c231c4189 | |||
| e3b5a44270 | |||
| 829630ae5c | |||
| 2a9694cd85 | |||
| 456b635a32 | |||
| faaf9e8cd6 | |||
| 4749613947 | |||
| 5125dab897 | |||
| 02eb81af67 | |||
| c02a5ff047 | |||
| 4181d114c1 | |||
| daff07c006 | |||
| 1f51c9b21e | |||
| 929980c089 | |||
| d4d5d66727 | |||
| d3e6249a41 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
+6
-3
@@ -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'
|
||||
|
||||
@@ -203,3 +204,5 @@ task printSourceSets {
|
||||
}
|
||||
}
|
||||
}
|
||||
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
|
||||
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
|
||||
*
|
||||
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
|
||||
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
|
||||
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
|
||||
*
|
||||
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
|
||||
* classpath 나 WAR 산출물에는 포함되지 않는다.
|
||||
*
|
||||
* 시트: 요약 / WAR 기준
|
||||
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
|
||||
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
|
||||
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
|
||||
*/
|
||||
buildscript {
|
||||
repositories {
|
||||
maven {
|
||||
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'org.apache.poi:poi-ooxml:3.17'
|
||||
}
|
||||
}
|
||||
|
||||
import groovy.json.JsonSlurper
|
||||
import org.apache.poi.ss.usermodel.BorderStyle
|
||||
import org.apache.poi.ss.usermodel.FillPatternType
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||
import org.apache.poi.ss.usermodel.IndexedColors
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||
import org.apache.poi.ss.util.CellRangeAddress
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
|
||||
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
|
||||
ext.SBOM_CELL_LIMIT = 32000
|
||||
|
||||
task sbomXlsx {
|
||||
group = 'sbom'
|
||||
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
|
||||
|
||||
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
|
||||
if (!project.hasProperty('sbomJson')) {
|
||||
dependsOn 'cyclonedxBom'
|
||||
}
|
||||
|
||||
doLast {
|
||||
File src = resolveBomJson(project)
|
||||
File out = project.hasProperty('sbomOut')
|
||||
? project.file(project.property('sbomOut'))
|
||||
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
|
||||
out.parentFile.mkdirs()
|
||||
|
||||
def bom = new JsonSlurper().parse(src, 'UTF-8')
|
||||
def deploy = collectDeployJars(project)
|
||||
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
|
||||
|
||||
def wb = new XSSFWorkbook()
|
||||
def st = createStyles(wb)
|
||||
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
|
||||
writeWarSheet(wb, st, warRows)
|
||||
|
||||
out.withOutputStream { os -> wb.write(os) }
|
||||
wb.close()
|
||||
|
||||
int unmatched = warRows.count { it.matched == 'N' }
|
||||
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
|
||||
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
|
||||
}
|
||||
}
|
||||
|
||||
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
|
||||
String sbomFileName(Project p) {
|
||||
def warTask = p.tasks.findByName('war')
|
||||
if (warTask != null) {
|
||||
String archive = warTask.archiveFileName.get()
|
||||
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
|
||||
}
|
||||
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
|
||||
return "${p.name}${ver}-sbom.xlsx"
|
||||
}
|
||||
|
||||
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
|
||||
File resolveBomJson(Project p) {
|
||||
if (p.hasProperty('sbomJson')) {
|
||||
File f = p.file(p.property('sbomJson'))
|
||||
if (!f.exists()) {
|
||||
throw new GradleException("bom.json 없음: ${f.absolutePath}")
|
||||
}
|
||||
return f
|
||||
}
|
||||
def candidates = [
|
||||
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
|
||||
new File(p.buildDir, 'reports/bom.json'),
|
||||
]
|
||||
File found = candidates.find { it.exists() }
|
||||
if (found == null) {
|
||||
throw new GradleException(
|
||||
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
|
||||
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 배포물에 packaging 되는 jar 목록.
|
||||
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
|
||||
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
|
||||
*
|
||||
* @return [label: 기준 설명, jars: 행 목록]
|
||||
*/
|
||||
Map collectDeployJars(Project p) {
|
||||
def cfg = p.configurations.findByName('runtimeClasspath')
|
||||
if (cfg == null) {
|
||||
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
|
||||
return [label: '(없음)', jars: []]
|
||||
}
|
||||
|
||||
def warTask = p.tasks.findByName('war')
|
||||
def files
|
||||
String label
|
||||
if (warTask != null) {
|
||||
files = warTask.classpath.files
|
||||
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
|
||||
} else {
|
||||
files = cfg.files
|
||||
label = 'runtimeClasspath (war 태스크 없음)'
|
||||
}
|
||||
|
||||
def coordByFile = [:]
|
||||
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
|
||||
def id = a.moduleVersion.id
|
||||
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
|
||||
}
|
||||
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
|
||||
def c = coordByFile[f]
|
||||
[
|
||||
file : f.name,
|
||||
group : c?.group ?: '',
|
||||
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
|
||||
version: c?.version ?: '',
|
||||
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
|
||||
]
|
||||
}.sort { it.file }
|
||||
|
||||
return [label: label, jars: jars]
|
||||
}
|
||||
|
||||
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
|
||||
Map indexComponents(bom) {
|
||||
String rootRef = bom.metadata?.component?.'bom-ref'
|
||||
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
|
||||
|
||||
def index = [:]
|
||||
bom.components?.each { c ->
|
||||
def hashes = [:]
|
||||
c.hashes?.each { h -> hashes[h.alg] = h.content }
|
||||
|
||||
def licenses = (c.licenses ?: []).collect { l ->
|
||||
l.license?.id ?: l.license?.name ?: l.expression ?: ''
|
||||
}.findAll { it }
|
||||
|
||||
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
|
||||
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
|
||||
licenses : licenses.join('; '),
|
||||
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
|
||||
purl : c.purl ?: '',
|
||||
sha256 : hashes['SHA-256'] ?: '',
|
||||
sha1 : hashes['SHA-1'] ?: '',
|
||||
description: c.description ?: '',
|
||||
]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
|
||||
List joinWarRows(List warJars, Map index) {
|
||||
def result = []
|
||||
warJars.eachWithIndex { j, i ->
|
||||
def m = j.coord ? index[j.coord] : null
|
||||
result << [
|
||||
no : i + 1,
|
||||
file : j.file,
|
||||
group : j.group,
|
||||
name : j.name,
|
||||
version : j.version,
|
||||
direct : m?.direct ?: '',
|
||||
licenses : m?.licenses ?: '',
|
||||
licenseList: m?.licenseList ?: ['(미상)'],
|
||||
purl : m?.purl ?: '',
|
||||
sha256 : m?.sha256 ?: '',
|
||||
sha1 : m?.sha1 ?: '',
|
||||
matched : (m != null) ? 'Y' : 'N',
|
||||
description: m?.description ?: '',
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Map createStyles(wb) {
|
||||
def headFont = wb.createFont()
|
||||
headFont.setBold(true)
|
||||
headFont.setColor(IndexedColors.WHITE.getIndex())
|
||||
|
||||
def head = wb.createCellStyle()
|
||||
head.setFont(headFont)
|
||||
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
|
||||
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
|
||||
head.setAlignment(HorizontalAlignment.CENTER)
|
||||
head.setVerticalAlignment(VerticalAlignment.CENTER)
|
||||
head.setBorderBottom(BorderStyle.THIN)
|
||||
|
||||
def body = wb.createCellStyle()
|
||||
body.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
|
||||
def wrap = wb.createCellStyle()
|
||||
wrap.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
wrap.setWrapText(true)
|
||||
|
||||
def labelFont = wb.createFont()
|
||||
labelFont.setBold(true)
|
||||
def label = wb.createCellStyle()
|
||||
label.setFont(labelFont)
|
||||
|
||||
return [head: head, body: body, wrap: wrap, label: label]
|
||||
}
|
||||
|
||||
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
|
||||
def writeHeader(sheet, style, List<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;
|
||||
}
|
||||
+17
@@ -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 = "인증";
|
||||
|
||||
@@ -64,6 +66,12 @@ public class ApiController {
|
||||
|
||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
|
||||
|
||||
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
|
||||
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
|
||||
if (apiGroup != null) {
|
||||
api.setApiGroupName(apiGroup.getGroupName());
|
||||
}
|
||||
|
||||
model.addAttribute("apiSpecInfo", api);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
@@ -80,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";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
@@ -37,13 +38,18 @@ public class APISender {
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
|
||||
String response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -85,11 +91,15 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -110,6 +120,12 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
|
||||
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
|
||||
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
outputStream.write(requestBodyBytes);
|
||||
@@ -120,7 +136,7 @@ public class APISender {
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
|
||||
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||
long auditStart = System.currentTimeMillis();
|
||||
String auditType = "-";
|
||||
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
|
||||
String proxyTarget = null;
|
||||
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
|
||||
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
|
||||
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
|
||||
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||
String requestBody = readBody(httpServletRequest);
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
|
||||
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
|
||||
|
||||
if (tokenRequest) {
|
||||
String body = requestBody;
|
||||
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
if (mockTokenRequest) {
|
||||
auditType = "TOKEN_MOCK";
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
proxyTarget = target;
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
|
||||
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||
}
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
@@ -191,6 +202,15 @@ public class ApiTesterFilter implements Filter {
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
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())) {
|
||||
@@ -198,23 +218,34 @@ 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);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
|
||||
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} finally {
|
||||
@@ -223,7 +254,14 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||
/**
|
||||
* 요청 본문 전체를 문자열로 읽는다.
|
||||
*
|
||||
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
|
||||
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
|
||||
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
|
||||
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
|
||||
*/
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
@@ -231,6 +269,39 @@ public class ApiTesterFilter implements Filter {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
if (sb.length() == 0 && isFormUrlEncoded(request)) {
|
||||
String rebuilt = rebuildFormBodyFromParams(request);
|
||||
if (!rebuilt.isEmpty()) {
|
||||
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
|
||||
return rebuilt;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
|
||||
private boolean isFormUrlEncoded(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
|
||||
private String rebuildFormBodyFromParams(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
for (String value : entry.getValue()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
try {
|
||||
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
|
||||
.append('=')
|
||||
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -250,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,8 +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;
|
||||
@@ -49,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/myapikey")
|
||||
@RequestMapping("/clients")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
||||
public class MyAppController {
|
||||
@@ -81,7 +84,8 @@ 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;
|
||||
|
||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||
|
||||
@@ -113,14 +117,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
||||
|
||||
if (appRequest == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록 조회 및 설정
|
||||
@@ -151,14 +155,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
||||
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록에 서비스 정보 추가
|
||||
@@ -179,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);
|
||||
}
|
||||
@@ -234,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")
|
||||
@@ -253,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;
|
||||
}
|
||||
|
||||
@@ -318,10 +331,12 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
|
||||
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
|
||||
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||
*
|
||||
* @param requestData clientId, password 포함
|
||||
* @param requestData clientId 포함
|
||||
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||
*/
|
||||
@PostMapping("/credential/reveal-secret")
|
||||
@@ -331,7 +346,6 @@ public class MyAppController {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
String clientId = requestData.get("clientId");
|
||||
String password = requestData.get("password");
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
@@ -341,14 +355,7 @@ public class MyAppController {
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 1. 본인 확인 (비밀번호)
|
||||
if (!appServiceFacade.verifyUserPassword(user, password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
|
||||
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
|
||||
try {
|
||||
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||
if (secret == null) {
|
||||
@@ -430,7 +437,7 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
||||
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey/api_key_request/history");
|
||||
return new ModelAndView("redirect:/clients/api_key_request/history");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -528,7 +535,7 @@ public class MyAppController {
|
||||
registration.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -593,7 +600,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
||||
@@ -624,7 +631,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,7 +649,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
@@ -651,7 +658,7 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -668,12 +675,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step3");
|
||||
return new ModelAndView("redirect:/clients/register/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,7 +698,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (registrationSuccess == null || !registrationSuccess) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -710,7 +717,7 @@ public class MyAppController {
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
// 등록과 관련된 세션 데이터 초기화
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,13 +761,15 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
public ModelAndView modifyStep1(
|
||||
@RequestParam(value = "clientId", required = false) String clientId,
|
||||
@RequestParam(value = "goto", required = false) String gotoStep,
|
||||
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (clientId == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -768,7 +777,7 @@ public class MyAppController {
|
||||
// 기존 API Key 정보 조회
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 새로운 수정 세션 시작시에만 초기화
|
||||
@@ -810,6 +819,16 @@ public class MyAppController {
|
||||
model.addAttribute("apiKeyModification", modification);
|
||||
}
|
||||
|
||||
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
|
||||
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
|
||||
if ("apis".equals(gotoStep)) {
|
||||
String redirectUrl = "redirect:/clients/modify/step2";
|
||||
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
|
||||
redirectUrl += "?apiApplyToast=" + apiApplyToast;
|
||||
}
|
||||
return new ModelAndView(redirectUrl);
|
||||
}
|
||||
|
||||
// Step 모델 설정
|
||||
setupStepModel(model, 1);
|
||||
model.addAttribute("userOrg", user.getPortalOrg());
|
||||
@@ -861,7 +880,7 @@ public class MyAppController {
|
||||
modification.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -883,7 +902,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 서비스 카테고리와 API 목록 가져오기
|
||||
@@ -893,6 +912,8 @@ public class MyAppController {
|
||||
setupStepModel(model, 2);
|
||||
model.addAttribute("apiServices", apiServices);
|
||||
model.addAttribute("modification", modification);
|
||||
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||
|
||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||
}
|
||||
@@ -914,7 +935,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -927,18 +948,19 @@ public class MyAppController {
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
@@ -947,7 +969,15 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!modification.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
|
||||
if (isAppModifyTwofaRequired()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
|
||||
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -964,12 +994,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step3");
|
||||
return new ModelAndView("redirect:/clients/modify/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,7 +1016,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (modificationComplete == null || !modificationComplete) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -1015,12 +1045,18 @@ public class MyAppController {
|
||||
|
||||
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
||||
if (clientId != null && !clientId.isEmpty()) {
|
||||
return "redirect:/myapikey/credential_detail?id=" + clientId;
|
||||
return "redirect:/clients/credential_detail?id=" + clientId;
|
||||
} else {
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
}
|
||||
|
||||
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
|
||||
private boolean isAppModifyTwofaRequired() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
|
||||
// 승인정보(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,23 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
/**
|
||||
* 수신자 이름을 지정해 인증번호를 발송한다. 메시지 템플릿의 %USER_NAME% 치환에 사용되며,
|
||||
* 회원가입·아이디/비밀번호 찾기처럼 사용자 이름을 알 수 없는 흐름은 "guest" 를 넘긴다.
|
||||
* username 이 비어 있으면 %USER_NAME% 은 치환되지 않고 원문이 그대로 남는다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, String username) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime, username);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
return sendRequestAuthNumber(recipientKey, msgType, ttlSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds, String username) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
String authNumber = generator.generateAuthNumber();
|
||||
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
|
||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
@@ -99,9 +111,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
});
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType) {
|
||||
private MessageRecipient createMessageRecipient(String recipientKey, String msgType, String username) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(recipientKey);
|
||||
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
|
||||
if (username != null && !username.trim().isEmpty()) {
|
||||
recipient.setUsername(username);
|
||||
}
|
||||
if ("SMS".equalsIgnoreCase(msgType)) {
|
||||
recipient.setPhone(recipientKey);
|
||||
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
||||
|
||||
+16
-8
@@ -11,7 +11,8 @@ import java.util.Set;
|
||||
* <p>검증 레벨(완화 정책)</p>
|
||||
* <ul>
|
||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||
* </ul>
|
||||
@@ -40,15 +41,17 @@ public final class StepUpProtectedPaths {
|
||||
}
|
||||
|
||||
/** Secret 키 조회 (AJAX POST) */
|
||||
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
|
||||
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
|
||||
/** 앱 해지 신청 (AJAX POST) */
|
||||
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
|
||||
/** 앱 정보 수정 페이지 진입 (GET) */
|
||||
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
|
||||
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
|
||||
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||
public static final String WITHDRAW = "/withdraw";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
|
||||
static {
|
||||
Map<String, String> keys = new LinkedHashMap<>();
|
||||
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
|
||||
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
|
||||
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||
|
||||
Map<String, Level> levels = new LinkedHashMap<>();
|
||||
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
||||
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR);
|
||||
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
|
||||
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
||||
levels.put(MYPAGE, Level.PASSWORD);
|
||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||
|
||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||
guarded.add(REVEAL_SECRET);
|
||||
guarded.add(APP_MODIFY_STEP1);
|
||||
guarded.add(APP_KEY_DELETE);
|
||||
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
@@ -32,6 +34,33 @@ public class TwoFactorProperties {
|
||||
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 2FA 적용 대상 역할인지 여부.
|
||||
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
|
||||
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
|
||||
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
|
||||
*/
|
||||
public boolean isLoginTargetRole(RoleCode roleCode) {
|
||||
if (roleCode == null) {
|
||||
roleCode = RoleCode.ROLE_USER;
|
||||
}
|
||||
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
|
||||
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if ("ALL".equalsIgnoreCase(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
for (String token : trimmed.split(",")) {
|
||||
if (roleCode.name().equalsIgnoreCase(token.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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 javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
|
||||
*
|
||||
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
|
||||
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
|
||||
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/login/duplicate")
|
||||
@RequiredArgsConstructor
|
||||
public class DuplicateLoginController {
|
||||
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
|
||||
@PostMapping("/confirm")
|
||||
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String redirect = duplicateLoginService.confirm(request, session);
|
||||
if (redirect != null) {
|
||||
result.put("valid", true);
|
||||
result.put("redirect", redirect);
|
||||
} else {
|
||||
result.put("valid", false);
|
||||
result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 확인 취소 — 로그인 포기(익명 유지) */
|
||||
@PostMapping("/cancel")
|
||||
public Map<String, Object> cancel(HttpSession session) {
|
||||
duplicateLoginService.cancel(session);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("valid", true);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
||||
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -24,9 +25,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
|
||||
public class LoginHandler implements PageHandler {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
public LoginHandler(TwoFactorService twoFactorService) {
|
||||
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.duplicateLoginService = duplicateLoginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
|
||||
|
||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
||||
model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session));
|
||||
boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
|
||||
model.addAttribute("twoFactorPending", twoFactorPending);
|
||||
|
||||
// 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
|
||||
// - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
|
||||
// - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
|
||||
String pendingLoginId = null;
|
||||
boolean duplicateConfirmPending = false;
|
||||
if (twoFactorPending) {
|
||||
pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
|
||||
} else if (duplicateLoginService.hasPending(session)
|
||||
&& "1".equals(httpRequest.getParameter("duplicate"))) {
|
||||
pendingLoginId = duplicateLoginService.pendingLoginId(session);
|
||||
duplicateConfirmPending = true;
|
||||
}
|
||||
model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
|
||||
model.addAttribute("duplicateInfo",
|
||||
pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
|
||||
|
||||
// 이미 인증된 사용자인지 확인
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.eactive.apim.portal.apps.login.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 로그인 시 동시 접속(중복 세션) 확인 처리.
|
||||
*
|
||||
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
|
||||
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
|
||||
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
|
||||
*
|
||||
* <p>두 경로에서 쓰인다:
|
||||
* <ul>
|
||||
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
|
||||
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
|
||||
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
|
||||
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
|
||||
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
|
||||
* </ul></p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class DuplicateLoginService {
|
||||
|
||||
/** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
|
||||
public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
|
||||
/** 동시 접속 확인 대기 - loginId */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
|
||||
/** 동시 접속 확인 대기 - 진입 시각 */
|
||||
public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
|
||||
|
||||
/** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
|
||||
public static final int PENDING_TTL_SECONDS = 120;
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
/** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveSession(String loginId) {
|
||||
return activeSession(loginId).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
|
||||
* 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, String> activeSessionInfo(String loginId) {
|
||||
Optional<UserSession> active = activeSession(loginId);
|
||||
if (!active.isPresent()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> info = new HashMap<>();
|
||||
info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
|
||||
info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
|
||||
return info;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2FA off 경로: 확정 보류 → 확인 → 확정
|
||||
// =========================================================================
|
||||
|
||||
/** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void begin(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
public boolean hasPending(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
public String pendingLoginId(HttpSession session) {
|
||||
return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
|
||||
* 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
|
||||
*/
|
||||
public String confirm(HttpServletRequest request, HttpSession session) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
Object at = session.getAttribute(ATTR_PENDING_AT);
|
||||
cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
|
||||
|
||||
if (userId == null || !(at instanceof LocalDateTime)
|
||||
|| ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PortalUser user = portalUserRepository.findById(userId).orElse(null);
|
||||
if (user == null || !isLoginStillAllowed(user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
/** 확인 취소 — 대기 상태 정리(익명 유지) */
|
||||
public void cancel(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
session.removeAttribute(ATTR_PENDING_AT);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private Optional<UserSession> activeSession(String loginId) {
|
||||
if (loginId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return userSessionService.getActiveSession(loginId.toLowerCase());
|
||||
}
|
||||
|
||||
/** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
|
||||
private boolean isLoginStillAllowed(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return false;
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return false;
|
||||
}
|
||||
return user.getPortalOrg() == null
|
||||
|| PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
|
||||
*/
|
||||
private static String maskIpAddress(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return "알 수 없음";
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length == 4) {
|
||||
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
||||
}
|
||||
// IPv6 등 다른 형식은 일부만 표시
|
||||
if (ip.length() > 8) {
|
||||
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
||||
}
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,14 @@ public class LoginFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
|
||||
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
|
||||
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
|
||||
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
|
||||
session.setAttribute("checkClientRegister", true);
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.main.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
||||
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
|
||||
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.util.List;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -17,6 +21,7 @@ public class IndexController {
|
||||
|
||||
private final MainApiFacade mainApiFacade;
|
||||
private final IndexStatisticsService indexStatisticsService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
/**
|
||||
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
|
||||
@@ -29,7 +34,7 @@ public class IndexController {
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/")
|
||||
public String index(Model model) {
|
||||
public String index(Model model, HttpSession session) {
|
||||
|
||||
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
|
||||
List<String> hashTags = mainApiFacade.getHashTags();
|
||||
@@ -39,9 +44,37 @@ public class IndexController {
|
||||
|
||||
addAttributesToModel(model, services, hashTags, statistics);
|
||||
|
||||
resolveClientRegisterNudge(model, session);
|
||||
|
||||
return "apps/main/index";
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
|
||||
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
|
||||
*
|
||||
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
|
||||
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
|
||||
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
|
||||
*/
|
||||
private void resolveClientRegisterNudge(Model model, HttpSession session) {
|
||||
if (session.getAttribute("checkClientRegister") == null) {
|
||||
return;
|
||||
}
|
||||
session.removeAttribute("checkClientRegister"); // 1회 소비
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user == null || user.getPortalOrg() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|
||||
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
|
||||
if (!hasClient) {
|
||||
model.addAttribute("needClientRegister", true);
|
||||
}
|
||||
}
|
||||
|
||||
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
|
||||
|
||||
model.addAttribute("services", apiServices);
|
||||
|
||||
+5
-49
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.apps.session.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -9,23 +8,23 @@ import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
|
||||
* 세션 타이머/유휴 로그아웃 처리용 REST API.
|
||||
*
|
||||
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
|
||||
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
|
||||
* {@code DuplicateLoginService} 가 처리한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||
* </ul>
|
||||
@@ -36,32 +35,8 @@ import java.util.Optional;
|
||||
@RequiredArgsConstructor
|
||||
public class SessionApiController {
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
/**
|
||||
* 로그인 전 중복 세션 확인
|
||||
*/
|
||||
@PostMapping("/check-duplicate")
|
||||
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
|
||||
|
||||
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
|
||||
|
||||
if (activeSession.isPresent()) {
|
||||
UserSession session = activeSession.get();
|
||||
result.put("duplicateSession", true);
|
||||
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
|
||||
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
|
||||
} else {
|
||||
result.put("duplicateSession", false);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 상태 폴링 (인증 필요)
|
||||
*/
|
||||
@@ -142,23 +117,4 @@ public class SessionApiController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||
* 예: 192.168.240.178 → 192.168.***.178
|
||||
*/
|
||||
private String maskIpAddress(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return "알 수 없음";
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length == 4) {
|
||||
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
||||
}
|
||||
// IPv6 등 다른 형식은 일부만 표시
|
||||
if (ip.length() > 8) {
|
||||
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
||||
}
|
||||
return "***";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -136,7 +143,8 @@ public class AccountController {
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success",
|
||||
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
@@ -182,6 +190,9 @@ public class AccountController {
|
||||
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
||||
mav.addObject("user", user);
|
||||
|
||||
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
|
||||
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
|
||||
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
@@ -401,21 +412,35 @@ public class AccountController {
|
||||
|
||||
@PostMapping("/withdraw")
|
||||
public String processWithdrawal(
|
||||
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
// 탈퇴 사유 필수
|
||||
if (withdrawReason == null || withdrawReason.trim().isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "탈퇴 사유를 입력해 주세요.");
|
||||
return "redirect:/mypage";
|
||||
}
|
||||
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않는다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
if (isWithdrawTwofaRequired()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.WITHDRAW)) {
|
||||
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
return "redirect:/mypage";
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 회원 탈퇴 처리
|
||||
if (currentUser != null) {
|
||||
userFacade.withdrawUser(currentUser.getId());
|
||||
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
|
||||
}
|
||||
|
||||
session.invalidate();
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 됩니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴가 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
|
||||
return "redirect:/";
|
||||
} catch (IllegalArgumentException e) {
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
@@ -423,6 +448,12 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 회원 탈퇴({@code /withdraw}) 반영 직전 2FA 를 요구할지 여부 */
|
||||
private boolean isWithdrawTwofaRequired() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.WITHDRAW);
|
||||
}
|
||||
|
||||
@GetMapping("/mypage/verification-email")
|
||||
public String showVerificationEmailPage(Model model) {
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
private final AuthNoticeProperties authNoticeProperties;
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
||||
|
||||
/**
|
||||
* 회원가입·아이디/비밀번호 찾기 등 로그인 이전 흐름은 수신자 이름을 알 수 없으므로
|
||||
* 메시지 템플릿 %USER_NAME% 자리에 넣을 기본값.
|
||||
*/
|
||||
private static final String GUEST_USER_NAME = "guest";
|
||||
|
||||
|
||||
/**
|
||||
* 인증 요청
|
||||
@@ -43,7 +49,7 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
}
|
||||
|
||||
try {
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
|
||||
response.setValid(true);
|
||||
response.setMessage("인증번호를 발송하였습니다.");
|
||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||
|
||||
@@ -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,11 +10,14 @@ 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);
|
||||
|
||||
void withdrawUser(String userId);
|
||||
void withdrawUser(String userId, String withdrawalReason);
|
||||
|
||||
void activateUserByEmail(String email);
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -131,7 +137,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void withdrawUser(String userId) {
|
||||
public void withdrawUser(String userId, String withdrawalReason) {
|
||||
PortalUser user = portalUserService.findById(userId);
|
||||
|
||||
// 법인 관리자 탈퇴 제한
|
||||
@@ -147,7 +153,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
// 메시지 요청정보 삭제
|
||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||
|
||||
portalUserService.deleteUser(user);
|
||||
portalUserService.deleteUser(user, withdrawalReason);
|
||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+13
-11
@@ -5,12 +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.common.util.StringMaskingUtil;
|
||||
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;
|
||||
@@ -50,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;
|
||||
@@ -76,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()));
|
||||
@@ -136,12 +136,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
|
||||
return users.stream().map(user -> {
|
||||
PortalUserDTO dto = portalUserMapper.toDTO(user);
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
|
||||
dto.setLoginId(null);
|
||||
return dto;
|
||||
}).collect(Collectors.toList());
|
||||
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
|
||||
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
|
||||
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
throw e;
|
||||
@@ -155,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("유효하지 않은 휴대폰 번호 형식입니다.");
|
||||
}
|
||||
@@ -164,6 +163,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
|
||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||
portalUser.setPasswordChangeDate(null);
|
||||
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
|
||||
portalUser.setAccountLockYn("N");
|
||||
}
|
||||
@@ -186,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())) {
|
||||
|
||||
@@ -280,7 +280,7 @@ public class PortalUserService {
|
||||
return portalUserRepository.save(user);
|
||||
}
|
||||
|
||||
public void deleteUser(PortalUser user) {
|
||||
public void deleteUser(PortalUser user, String withdrawalReason) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
||||
|
||||
@@ -290,6 +290,12 @@ public class PortalUserService {
|
||||
user.setMobileNumber("");
|
||||
user.setPasswordHash("");
|
||||
|
||||
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
|
||||
if (withdrawalReason != null && withdrawalReason.length() > 200) {
|
||||
withdrawalReason = withdrawalReason.substring(0, 200);
|
||||
}
|
||||
user.setWithdrawalReason(withdrawalReason);
|
||||
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
|
||||
|
||||
portalUserRepository.save(user);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private AuthNoticeProperties authNoticeProperties;
|
||||
|
||||
@Autowired
|
||||
private RelatedSiteService relatedSiteService;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("relatedSitesLabel")
|
||||
public String relatedSitesLabel() {
|
||||
return relatedSiteService.getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
|
||||
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
|
||||
*/
|
||||
@ModelAttribute("relatedSites")
|
||||
public List<RelatedSite> relatedSites() {
|
||||
return relatedSiteService.getSites();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-22
@@ -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();
|
||||
|
||||
+6
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+83
-21
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -20,6 +21,8 @@ import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
@@ -29,15 +32,19 @@ import java.util.Map;
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
||||
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
||||
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
||||
* 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@@ -46,13 +53,14 @@ import java.util.Map;
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
||||
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
||||
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
||||
PortalPropertyService portalPropertyService) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
this.portalPropertyService = portalPropertyService;
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertAllowedIp(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
int totalSkipped = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
totalSkipped += (int) r.get("skipped");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("damoMode", resolveDamoMode());
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
||||
response.put("totalSkipped", totalSkipped);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
||||
return response;
|
||||
}
|
||||
|
||||
private String resolveDamoMode() {
|
||||
if (converter.isBypassMode()) {
|
||||
return "BYPASS";
|
||||
}
|
||||
return converter.isFakeMode() ? "FAKE" : "REAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("skipped", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
int skipped = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
||||
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
||||
skipped++;
|
||||
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
||||
table, column, value.length());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
r.put("skipped", skipped);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -169,21 +207,45 @@ public class LegacyEncryptionMigrationController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
||||
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
||||
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
private void assertAllowedIp(HttpServletRequest request) {
|
||||
String remote = canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
|
||||
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
||||
.map(String::trim)
|
||||
.filter(ip -> !ip.isEmpty())
|
||||
.map(LegacyEncryptionMigrationController::canonicalize)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (viaProxy || !allowed.contains(remote)) {
|
||||
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
||||
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
|
||||
/** IPv6 loopback 표기 통일 */
|
||||
private static String canonicalize(String ip) {
|
||||
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,44 @@ public class StringMaskingUtil {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
"refresh_token", "access_token", "id_token", "code", "assertion"));
|
||||
|
||||
/**
|
||||
* {@code application/x-www-form-urlencoded} 본문 로깅 시 민감 파라미터(client_secret/password 등)의
|
||||
* 값을 {@link #maskToken(String)} 규칙(앞4·뒤4)으로 마스킹한다. client_id·grant_type·scope 등은
|
||||
* "client not found" 류 원인 파악을 위해 원본 그대로 남긴다. 폼 형식이 아니면 원본을 반환한다(멱등).
|
||||
* <pre>grant_type=client_credentials&client_id=ABC&client_secret=s3cr3tValue → …&client_secret=s3cr***alue</pre>
|
||||
*/
|
||||
public static String maskFormBody(String body) {
|
||||
if (!isValidString(body) || body.indexOf('=') < 0) {
|
||||
return body;
|
||||
}
|
||||
String[] pairs = body.split("&");
|
||||
StringBuilder sb = new StringBuilder(body.length());
|
||||
for (int i = 0; i < pairs.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
String pair = pairs[i];
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq < 0) {
|
||||
sb.append(pair);
|
||||
continue;
|
||||
}
|
||||
String key = pair.substring(0, eq);
|
||||
String value = pair.substring(eq + 1);
|
||||
if (SENSITIVE_FORM_PARAMS.contains(key.toLowerCase())) {
|
||||
sb.append(key).append('=').append(maskToken(value));
|
||||
} else {
|
||||
sb.append(pair);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
public static String maskName(String name) {
|
||||
return maskName(name, null, null);
|
||||
|
||||
@@ -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,25 +73,13 @@ 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)) {
|
||||
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)) {
|
||||
if (containsMobileSegment(password, mobileNumber)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
@@ -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);
|
||||
|
||||
+8
-3
@@ -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("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.config;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -29,7 +30,9 @@ import java.io.IOException;
|
||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
||||
*
|
||||
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||
* <p>2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고
|
||||
* {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}).
|
||||
* 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
||||
*/
|
||||
@Service
|
||||
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -56,7 +60,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
|
||||
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant) {
|
||||
// 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant
|
||||
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
@@ -71,6 +77,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
return;
|
||||
}
|
||||
|
||||
// 2FA off: 동시 접속(다른 곳 활성 세션)이 있으면 확정을 보류하고 확인 팝업으로 유도한다.
|
||||
// 중복 확인은 비밀번호 검증 통과 후에만 노출한다(사전 체크는 접속 여부/IP 정보 노출).
|
||||
if (!dormant && duplicateLoginService.hasActiveSession(normalizedUsername)) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
HttpSession session = request.getSession();
|
||||
duplicateLoginService.begin(session, user);
|
||||
|
||||
// 확인 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||
SecurityContextHolder.clearContext();
|
||||
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
|
||||
response.sendRedirect(request.getContextPath() + "/login?duplicate=1");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||
response.sendRedirect(redirect);
|
||||
|
||||
@@ -108,8 +108,8 @@ public class PortalConfigSecurity {
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+211
@@ -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;
|
||||
}
|
||||
+118
@@ -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);
|
||||
}
|
||||
+274
@@ -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());
|
||||
}
|
||||
}
|
||||
+198
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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) + "일 전";
|
||||
}
|
||||
}
|
||||
+236
@@ -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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-13
@@ -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;
|
||||
|
||||
@EMSDataSource
|
||||
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
||||
|
||||
/**
|
||||
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
|
||||
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
|
||||
* 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
|
||||
*
|
||||
* <p>역할 기반 알림 대상 조회는
|
||||
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
|
||||
*/
|
||||
@Query(value = "SELECT * FROM TSEAIRM02 t"
|
||||
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
|
||||
nativeQuery = true)
|
||||
List<UserInfo> findByRoleContaining(@Param("role") String role);
|
||||
@EMSDataSource
|
||||
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
||||
}
|
||||
|
||||
-57
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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,22 @@
|
||||
package com.eactive.apim.portal.djb.footer;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* 푸터 "관련 사이트" 셀렉트에 노출되는 사이트 한 건.
|
||||
*
|
||||
* <p>{@link RelatedSiteService} 가 PortalProperty 문자열을 파싱해 만든다.</p>
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
public class RelatedSite {
|
||||
|
||||
/** 셀렉트에 표시되는 이름 (예: 신한은행) */
|
||||
private final String name;
|
||||
|
||||
/** 이동 대상 URL (http/https 절대주소 또는 `/` 로 시작하는 사이트 상대주소) */
|
||||
private final String url;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.eactive.apim.portal.djb.footer;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 푸터 "관련 사이트" 셀렉트의 라벨과 목록을 DB(PortalProperty)에서 조회한다.
|
||||
*
|
||||
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
|
||||
* 의 자동 생성(self-seed)이 동작하도록 한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code footer.related-sites.label} - 셀렉트 첫 항목(플레이스홀더) 문구</li>
|
||||
* <li>{@code footer.related-sites} - 사이트 목록. 한 줄에 하나씩 {@code 이름=URL}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>목록 표기 규칙</h3>
|
||||
* <pre>
|
||||
* # 주석 (# 으로 시작하는 줄은 무시)
|
||||
* 신한은행=http://www.shinhan.com
|
||||
* 제주은행 = https://www.jejubank.co.kr ← = 앞뒤 공백 허용
|
||||
* </pre>
|
||||
* <ul>
|
||||
* <li>빈 줄 · {@code #} 로 시작하는 줄은 건너뛴다.</li>
|
||||
* <li>줄 순서가 곧 화면 노출 순서다.</li>
|
||||
* <li>URL 에 {@code =} 가 들어가도 <b>첫 번째</b> {@code =} 만 구분자로 쓰므로 안전하다.</li>
|
||||
* <li>{@code =} 가 없거나 이름/URL 이 비었거나 허용되지 않은 스킴이면 그 줄만 버리고 WARN 로그를
|
||||
* 남긴다. (한 줄이 잘못돼도 나머지 사이트는 정상 노출)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>허용 스킴을 {@code http/https} 와 사이트 상대경로로 제한하는 이유는, 이 값이 관리자 화면에서
|
||||
* 편집되어 그대로 앵커/스크립트 이동 대상이 되기 때문이다({@code javascript:} 등 차단).</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelatedSiteService {
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
static final String NAME_LABEL = "footer.related-sites.label";
|
||||
static final String NAME_SITES = "footer.related-sites";
|
||||
|
||||
static final String DESC_LABEL = "푸터 관련 사이트 셀렉트 라벨(첫 항목 문구)";
|
||||
static final String DESC_SITES = "푸터 관련 사이트 목록. 한 줄에 하나씩 '이름=URL' (빈 줄·# 주석 무시)";
|
||||
|
||||
static final String DEFAULT_LABEL = "DJBank 관련 사이트";
|
||||
|
||||
/** 기본값: 신한금융그룹 Family site (shinhangroup.com 하단 목록 기준) */
|
||||
static final String DEFAULT_SITES = String.join("\n",
|
||||
"신한은행=http://www.shinhan.com",
|
||||
"신한카드=http://www.shinhancard.com",
|
||||
"신한투자증권=http://www.shinhansec.com",
|
||||
"신한라이프=http://www.shinhanlife.co.kr",
|
||||
"신한캐피탈=http://www.shcap.co.kr",
|
||||
"신한자산운용=https://www.shinhanfund.com",
|
||||
"제주은행=https://www.jejubank.co.kr",
|
||||
"신한저축은행=http://www.shinhansavings.co.kr",
|
||||
"신한자산신탁=http://www.shinhantrust.kr",
|
||||
"신한DS=http://www.shinhansys.co.kr",
|
||||
"신한펀드파트너스=https://www.shinhanfundpartners.com",
|
||||
"신한리츠운용=http://shinhanrem.com",
|
||||
"신한벤처투자=http://www.shinhanvc.com",
|
||||
"신한EZ손해보험=http://www.shinhanez.co.kr",
|
||||
"신한장학재단=http://www.shsf.or.kr",
|
||||
"신한금융희망재단=http://www.shinhanfoundation.or.kr");
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 셀렉트 첫 항목에 노출할 라벨 */
|
||||
@Transactional
|
||||
public String getLabel() {
|
||||
String label = portalPropertyService.getOrCreateProperty(GROUP, NAME_LABEL, DEFAULT_LABEL, DESC_LABEL);
|
||||
return (label == null || label.trim().isEmpty()) ? DEFAULT_LABEL : label.trim();
|
||||
}
|
||||
|
||||
/** 셀렉트에 노출할 사이트 목록. 파싱 결과가 없으면 빈 리스트(푸터에서 셀렉트 미노출) */
|
||||
@Transactional
|
||||
public List<RelatedSite> getSites() {
|
||||
return parse(portalPropertyService.getOrCreateProperty(GROUP, NAME_SITES, DEFAULT_SITES, DESC_SITES));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code 이름=URL} 줄 목록을 파싱한다. 잘못된 줄은 건너뛴다.
|
||||
*/
|
||||
static List<RelatedSite> parse(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<RelatedSite> sites = new ArrayList<>();
|
||||
for (String rawLine : raw.split("\\r?\\n")) {
|
||||
String line = rawLine.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int sep = line.indexOf('=');
|
||||
if (sep <= 0) {
|
||||
log.warn("관련 사이트 설정 형식 오류(= 구분자 없음) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = line.substring(0, sep).trim();
|
||||
String url = line.substring(sep + 1).trim();
|
||||
if (name.isEmpty() || url.isEmpty()) {
|
||||
log.warn("관련 사이트 설정 형식 오류(이름 또는 URL 없음) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
if (!isAllowedUrl(url)) {
|
||||
log.warn("관련 사이트 설정 URL 스킴 불허(http/https 또는 / 로 시작해야 함) - 해당 줄 무시: {}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
sites.add(new RelatedSite(name, url));
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
private static boolean isAllowedUrl(String url) {
|
||||
String lower = url.toLowerCase();
|
||||
return lower.startsWith("http://") || lower.startsWith("https://") || url.startsWith("/");
|
||||
}
|
||||
}
|
||||
@@ -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,91 @@
|
||||
package com.eactive.apim.portal.djb.menu;
|
||||
|
||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||
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.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||
*
|
||||
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
||||
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
||||
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
||||
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
||||
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
|
||||
*
|
||||
* <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";
|
||||
static final String PROP_ALLOW_IPS_DESCRIPTION =
|
||||
"메뉴 내부 API(리로드) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
|
||||
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
|
||||
|
||||
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 = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
|
||||
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
|
||||
log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
|
||||
} catch (Exception e) {
|
||||
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "#"
|
||||
@@ -366,38 +358,38 @@ page:
|
||||
name: "개발자 정보"
|
||||
path: "/users/detail"
|
||||
apikey:
|
||||
name: "앱 관리"
|
||||
path: "/myapikey"
|
||||
name: "API 신청 관리"
|
||||
path: "/clients"
|
||||
app_request_detail:
|
||||
name: "인증키 신청 상세"
|
||||
path: "/myapikey/app_request_detail"
|
||||
path: "/clients/app_request_detail"
|
||||
credential_detail:
|
||||
name: "인증키 정보"
|
||||
path: "/myapikey/credential_detail"
|
||||
path: "/clients/credential_detail"
|
||||
password_verify:
|
||||
name: "비밀번호 변경"
|
||||
path: "/password/verify"
|
||||
password_change:
|
||||
name: "비밀번호 변경"
|
||||
path: "/password/change"
|
||||
myapikey_register_step1:
|
||||
clients_register_step1:
|
||||
name: "앱 생성 (기본 정보)"
|
||||
path: "/myapikey/register/step1"
|
||||
myapikey_register_step2:
|
||||
path: "/clients/register/step1"
|
||||
clients_register_step2:
|
||||
name: "앱 생성 (API 선택)"
|
||||
path: "/myapikey/register/step2"
|
||||
myapikey_register_step3:
|
||||
path: "/clients/register/step2"
|
||||
clients_register_step3:
|
||||
name: "앱 생성 요청 완료"
|
||||
path: "/myapikey/register/step3"
|
||||
myapikey_modify_step1:
|
||||
path: "/clients/register/step3"
|
||||
clients_modify_step1:
|
||||
name: "앱 수정 (기본 정보)"
|
||||
path: "/myapikey/modify/step1"
|
||||
myapikey_modify_step2:
|
||||
path: "/clients/modify/step1"
|
||||
clients_modify_step2:
|
||||
name: "앱 수정 (API 선택)"
|
||||
path: "/myapikey/modify/step2"
|
||||
myapikey_modify_step3:
|
||||
path: "/clients/modify/step2"
|
||||
clients_modify_step3:
|
||||
name: "앱 수정 요청 완료"
|
||||
path: "/myapikey/modify/step3"
|
||||
path: "/clients/modify/step3"
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
path: "/statistics/api"
|
||||
|
||||
@@ -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] }
|
||||
@@ -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: "개발자 관리"
|
||||
+2385
-1274
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user