Compare commits
32 Commits
815e2bdd04
..
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 | |||
| 0629e842a3 | |||
| 6fa2167378 |
@@ -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 설치 가이드
|
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
|
||||||
|
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
|
||||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||||
|
|||||||
+3
-1
@@ -203,4 +203,6 @@ task printSourceSets {
|
|||||||
println " Output dir : ${srcSet.output.classesDirs.asPath}"
|
println " Output dir : ${srcSet.output.classesDirs.asPath}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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 화면에 표기된다.
|
||||||
@@ -11,8 +11,8 @@ import java.time.LocalDateTime;
|
|||||||
/**
|
/**
|
||||||
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
||||||
*
|
*
|
||||||
* <p>eapim-admin 의 {@code ApiStatusMonitorJob}(Quartz, 매분)이 상태 변화가 있을 때만 upsert 한다.
|
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
|
||||||
* 포털은 읽기 전용으로 "마지막 상태 갱신 시각" 표시에 사용한다.</p>
|
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "API_STATUS")
|
@Table(name = "API_STATUS")
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import java.util.Optional;
|
|||||||
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 탐지 결과가 마지막으로 갱신된 시각. 데이터가 없으면 empty.
|
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
|
||||||
*/
|
*/
|
||||||
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
||||||
Optional<LocalDateTime> findLastModifiedDate();
|
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.apps.apiservice.service.ApiServiceService;
|
||||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -31,6 +32,7 @@ public class ApiController {
|
|||||||
private final ApiService apiService;
|
private final ApiService apiService;
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final ApiSearchFacade apiSearchFacade;
|
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_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ public class ApiController {
|
|||||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||||
|
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
|
||||||
|
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
|
||||||
|
|
||||||
return "apps/apis/mainApiList";
|
return "apps/apis/mainApiList";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public class AppServiceFacade {
|
|||||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||||
|
|
||||||
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
|
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
|
||||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||||
|
|
||||||
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ public interface AuthNumberService {
|
|||||||
|
|
||||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 TTL 로 발송하되 수신자 이름을 지정한다. 세 번째 인자가 int 인 오버로드(TTL 지정)와 혼동하지 말 것.
|
||||||
|
*/
|
||||||
|
String sendRequestAuthNumber(String recipientKey, String msgType, String username);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||||
*/
|
*/
|
||||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
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);
|
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,19 +45,31 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
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
|
@Override
|
||||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
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);
|
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||||
|
|
||||||
validateResendTime(recipientKey);
|
validateResendTime(recipientKey);
|
||||||
|
|
||||||
String authNumber = generator.generateAuthNumber();
|
String authNumber = generator.generateAuthNumber();
|
||||||
|
|
||||||
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType);
|
MessageRecipient recipient = createMessageRecipient(recipientKey, msgType, username);
|
||||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||||
|
|
||||||
storage.saveAuthNumber(recipientKey, authNumber,
|
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();
|
MessageRecipient recipient = new MessageRecipient();
|
||||||
recipient.setUserId(recipientKey);
|
recipient.setUserId(recipientKey);
|
||||||
|
// 메시지 템플릿 %USER_NAME% 치환용. 비어 있으면 MessageSendService 가 파라미터 자체를 넣지 않는다.
|
||||||
|
if (username != null && !username.trim().isEmpty()) {
|
||||||
|
recipient.setUsername(username);
|
||||||
|
}
|
||||||
if ("SMS".equalsIgnoreCase(msgType)) {
|
if ("SMS".equalsIgnoreCase(msgType)) {
|
||||||
recipient.setPhone(recipientKey);
|
recipient.setPhone(recipientKey);
|
||||||
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
} else if ("EMAIL".equalsIgnoreCase(msgType)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.apps.community.notice.dto;
|
package com.eactive.apim.portal.apps.community.notice.dto;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
|
|||||||
private String state;
|
private String state;
|
||||||
private String previousState;
|
private String previousState;
|
||||||
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
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() {
|
public boolean isIncidentType() {
|
||||||
return NOTICE_TYPE_INCIDENT.equals(noticeType);
|
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.apim.portal.portalNotice.entity.PortalNotice;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
|
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.dto.PortalNoticeSearch;
|
||||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
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.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.DjbApistatusIncidentApiRepository;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
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.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
private final PortalNoticeMapper portalNoticeMapper;
|
private final PortalNoticeMapper portalNoticeMapper;
|
||||||
private final DjbApistatusIncidentRepository incidentRepository;
|
private final DjbApistatusIncidentRepository incidentRepository;
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
|
private final ApiStatusAssembler apiStatusAssembler;
|
||||||
|
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<PortalNoticeDTO> getLatestNotices() {
|
public List<PortalNoticeDTO> getLatestNotices() {
|
||||||
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
private void populateIncident(PortalNoticeDTO dto) {
|
private void populateIncident(PortalNoticeDTO dto) {
|
||||||
if (!dto.isIncidentOrMaintenance()) {
|
if (!dto.isIncidentOrMaintenance()) {
|
||||||
dto.setAffectedApis(Collections.emptyList());
|
dto.setAffectedApis(Collections.emptyList());
|
||||||
|
dto.setTimeline(Collections.emptyList());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
||||||
if (!incidentOpt.isPresent()) {
|
if (!incidentOpt.isPresent()) {
|
||||||
dto.setAffectedApis(Collections.emptyList());
|
dto.setAffectedApis(Collections.emptyList());
|
||||||
|
dto.setTimeline(Collections.emptyList());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DjbApistatusIncident incident = incidentOpt.get();
|
DjbApistatusIncident incident = incidentOpt.get();
|
||||||
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
|||||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||||
|
|
||||||
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
|
||||||
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
|
||||||
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
|
||||||
.collect(Collectors.toList());
|
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.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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
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.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||||
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||||
private final CommunityAdminNotifier portalAdminNotifier;
|
private final SwingNotifier swingNotifier;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
if (writer != null) {
|
if (writer != null) {
|
||||||
params.put("writerName", writer.getUserName());
|
params.put("writerName", writer.getUserName());
|
||||||
}
|
}
|
||||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
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.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
|
|
||||||
private final InquiryService inquiryService;
|
private final InquiryService inquiryService;
|
||||||
private final InquiryMapper inquiryMapper;
|
private final InquiryMapper inquiryMapper;
|
||||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
private final SwingNotifier swingNotifier;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
|||||||
params.put("inquiryId", inquiry.getId());
|
params.put("inquiryId", inquiry.getId());
|
||||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||||
params.put("writerName", current.getUserName());
|
params.put("writerName", current.getUserName());
|
||||||
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ public class AuthFacadeImpl implements AuthFacade {
|
|||||||
private final AuthNoticeProperties authNoticeProperties;
|
private final AuthNoticeProperties authNoticeProperties;
|
||||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
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 {
|
try {
|
||||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType, GUEST_USER_NAME);
|
||||||
response.setValid(true);
|
response.setValid(true);
|
||||||
response.setMessage("인증번호를 발송하였습니다.");
|
response.setMessage("인증번호를 발송하였습니다.");
|
||||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
|||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
|
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final PortalUserMapper portalUserMapper;
|
private final PortalUserMapper portalUserMapper;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalRolesProperties portalRolesProperties;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
*/
|
*/
|
||||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
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);
|
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||||
|
|
||||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||||
|
|||||||
@@ -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.auth.AuthNoticeProperties;
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
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;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private AuthNoticeProperties authNoticeProperties;
|
private AuthNoticeProperties authNoticeProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RelatedSiteService relatedSiteService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private Environment environment;
|
private Environment environment;
|
||||||
|
|
||||||
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
|
|||||||
return portalPropertyService.getOrCreateProperty(
|
return portalPropertyService.getOrCreateProperty(
|
||||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
"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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -20,6 +21,8 @@ import java.util.Arrays;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||||
@@ -29,15 +32,19 @@ import java.util.Map;
|
|||||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||||
*
|
*
|
||||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
||||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
||||||
|
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
||||||
|
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||||
|
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
||||||
|
* 작업 완료 후 이 클래스는 제거한다.</p>
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* # 미리보기(변경 안 함)
|
* # 미리보기(변경 안 함)
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||||
* # 실제 실행 (PII 컬럼)
|
* # 실제 실행 (PII 컬럼)
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
* 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'
|
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@@ -46,13 +53,14 @@ import java.util.Map;
|
|||||||
@RequestMapping("/internal/migration")
|
@RequestMapping("/internal/migration")
|
||||||
public class LegacyEncryptionMigrationController {
|
public class LegacyEncryptionMigrationController {
|
||||||
|
|
||||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
||||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
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_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
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_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) 상속 테이블의 감사 컬럼 (옵션) */
|
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||||
@@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController {
|
|||||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
"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");
|
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 JdbcTemplate jdbcTemplate;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||||
|
|
||||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
||||||
|
PortalPropertyService portalPropertyService) {
|
||||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||||
|
this.portalPropertyService = portalPropertyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/encrypt-legacy")
|
@PostMapping("/encrypt-legacy")
|
||||||
@@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController {
|
|||||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||||
assertLocalOnly(request);
|
assertAllowedIp(request);
|
||||||
assertNotBypass();
|
assertNotBypass();
|
||||||
|
|
||||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||||
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
|
|||||||
|
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
int totalChanged = 0;
|
int totalChanged = 0;
|
||||||
|
int totalSkipped = 0;
|
||||||
for (TargetTable target : targets) {
|
for (TargetTable target : targets) {
|
||||||
for (String column : target.columns) {
|
for (String column : target.columns) {
|
||||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||||
results.add(r);
|
results.add(r);
|
||||||
totalChanged += (int) r.get("changed");
|
totalChanged += (int) r.get("changed");
|
||||||
|
totalSkipped += (int) r.get("skipped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||||
|
response.put("damoMode", resolveDamoMode());
|
||||||
response.put("includeAudit", includeAudit);
|
response.put("includeAudit", includeAudit);
|
||||||
response.put("totalChanged", totalChanged);
|
response.put("totalChanged", totalChanged);
|
||||||
|
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
||||||
|
response.put("totalSkipped", totalSkipped);
|
||||||
response.put("results", results);
|
response.put("results", results);
|
||||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
||||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveDamoMode() {
|
||||||
|
if (converter.isBypassMode()) {
|
||||||
|
return "BYPASS";
|
||||||
|
}
|
||||||
|
return converter.isFakeMode() ? "FAKE" : "REAL";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||||
*/
|
*/
|
||||||
@@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController {
|
|||||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||||
r.put("distinct", 0);
|
r.put("distinct", 0);
|
||||||
r.put("changed", 0);
|
r.put("changed", 0);
|
||||||
|
r.put("skipped", 0);
|
||||||
r.put("error", e.getMessage());
|
r.put("error", e.getMessage());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
|
int skipped = 0;
|
||||||
for (String value : values) {
|
for (String value : values) {
|
||||||
String normalized;
|
String normalized;
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController {
|
|||||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||||
continue;
|
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 (normalized != null && !normalized.equals(value)) {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
@@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController {
|
|||||||
|
|
||||||
r.put("distinct", values.size());
|
r.put("distinct", values.size());
|
||||||
r.put("changed", changed);
|
r.put("changed", changed);
|
||||||
|
r.put("skipped", skipped);
|
||||||
return r;
|
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) {
|
private void assertAllowedIp(HttpServletRequest request) {
|
||||||
String remote = request.getRemoteAddr();
|
String remote = canonicalize(request.getRemoteAddr());
|
||||||
boolean localAddr = "127.0.0.1".equals(remote)
|
|
||||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
|
||||||
|| "::1".equals(remote);
|
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
if (!localAddr || viaProxy) {
|
|
||||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
||||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
.map(String::trim)
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
.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 {
|
private static final class TargetTable {
|
||||||
final String table;
|
final String table;
|
||||||
final List<String> columns;
|
final List<String> columns;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
|
|||||||
.csrfTokenRepository(csrfTokenRepository)
|
.csrfTokenRepository(csrfTokenRepository)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||||
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||||
)
|
)
|
||||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
|
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.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
|
||||||
|
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
|
||||||
|
|
||||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||||
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
|
|
||||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
||||||
|
com.eactive.apim.portal.djb.menu.MenuService menuService) {
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
this.twoFactorService = twoFactorService;
|
this.twoFactorService = twoFactorService;
|
||||||
this.twoFactorProperties = twoFactorProperties;
|
this.twoFactorProperties = twoFactorProperties;
|
||||||
|
this.menuService = menuService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns(staticExcludes)
|
.excludePathPatterns(staticExcludes)
|
||||||
.excludePathPatterns("/auth/2fa/**");
|
.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
|
@Bean
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.eactive.apim.portal.config;
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
|
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@@ -25,8 +23,6 @@ public class PortalProperties {
|
|||||||
|
|
||||||
private String authVirtualCode = "";
|
private String authVirtualCode = "";
|
||||||
|
|
||||||
private Map<RoleCode, List<String>> portalSecurity;
|
|
||||||
|
|
||||||
private FileProperties file = new FileProperties();
|
private FileProperties file = new FileProperties();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+46
-8
@@ -2,12 +2,14 @@ package com.eactive.apim.portal.djb.apistatus.controller;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
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.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.ApiOptionDTO;
|
||||||
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
|
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.IssueDateEntryDTO;
|
||||||
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
|
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.MyApiStatusDTO;
|
||||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
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.ApiStatusCatalogService;
|
||||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
|
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.ApiStatusQueryService;
|
||||||
@@ -30,6 +32,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
|
|||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -49,20 +52,27 @@ public class ApiStatusController {
|
|||||||
|
|
||||||
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
|
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
|
||||||
private static final int MAX_PAGE_SIZE = 50;
|
private static final int MAX_PAGE_SIZE = 50;
|
||||||
|
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
|
||||||
|
private static final int MAX_STATUS_BATCH = 100;
|
||||||
|
|
||||||
private final ApiStatusQueryService apiStatusQueryService;
|
private final ApiStatusQueryService apiStatusQueryService;
|
||||||
private final ApiStatusUptimeService uptimeService;
|
private final ApiStatusUptimeService uptimeService;
|
||||||
private final ApiStatusIssueHistoryService issueHistoryService;
|
private final ApiStatusIssueHistoryService issueHistoryService;
|
||||||
private final MyApiStatusQueryService myApiStatusQueryService;
|
private final MyApiStatusQueryService myApiStatusQueryService;
|
||||||
private final ApiStatusCatalogService catalogService;
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
private final ApiCurrentStatusService apiCurrentStatusService;
|
||||||
|
|
||||||
/** P1 - API Status 메인 */
|
/** P1 - API Status 메인 */
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ModelAndView index() {
|
public ModelAndView index() {
|
||||||
ModelAndView mav = new ModelAndView("djb/apistatus/index");
|
ModelAndView mav = new ModelAndView("djb/apistatus/index");
|
||||||
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
|
mav.addObject("windowDays", catalogService.getWindowDays());
|
||||||
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
|
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
|
||||||
mav.addObject("lastStatusUpdatedAt", catalogService.getLastStatusUpdatedAt());
|
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
|
||||||
|
mav.addObject("lastFireAt", lastFireAt);
|
||||||
|
mav.addObject("lastFireRelative",
|
||||||
|
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
|
||||||
|
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
|
||||||
return mav;
|
return mav;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +88,12 @@ public class ApiStatusController {
|
|||||||
@RequestParam(value = "kind", required = false) String kind) {
|
@RequestParam(value = "kind", required = false) String kind) {
|
||||||
|
|
||||||
LocalDate today = ApiStatusSupport.now().toLocalDate();
|
LocalDate today = ApiStatusSupport.now().toLocalDate();
|
||||||
|
int windowDays = catalogService.getWindowDays();
|
||||||
|
|
||||||
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
|
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
|
||||||
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
|
mav.addObject("windowDays", windowDays);
|
||||||
mav.addObject("today", today);
|
mav.addObject("today", today);
|
||||||
mav.addObject("minDate", today.minusDays(ApiStatusSupport.DEFAULT_WINDOW_DAYS - 1L));
|
mav.addObject("minDate", today.minusDays(windowDays - 1L));
|
||||||
mav.addObject("selectedDate", date);
|
mav.addObject("selectedDate", date);
|
||||||
mav.addObject("selectedApiId", apiId);
|
mav.addObject("selectedApiId", apiId);
|
||||||
mav.addObject("selectedKind", kind);
|
mav.addObject("selectedKind", kind);
|
||||||
@@ -100,8 +111,8 @@ public class ApiStatusController {
|
|||||||
@GetMapping("/uptime.json")
|
@GetMapping("/uptime.json")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public List<DailyStatDTO> uptime(
|
public List<DailyStatDTO> uptime(
|
||||||
@RequestParam(value = "days", defaultValue = "90") int days) {
|
@RequestParam(value = "days", defaultValue = "0") int days) {
|
||||||
return uptimeService.getDailyStats(days);
|
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P3 - 진행 중 장애 */
|
/** P3 - 진행 중 장애 */
|
||||||
@@ -149,10 +160,10 @@ public class ApiStatusController {
|
|||||||
@GetMapping("/issues/dates.json")
|
@GetMapping("/issues/dates.json")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public List<IssueDateEntryDTO> issueDates(
|
public List<IssueDateEntryDTO> issueDates(
|
||||||
@RequestParam(value = "days", defaultValue = "90") int days,
|
@RequestParam(value = "days", defaultValue = "0") int days,
|
||||||
@RequestParam(value = "apiId", required = false) String apiId,
|
@RequestParam(value = "apiId", required = false) String apiId,
|
||||||
@RequestParam(value = "kind", required = false) String kind) {
|
@RequestParam(value = "kind", required = false) String kind) {
|
||||||
return issueHistoryService.getIssueDates(days, apiId, kind);
|
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
|
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
|
||||||
@@ -170,4 +181,31 @@ public class ApiStatusController {
|
|||||||
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
|
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
|
||||||
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class ActiveIncidentDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String kind;
|
private String kind;
|
||||||
private String state;
|
private String state;
|
||||||
private String stateLabel;
|
private String stateLabel;
|
||||||
@@ -24,5 +28,7 @@ public class ActiveIncidentDTO {
|
|||||||
private LocalDateTime startedAt;
|
private LocalDateTime startedAt;
|
||||||
private long elapsedMinutes;
|
private long elapsedMinutes;
|
||||||
private List<AffectedApiDTO> apis = new ArrayList<>();
|
private List<AffectedApiDTO> apis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,8 +19,12 @@ public class DailyStatDTO {
|
|||||||
/** 0.0000 ~ 1.0000 */
|
/** 0.0000 ~ 1.0000 */
|
||||||
private double uptimeRatio;
|
private double uptimeRatio;
|
||||||
|
|
||||||
|
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
|
||||||
private long incidentMinutes;
|
private long incidentMinutes;
|
||||||
|
|
||||||
|
/** 그 중 지연(자동 탐지) 분 */
|
||||||
|
private long delayMinutes;
|
||||||
|
|
||||||
private long maintenanceMinutes;
|
private long maintenanceMinutes;
|
||||||
|
|
||||||
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
|
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ public class IssueDateEntryDTO {
|
|||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||||
private LocalDate date;
|
private LocalDate date;
|
||||||
|
|
||||||
/** INCIDENT / MAINTENANCE */
|
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
|
||||||
private List<String> kinds = new ArrayList<>();
|
private List<String> kinds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 장애 건수 (자동 탐지 지연 제외) */
|
||||||
private int incCount;
|
private int incCount;
|
||||||
|
|
||||||
|
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
|
||||||
|
private int dlyCount;
|
||||||
|
|
||||||
private int mntCount;
|
private int mntCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class MaintenanceCardDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String title;
|
private String title;
|
||||||
private String summary;
|
private String summary;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -23,6 +27,8 @@ public class MaintenanceCardDTO {
|
|||||||
private LocalDateTime endAt;
|
private LocalDateTime endAt;
|
||||||
private Long durationMinutes;
|
private Long durationMinutes;
|
||||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime registeredAt;
|
private LocalDateTime registeredAt;
|
||||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public class PastIssueCardDTO {
|
|||||||
|
|
||||||
private Long incidentId;
|
private Long incidentId;
|
||||||
private String noticeId;
|
private String noticeId;
|
||||||
|
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeSubject;
|
||||||
|
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||||
|
private String noticeDetail;
|
||||||
private String kind;
|
private String kind;
|
||||||
private String state;
|
private String state;
|
||||||
private String stateLabel;
|
private String stateLabel;
|
||||||
@@ -29,5 +33,7 @@ public class PastIssueCardDTO {
|
|||||||
private LocalDate dateGroup;
|
private LocalDate dateGroup;
|
||||||
private Long durationMinutes;
|
private Long durationMinutes;
|
||||||
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
|
||||||
|
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||||
|
private int hiddenApiCount;
|
||||||
private List<TimelineEntryDTO> timeline = new ArrayList<>();
|
private List<TimelineEntryDTO> timeline = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-24
@@ -15,31 +15,52 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건(공지 게시 + 초안 아님)을 항상 적용한다.
|
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
|
||||||
*
|
*
|
||||||
* <p>PTL_NOTICE 와의 조인은 두 가지 역할을 한다.
|
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
|
||||||
* (1) 관리자가 미게시(USE_YN='N')한 장애/점검을 숨긴다.
|
* <ul>
|
||||||
* (2) 공지가 삭제된 고아 장애 행을 자연히 제외한다 (물리 FK 없음 - ADR-F10).</p>
|
* <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> {
|
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
|
||||||
|
|
||||||
String VISIBLE = " i.noticeId = n.id AND n.useYn = 'Y' AND i.draftYn = 'N' ";
|
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)
|
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
|
||||||
*/
|
*/
|
||||||
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
|
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND i.kind = :kind"
|
+ " AND i.kind IN :kinds"
|
||||||
+ " AND i.state NOT IN :closedStates"
|
+ " AND i.state NOT IN :closedStates"
|
||||||
+ " ORDER BY i.startedAt DESC")
|
+ " ORDER BY i.startedAt DESC")
|
||||||
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kind") IncidentKind kind,
|
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
|
||||||
@Param("closedStates") Collection<IncidentState> closedStates);
|
@Param("closedStates") Collection<IncidentState> closedStates);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
|
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
|
||||||
*/
|
*/
|
||||||
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
|
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND i.kind = :kind"
|
+ " AND i.kind = :kind"
|
||||||
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
|
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
|
||||||
@@ -48,21 +69,15 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
@Param("now") LocalDateTime now);
|
@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 종결된 이슈 (P6). 장애는 종결 상태, 점검은 종료 시각 경과.
|
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
|
||||||
*/
|
*/
|
||||||
@Query(value = "SELECT i FROM DjbApistatusIncident i, PortalNotice n"
|
@Query(value = "SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
|
+ " AND " + CLOSED_CONDITION
|
||||||
+ " AND i.state IN :closedStates)"
|
|
||||||
+ " OR (i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE"
|
|
||||||
+ " AND i.endAt IS NOT NULL AND i.endAt < :now))"
|
|
||||||
+ " ORDER BY i.startedAt DESC",
|
+ " ORDER BY i.startedAt DESC",
|
||||||
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i, PortalNotice n"
|
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
|
+ " AND " + CLOSED_CONDITION)
|
||||||
+ " AND i.state IN :closedStates)"
|
|
||||||
+ " OR (i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE"
|
|
||||||
+ " AND i.endAt IS NOT NULL AND i.endAt < :now))")
|
|
||||||
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
|
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
|
||||||
@Param("now") LocalDateTime now,
|
@Param("now") LocalDateTime now,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
@@ -71,7 +86,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
|
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
|
||||||
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
|
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
|
||||||
*/
|
*/
|
||||||
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
|
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND i.startedAt < :to"
|
+ " AND i.startedAt < :to"
|
||||||
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
||||||
@@ -82,7 +97,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
/**
|
/**
|
||||||
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
|
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
|
||||||
*/
|
*/
|
||||||
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, PortalNotice n, DjbApistatusIncidentApi a"
|
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND a.incidentId = i.incidentId"
|
+ " AND a.incidentId = i.incidentId"
|
||||||
+ " AND a.apiId = :apiId"
|
+ " AND a.apiId = :apiId"
|
||||||
@@ -96,7 +111,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
/**
|
/**
|
||||||
* 공개 상세 (P7)
|
* 공개 상세 (P7)
|
||||||
*/
|
*/
|
||||||
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
|
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND i.incidentId = :incidentId")
|
+ " AND i.incidentId = :incidentId")
|
||||||
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-5
@@ -11,6 +11,9 @@ import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusInciden
|
|||||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
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.DjbApistatusIncidentApiRepository;
|
||||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -37,6 +40,42 @@ public class ApiStatusAssembler {
|
|||||||
|
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
private final DjbApistatusIncidentTimelineRepository timelineRepository;
|
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) {
|
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
|
||||||
if (incidentIds == null || incidentIds.isEmpty()) {
|
if (incidentIds == null || incidentIds.isEmpty()) {
|
||||||
@@ -69,6 +108,30 @@ public class ApiStatusAssembler {
|
|||||||
return result;
|
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) {
|
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
|
||||||
if (incidents.isEmpty()) {
|
if (incidents.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
@@ -76,6 +139,10 @@ public class ApiStatusAssembler {
|
|||||||
List<Long> ids = incidentIds(incidents);
|
List<Long> ids = incidentIds(incidents);
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(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<>();
|
List<ActiveIncidentDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
@@ -89,7 +156,16 @@ public class ApiStatusAssembler {
|
|||||||
dto.setSummary(incident.getSummary());
|
dto.setSummary(incident.getSummary());
|
||||||
dto.setStartedAt(incident.getStartedAt());
|
dto.setStartedAt(incident.getStartedAt());
|
||||||
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
|
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
|
||||||
dto.setApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
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());
|
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
|
||||||
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
|
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
|
||||||
@@ -104,19 +180,33 @@ public class ApiStatusAssembler {
|
|||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
|
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<>();
|
List<MaintenanceCardDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
MaintenanceCardDTO dto = new MaintenanceCardDTO();
|
MaintenanceCardDTO dto = new MaintenanceCardDTO();
|
||||||
dto.setIncidentId(incident.getIncidentId());
|
dto.setIncidentId(incident.getIncidentId());
|
||||||
dto.setNoticeId(incident.getNoticeId());
|
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.setTitle(incident.getTitle());
|
||||||
dto.setSummary(incident.getSummary());
|
dto.setSummary(incident.getSummary());
|
||||||
dto.setStartedAt(incident.getStartedAt());
|
dto.setStartedAt(incident.getStartedAt());
|
||||||
dto.setEndAt(incident.getEndAt());
|
dto.setEndAt(incident.getEndAt());
|
||||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||||
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
VisibleApis affected = splitByVisibility(
|
||||||
|
apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()), visibleNames);
|
||||||
|
dto.setImpactedApis(affected.getVisible());
|
||||||
|
dto.setHiddenApiCount(affected.getHiddenCount());
|
||||||
dto.setRegisteredAt(incident.getCreatedDate());
|
dto.setRegisteredAt(incident.getCreatedDate());
|
||||||
dto.setLastModifiedAt(incident.getLastModifiedDate());
|
dto.setLastModifiedAt(incident.getLastModifiedDate());
|
||||||
result.add(dto);
|
result.add(dto);
|
||||||
@@ -125,7 +215,7 @@ public class ApiStatusAssembler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지난 이슈 카드. 장애는 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
|
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
|
||||||
*/
|
*/
|
||||||
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
|
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
|
||||||
if (incidents.isEmpty()) {
|
if (incidents.isEmpty()) {
|
||||||
@@ -133,18 +223,29 @@ public class ApiStatusAssembler {
|
|||||||
}
|
}
|
||||||
List<Long> ids = incidentIds(incidents);
|
List<Long> ids = incidentIds(incidents);
|
||||||
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
|
||||||
|
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
|
||||||
|
|
||||||
List<Long> incidentKindIds = incidents.stream()
|
List<Long> incidentKindIds = incidents.stream()
|
||||||
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
|
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||||
.map(DjbApistatusIncident::getIncidentId)
|
.map(DjbApistatusIncident::getIncidentId)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
|
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<>();
|
List<PastIssueCardDTO> result = new ArrayList<>();
|
||||||
for (DjbApistatusIncident incident : incidents) {
|
for (DjbApistatusIncident incident : incidents) {
|
||||||
PastIssueCardDTO dto = new PastIssueCardDTO();
|
PastIssueCardDTO dto = new PastIssueCardDTO();
|
||||||
dto.setIncidentId(incident.getIncidentId());
|
dto.setIncidentId(incident.getIncidentId());
|
||||||
dto.setNoticeId(incident.getNoticeId());
|
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.setKind(incident.getKind() == null ? null : incident.getKind().name());
|
||||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||||
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
|
||||||
@@ -155,7 +256,10 @@ public class ApiStatusAssembler {
|
|||||||
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
|
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
|
||||||
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
dto.setDurationMinutes(incident.getEndAt() == null ? null
|
||||||
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
|
||||||
dto.setImpactedApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
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()));
|
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
|
||||||
result.add(dto);
|
result.add(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-6
@@ -5,20 +5,26 @@ import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
|||||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
|
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import javax.persistence.EntityManager;
|
||||||
|
import javax.persistence.PersistenceContext;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Status 화면의 부가 조회 - 필터용 API 목록, 탐지 결과 갱신 시각.
|
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -26,9 +32,32 @@ import java.util.stream.Collectors;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class ApiStatusCatalogService {
|
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 ApiSearchFacade apiSearchFacade;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
private final GwApiStatusRepository gwApiStatusRepository;
|
private final GwApiStatusRepository gwApiStatusRepository;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이슈 이력 필터용 API 목록.
|
* 이슈 이력 필터용 API 목록.
|
||||||
*
|
*
|
||||||
@@ -54,15 +83,115 @@ public class ApiStatusCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 탐지 결과(AGWAPP.API_STATUS)가 마지막으로 갱신된 시각.
|
* 현재 사용자에게 공개된 API 의 {API ID → 노출명} 맵.
|
||||||
* 상태 변화가 있을 때만 갱신되므로 "마지막 상태 변경 시각" 이다.
|
*
|
||||||
|
* <p>GW 인터페이스는 전부 이슈 데이터(DJB_APISTATUS_INCIDENT_API)에 들어오지만
|
||||||
|
* 개발자포탈에 게시되는 것은 그 일부(= API)뿐이다. 화면은 이 맵에 있는 것만
|
||||||
|
* 개별 이름으로 노출하고 나머지는 건수로 묶는다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@link #getSelectableApis()} 와 같은 경로라 역할·소속에 따른 공개 범위가 그대로 반영된다.</p>
|
||||||
*/
|
*/
|
||||||
public LocalDateTime getLastStatusUpdatedAt() {
|
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 {
|
try {
|
||||||
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
|
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 게이트웨이 조회 실패가 화면 전체를 막지 않도록 한다
|
log.warn("API 상태 변경 시각 조회 실패", e);
|
||||||
log.warn("API 상태 갱신 시각 조회 실패", e);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-19
@@ -32,6 +32,7 @@ public class ApiStatusIssueHistoryService {
|
|||||||
|
|
||||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||||
private final ApiStatusAssembler assembler;
|
private final ApiStatusAssembler assembler;
|
||||||
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
|
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
|
||||||
@@ -68,17 +69,7 @@ public class ApiStatusIssueHistoryService {
|
|||||||
while (!cursor.isAfter(last)) {
|
while (!cursor.isAfter(last)) {
|
||||||
IssueDateEntryDTO entry = byDate.get(cursor);
|
IssueDateEntryDTO entry = byDate.get(cursor);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
if (incident.getKind() == IncidentKind.MAINTENANCE) {
|
tally(entry, incident);
|
||||||
entry.setMntCount(entry.getMntCount() + 1);
|
|
||||||
if (!entry.getKinds().contains(IncidentKind.MAINTENANCE.name())) {
|
|
||||||
entry.getKinds().add(IncidentKind.MAINTENANCE.name());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
entry.setIncCount(entry.getIncCount() + 1);
|
|
||||||
if (!entry.getKinds().contains(IncidentKind.INCIDENT.name())) {
|
|
||||||
entry.getKinds().add(IncidentKind.INCIDENT.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cursor = cursor.plusDays(1);
|
cursor = cursor.plusDays(1);
|
||||||
}
|
}
|
||||||
@@ -87,6 +78,24 @@ public class ApiStatusIssueHistoryService {
|
|||||||
return new ArrayList<>(byDate.values());
|
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일 전체.
|
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
|
||||||
*/
|
*/
|
||||||
@@ -97,7 +106,7 @@ public class ApiStatusIssueHistoryService {
|
|||||||
|
|
||||||
if (date == null) {
|
if (date == null) {
|
||||||
LocalDate today = now.toLocalDate();
|
LocalDate today = now.toLocalDate();
|
||||||
from = today.minusDays(ApiStatusSupport.DEFAULT_WINDOW_DAYS - 1L).atStartOfDay();
|
from = today.minusDays(catalogService.getWindowDays() - 1L).atStartOfDay();
|
||||||
to = today.plusDays(1).atStartOfDay();
|
to = today.plusDays(1).atStartOfDay();
|
||||||
} else {
|
} else {
|
||||||
from = date.atStartOfDay();
|
from = date.atStartOfDay();
|
||||||
@@ -125,24 +134,24 @@ public class ApiStatusIssueHistoryService {
|
|||||||
? incidentQueryRepository.findVisibleOverlapping(from, to)
|
? incidentQueryRepository.findVisibleOverlapping(from, to)
|
||||||
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
|
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
|
||||||
|
|
||||||
IncidentKind selected = parseKind(kind);
|
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
|
||||||
if (selected == null) {
|
if (filter == null) {
|
||||||
return incidents;
|
return incidents;
|
||||||
}
|
}
|
||||||
return incidents.stream()
|
return incidents.stream().filter(filter).collect(Collectors.toList());
|
||||||
.filter(incident -> incident.getKind() == selected)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
|
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
|
||||||
|
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
|
||||||
*/
|
*/
|
||||||
private IncidentKind parseKind(String kind) {
|
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
|
||||||
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
|
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return IncidentKind.valueOf(kind.toUpperCase());
|
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
|
||||||
|
return incident -> incident.getKind() == selected;
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -26,11 +26,11 @@ public class ApiStatusQueryService {
|
|||||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||||
private final ApiStatusAssembler assembler;
|
private final ApiStatusAssembler assembler;
|
||||||
|
|
||||||
/** P3 - 진행 중 장애 */
|
/** P3 - 진행 중 장애·지연 */
|
||||||
public List<ActiveIncidentDTO> getActiveIncidents() {
|
public List<ActiveIncidentDTO> getActiveIncidents() {
|
||||||
LocalDateTime now = ApiStatusSupport.now();
|
LocalDateTime now = ApiStatusSupport.now();
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
|
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
|
||||||
return assembler.toActiveIncidents(incidents, now);
|
return assembler.toActiveIncidents(incidents, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.eactive.apim.portal.djb.apistatus.service;
|
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 com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -36,6 +38,39 @@ public final class ApiStatusSupport {
|
|||||||
return LocalDateTime.now(ZONE);
|
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) {
|
public static String stateLabel(IncidentState state) {
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -72,4 +107,26 @@ public final class ApiStatusSupport {
|
|||||||
}
|
}
|
||||||
return Duration.between(from, to).toMinutes();
|
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) + "일 전";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -64,9 +64,10 @@ public class ApiStatusUptimeService {
|
|||||||
LocalDateTime now = ApiStatusSupport.now();
|
LocalDateTime now = ApiStatusSupport.now();
|
||||||
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
||||||
|
|
||||||
|
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
||||||
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
|
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
|
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
|
||||||
@@ -116,7 +117,11 @@ public class ApiStatusUptimeService {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
|
||||||
|
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
|
||||||
List<long[]> incidentIntervals = new ArrayList<>();
|
List<long[]> incidentIntervals = new ArrayList<>();
|
||||||
|
List<long[]> outageIntervals = new ArrayList<>();
|
||||||
|
List<long[]> delayIntervals = new ArrayList<>();
|
||||||
List<long[]> maintenanceIntervals = new ArrayList<>();
|
List<long[]> maintenanceIntervals = new ArrayList<>();
|
||||||
Set<Long> seen = new HashSet<>();
|
Set<Long> seen = new HashSet<>();
|
||||||
|
|
||||||
@@ -131,6 +136,11 @@ public class ApiStatusUptimeService {
|
|||||||
maintenanceIntervals.add(interval);
|
maintenanceIntervals.add(interval);
|
||||||
} else {
|
} else {
|
||||||
incidentIntervals.add(interval);
|
incidentIntervals.add(interval);
|
||||||
|
if (incident.getKind() == IncidentKind.DELAY) {
|
||||||
|
delayIntervals.add(interval);
|
||||||
|
} else {
|
||||||
|
outageIntervals.add(interval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (seen.add(incident.getIncidentId())) {
|
if (seen.add(incident.getIncidentId())) {
|
||||||
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
|
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
|
||||||
@@ -141,18 +151,24 @@ public class ApiStatusUptimeService {
|
|||||||
|
|
||||||
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
|
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
|
||||||
long incidentMinutes = unionMinutes(incidentIntervals);
|
long incidentMinutes = unionMinutes(incidentIntervals);
|
||||||
|
long outageMinutes = unionMinutes(outageIntervals);
|
||||||
|
long delayMinutes = unionMinutes(delayIntervals);
|
||||||
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
|
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
|
||||||
|
|
||||||
dto.setIncidentMinutes(incidentMinutes);
|
dto.setIncidentMinutes(incidentMinutes);
|
||||||
|
dto.setDelayMinutes(delayMinutes);
|
||||||
dto.setMaintenanceMinutes(maintenanceMinutes);
|
dto.setMaintenanceMinutes(maintenanceMinutes);
|
||||||
|
|
||||||
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
|
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
|
||||||
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
|
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
|
||||||
|
|
||||||
if (incidentMinutes > 0) {
|
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
|
||||||
|
if (outageMinutes > 0) {
|
||||||
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
|
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
|
||||||
} else if (maintenanceMinutes > 0) {
|
} else if (maintenanceMinutes > 0) {
|
||||||
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
|
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
|
||||||
|
} else if (delayMinutes > 0) {
|
||||||
|
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
|
||||||
} else {
|
} else {
|
||||||
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
|
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-134
@@ -5,34 +5,25 @@ import com.eactive.apim.portal.app.repository.CredentialRepository;
|
|||||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
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 com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
|
||||||
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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
|
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
|
||||||
*
|
*
|
||||||
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.</p>
|
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
|
||||||
|
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -40,9 +31,9 @@ import java.util.stream.Collectors;
|
|||||||
public class MyApiStatusQueryService {
|
public class MyApiStatusQueryService {
|
||||||
|
|
||||||
private final CredentialRepository credentialRepository;
|
private final CredentialRepository credentialRepository;
|
||||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
private final ApiCurrentStatusService apiCurrentStatusService;
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
|
||||||
private final ApiStatusUptimeService uptimeService;
|
private final ApiStatusUptimeService uptimeService;
|
||||||
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
|
||||||
public List<MyApiStatusDTO> getMyApiStatuses() {
|
public List<MyApiStatusDTO> getMyApiStatuses() {
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
@@ -55,30 +46,29 @@ public class MyApiStatusQueryService {
|
|||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime now = ApiStatusSupport.now();
|
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
|
||||||
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(myApis.keySet());
|
Map<String, Double> uptimes =
|
||||||
Set<String> underMaintenance = collectApisUnderMaintenance(myApis.keySet(), now);
|
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
|
||||||
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(myApis.keySet(), now);
|
|
||||||
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(
|
|
||||||
myApis.keySet(), ApiStatusSupport.DEFAULT_WINDOW_DAYS);
|
|
||||||
|
|
||||||
List<MyApiStatusDTO> result = new ArrayList<>();
|
List<MyApiStatusDTO> result = new ArrayList<>();
|
||||||
for (Map.Entry<String, String> entry : myApis.entrySet()) {
|
for (Map.Entry<String, String> entry : myApis.entrySet()) {
|
||||||
String apiId = entry.getKey();
|
String apiId = entry.getKey();
|
||||||
DjbApistatusIncident open = openByApi.get(apiId);
|
ApiCurrentStatusDTO status = statuses.get(apiId);
|
||||||
|
|
||||||
MyApiStatusDTO dto = new MyApiStatusDTO();
|
MyApiStatusDTO dto = new MyApiStatusDTO();
|
||||||
dto.setApiId(apiId);
|
dto.setApiId(apiId);
|
||||||
|
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
|
||||||
dto.setApiName(entry.getValue());
|
dto.setApiName(entry.getValue());
|
||||||
dto.setCurrentStatus(resolveStatus(open, underMaintenance.contains(apiId)));
|
dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
|
||||||
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
|
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
|
||||||
dto.setActiveIncidentId(open == null ? null : open.getIncidentId());
|
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
|
||||||
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
|
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
|
||||||
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
|
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
|
||||||
result.add(dto);
|
result.add(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.sort(Comparator.comparingInt((MyApiStatusDTO dto) -> statusRank(dto.getCurrentStatus()))
|
result.sort(Comparator
|
||||||
|
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
|
||||||
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
|
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -101,113 +91,4 @@ public class MyApiStatusQueryService {
|
|||||||
}
|
}
|
||||||
return myApis;
|
return myApis;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
|
|
||||||
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
|
|
||||||
.findVisibleOpenIncidents(IncidentKind.INCIDENT, 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 || statePriority(incident.getState()) < statePriority(previous.getState())) {
|
|
||||||
byApi.put(api.getApiId(), incident);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return byApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<String> collectApisUnderMaintenance(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.emptySet();
|
|
||||||
}
|
|
||||||
List<Long> ids = maintenances.stream()
|
|
||||||
.map(DjbApistatusIncident::getIncidentId)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
Set<String> result = new HashSet<>();
|
|
||||||
for (DjbApistatusIncidentApi api : incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(ids)) {
|
|
||||||
if (apiIds.contains(api.getApiId())) {
|
|
||||||
result.add(api.getApiId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now) {
|
|
||||||
LocalDateTime windowStart = now.toLocalDate()
|
|
||||||
.minusDays(ApiStatusSupport.DEFAULT_WINDOW_DAYS - 1L).atStartOfDay();
|
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
|
||||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
|
||||||
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
|
|
||||||
if (open != null) {
|
|
||||||
return open.getState() == IncidentState.MONITORING
|
|
||||||
? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
|
|
||||||
}
|
|
||||||
if (underMaintenance) {
|
|
||||||
return ApiStatusSupport.STATUS_MAINTENANCE;
|
|
||||||
}
|
|
||||||
return ApiStatusSupport.STATUS_NORMAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int statePriority(IncidentState state) {
|
|
||||||
if (state == null) {
|
|
||||||
return 9;
|
|
||||||
}
|
|
||||||
switch (state) {
|
|
||||||
case INVESTIGATING: return 0;
|
|
||||||
case IDENTIFIED: return 1;
|
|
||||||
case MONITORING: return 2;
|
|
||||||
default: return 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int statusRank(String status) {
|
|
||||||
if (status == null) {
|
|
||||||
return 9;
|
|
||||||
}
|
|
||||||
switch (status) {
|
|
||||||
case ApiStatusSupport.STATUS_OUTAGE: return 0;
|
|
||||||
case ApiStatusSupport.STATUS_MAINTENANCE: return 1;
|
|
||||||
case ApiStatusSupport.STATUS_DEGRADED: return 2;
|
|
||||||
default: return 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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.apim.portal.user.entity.UserInfo;
|
||||||
import com.eactive.eai.data.jpa.BaseRepository;
|
import com.eactive.eai.data.jpa.BaseRepository;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
|
||||||
|
*
|
||||||
|
* <p>역할 기반 알림 대상 조회는
|
||||||
|
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
|
||||||
|
*/
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
||||||
|
|
||||||
/**
|
|
||||||
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
|
|
||||||
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
|
|
||||||
*/
|
|
||||||
@Query(value = "SELECT * FROM TSEAIRM02 t"
|
|
||||||
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
|
|
||||||
nativeQuery = true)
|
|
||||||
List<UserInfo> findByRoleContaining(@Param("role") String role);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-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.InquiryCommentRepository;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
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.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.djb.community.qna.support.InquiryCommentPermissionChecker;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
@@ -47,7 +48,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
private final InquiryCommentService commentService;
|
private final InquiryCommentService commentService;
|
||||||
private final InquiryCommentRepository commentRepository;
|
private final InquiryCommentRepository commentRepository;
|
||||||
private final InquiryCommentPermissionChecker permissionChecker;
|
private final InquiryCommentPermissionChecker permissionChecker;
|
||||||
private final CommunityAdminNotifier adminNotifier;
|
private final SwingNotifier swingNotifier;
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final UserInfoRepository userInfoRepository;
|
private final UserInfoRepository userInfoRepository;
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
|||||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||||
params.put("commentContent", comment.getCommentDetail());
|
params.put("commentContent", comment.getCommentDetail());
|
||||||
params.put("writerName", current.getUserName());
|
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) {
|
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);
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ server:
|
|||||||
|
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
config:
|
||||||
|
import:
|
||||||
|
- classpath:menu.yml
|
||||||
|
- classpath:roles.yml
|
||||||
data:
|
data:
|
||||||
web:
|
web:
|
||||||
pageable:
|
pageable:
|
||||||
@@ -218,26 +222,7 @@ portal:
|
|||||||
view-name: apps/apis/static/tokenApiSpec
|
view-name: apps/apis/static/tokenApiSpec
|
||||||
bean: apiHandler
|
bean: apiHandler
|
||||||
|
|
||||||
portal_security:
|
# portal_security 는 roles.yml (portal-roles) 로 이동
|
||||||
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
|
|
||||||
page:
|
page:
|
||||||
home:
|
home:
|
||||||
name: "Home"
|
name: "Home"
|
||||||
|
|||||||
@@ -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: "개발자 관리"
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -7,15 +7,19 @@
|
|||||||
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
||||||
const base = page.getAttribute('data-base') || '/apistatus';
|
const base = page.getAttribute('data-base') || '/apistatus';
|
||||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||||
|
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||||
|
|
||||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||||
const linkableApiIds = new Set();
|
const linkableApiIds = new Set();
|
||||||
|
|
||||||
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
|
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
|
||||||
|
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
|
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
|
||||||
|
|
||||||
const today = page.getAttribute('data-today') || '';
|
const today = page.getAttribute('data-today') || '';
|
||||||
|
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
|
||||||
|
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
|
||||||
const minDate = page.getAttribute('data-min-date') || '';
|
const minDate = page.getAttribute('data-min-date') || '';
|
||||||
|
|
||||||
// 진입 시 날짜 미지정 = 조회 기간 전체
|
// 진입 시 날짜 미지정 = 조회 기간 전체
|
||||||
@@ -39,6 +43,8 @@
|
|||||||
const apiInput = document.getElementById('filterApiInput');
|
const apiInput = document.getElementById('filterApiInput');
|
||||||
const apiClearBtn = document.getElementById('filterApiClear');
|
const apiClearBtn = document.getElementById('filterApiClear');
|
||||||
const apiListEl = document.getElementById('filterApiList');
|
const apiListEl = document.getElementById('filterApiList');
|
||||||
|
const unlistedBanner = document.getElementById('unlistedApiBanner');
|
||||||
|
const unlistedName = document.getElementById('unlistedApiName');
|
||||||
|
|
||||||
let apiOptions = [];
|
let apiOptions = [];
|
||||||
|
|
||||||
@@ -124,15 +130,21 @@
|
|||||||
|
|
||||||
indexBar.innerHTML = entries.map(function (entry) {
|
indexBar.innerHTML = entries.map(function (entry) {
|
||||||
const hasIncident = entry.incCount > 0;
|
const hasIncident = entry.incCount > 0;
|
||||||
|
const hasDelay = entry.dlyCount > 0;
|
||||||
const hasMaintenance = entry.mntCount > 0;
|
const hasMaintenance = entry.mntCount > 0;
|
||||||
|
|
||||||
|
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
|
||||||
|
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
|
||||||
let cls = '';
|
let cls = '';
|
||||||
if (hasIncident && hasMaintenance) cls = ' has-both';
|
if (kindCount > 1) cls = ' has-mixed';
|
||||||
else if (hasIncident) cls = ' has-incident';
|
else if (hasIncident) cls = ' has-incident';
|
||||||
|
else if (hasDelay) cls = ' has-degraded';
|
||||||
else if (hasMaintenance) cls = ' has-maintenance';
|
else if (hasMaintenance) cls = ' has-maintenance';
|
||||||
if (state.date && state.date === entry.date) cls += ' is-selected';
|
if (state.date && state.date === entry.date) cls += ' is-selected';
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
|
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
|
||||||
|
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
|
||||||
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
|
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
|
||||||
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
|
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
|
||||||
|
|
||||||
@@ -163,25 +175,65 @@
|
|||||||
|
|
||||||
// ---------------- 이슈 카드 ----------------
|
// ---------------- 이슈 카드 ----------------
|
||||||
/**
|
/**
|
||||||
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
|
* 영향 API 태그.
|
||||||
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
|
*
|
||||||
|
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||||
|
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||||
*/
|
*/
|
||||||
function apiPillsHtml(apis) {
|
function apiPillsHtml(apis, hiddenCount) {
|
||||||
if (!apis || !apis.length) return '';
|
const hidden = hiddenCount || 0;
|
||||||
const pills = apis.map(function (api) {
|
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||||
|
|
||||||
|
const pills = (apis || []).map(function (api) {
|
||||||
const text = escapeHtml(api.apiName || api.apiId);
|
const text = escapeHtml(api.apiName || api.apiId);
|
||||||
if (!linkableApiIds.has(api.apiId)) {
|
if (!linkableApiIds.has(api.apiId)) {
|
||||||
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
|
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||||
+ text + '</span>';
|
|
||||||
}
|
}
|
||||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||||
+ text + '</a>';
|
+ text + '</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + '</div>';
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스는 개별 노출 없이 건수로만 알린다
|
||||||
|
const hiddenPill = hidden > 0
|
||||||
|
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||||
|
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + hiddenPill + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||||
|
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||||
|
*/
|
||||||
|
function noticeHtml(issue) {
|
||||||
|
if (!issue.noticeDetail) return '';
|
||||||
|
const link = issue.noticeId
|
||||||
|
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||||
|
+ encodeURIComponent(issue.noticeId) + '">공지 전체 보기 →</a>'
|
||||||
|
: '';
|
||||||
|
return '<div class="as-alert-notice">'
|
||||||
|
+ '<div class="as-alert-notice-head">'
|
||||||
|
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||||
|
+ (issue.noticeSubject ? '<strong>' + escapeHtml(issue.noticeSubject) + '</strong>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="as-alert-notice-body editor-content">' + issue.noticeDetail + '</div>'
|
||||||
|
+ link
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||||
|
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||||
|
*/
|
||||||
|
function splitHtml(left, right) {
|
||||||
|
if (!left) return right;
|
||||||
|
if (!right) return left;
|
||||||
|
return '<div class="as-card-split">' + left + right + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueCardHtml(issue) {
|
function issueCardHtml(issue) {
|
||||||
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
|
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
|
||||||
|
|
||||||
let inner;
|
let inner;
|
||||||
if (issue.kind === 'MAINTENANCE') {
|
if (issue.kind === 'MAINTENANCE') {
|
||||||
@@ -211,8 +263,8 @@
|
|||||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||||
+ inner
|
+ splitHtml(inner, noticeHtml(issue))
|
||||||
+ apiPillsHtml(issue.impactedApis)
|
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
|
||||||
+ '</article>';
|
+ '</article>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,13 +323,8 @@
|
|||||||
.then(function (apis) {
|
.then(function (apis) {
|
||||||
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
|
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
|
||||||
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
|
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
|
||||||
|
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
|
||||||
const selected = findApiOption(state.apiId);
|
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
|
||||||
if (state.apiId && !selected) {
|
|
||||||
// 조회 가능 목록에 없는 API 가 URL 로 들어온 경우 필터 해제
|
|
||||||
state.apiId = '';
|
|
||||||
syncBrowserUrl();
|
|
||||||
}
|
|
||||||
renderApiInput();
|
renderApiInput();
|
||||||
})
|
})
|
||||||
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
|
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
|
||||||
@@ -299,8 +346,16 @@
|
|||||||
|
|
||||||
function renderApiInput() {
|
function renderApiInput() {
|
||||||
const selected = findApiOption(state.apiId);
|
const selected = findApiOption(state.apiId);
|
||||||
apiInput.value = optionLabel(selected);
|
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
|
||||||
apiCombo.classList.toggle('has-value', !!selected);
|
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
|
||||||
|
apiCombo.classList.toggle('has-value', !!state.apiId);
|
||||||
|
|
||||||
|
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
|
||||||
|
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
|
||||||
|
if (unlisted) {
|
||||||
|
unlistedName.textContent = urlApiName || state.apiId;
|
||||||
|
}
|
||||||
|
unlistedBanner.style.display = unlisted ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeApiList() {
|
function closeApiList() {
|
||||||
@@ -375,7 +430,20 @@
|
|||||||
applyFilters(true);
|
applyFilters(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
apiInput.addEventListener('focus', function () { openApiList(''); });
|
// readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
|
||||||
|
// 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
|
||||||
|
function unlockApiInput() {
|
||||||
|
if (apiInput.hasAttribute('readonly')) {
|
||||||
|
apiInput.removeAttribute('readonly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
|
||||||
|
apiInput.addEventListener('mousedown', unlockApiInput);
|
||||||
|
|
||||||
|
apiInput.addEventListener('focus', function () {
|
||||||
|
unlockApiInput();
|
||||||
|
openApiList('');
|
||||||
|
});
|
||||||
|
|
||||||
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
|
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
const authenticated = page.classList.contains('is-authenticated');
|
const authenticated = page.classList.contains('is-authenticated');
|
||||||
const base = page.getAttribute('data-base') || '/apistatus';
|
const base = page.getAttribute('data-base') || '/apistatus';
|
||||||
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
||||||
|
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
|
||||||
|
|
||||||
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
||||||
const linkableApiIds = new Set();
|
const linkableApiIds = new Set();
|
||||||
|
|
||||||
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
|
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
|
||||||
|
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
|
||||||
|
|
||||||
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
|
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
|
||||||
const ACTIVE_INCIDENT_LIMIT = 1;
|
const ACTIVE_INCIDENT_LIMIT = 1;
|
||||||
@@ -81,25 +83,65 @@
|
|||||||
const query = [];
|
const query = [];
|
||||||
if (params.date) query.push('date=' + encodeURIComponent(params.date));
|
if (params.date) query.push('date=' + encodeURIComponent(params.date));
|
||||||
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
|
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
|
||||||
|
if (params.apiName) query.push('apiName=' + encodeURIComponent(params.apiName));
|
||||||
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
|
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
|
* 영향 API 태그.
|
||||||
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
|
*
|
||||||
|
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
|
||||||
|
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
|
||||||
*/
|
*/
|
||||||
function apiPillsHtml(apis, label) {
|
function apiPillsHtml(apis, label, hiddenCount) {
|
||||||
if (!apis || !apis.length) return '';
|
const hidden = hiddenCount || 0;
|
||||||
const pills = apis.map(function (api) {
|
if ((!apis || !apis.length) && hidden === 0) return '';
|
||||||
|
|
||||||
|
const pills = (apis || []).map(function (api) {
|
||||||
const text = escapeHtml(api.apiName || api.apiId);
|
const text = escapeHtml(api.apiName || api.apiId);
|
||||||
if (!linkableApiIds.has(api.apiId)) {
|
if (!linkableApiIds.has(api.apiId)) {
|
||||||
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
|
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
|
||||||
+ text + '</span>';
|
|
||||||
}
|
}
|
||||||
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
||||||
+ text + '</a>';
|
+ text + '</a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + '</div>';
|
|
||||||
|
const hiddenPill = hidden > 0
|
||||||
|
? '<span class="as-api-pill is-gw" title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">'
|
||||||
|
+ 'GW 인터페이스 ' + hidden + '건</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="as-api-pills"><span>' + escapeHtml(label) + '</span>' + pills + hiddenPill + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈에 연결된 공지 본문. 서버가 게시 중(USE_YN='Y')인 공지만 내려주므로 여기서는 존재 여부만 본다.
|
||||||
|
* 본문은 관리자가 에디터로 작성한 HTML 이라 공지 상세(th:utext)와 같이 그대로 렌더한다.
|
||||||
|
*/
|
||||||
|
function noticeHtml(incident) {
|
||||||
|
if (!incident.noticeDetail) return '';
|
||||||
|
const link = incident.noticeId
|
||||||
|
? '<a class="as-alert-notice-link" href="' + noticeDetailBase + '?id='
|
||||||
|
+ encodeURIComponent(incident.noticeId) + '">공지 전체 보기 →</a>'
|
||||||
|
: '';
|
||||||
|
return '<div class="as-alert-notice">'
|
||||||
|
+ '<div class="as-alert-notice-head">'
|
||||||
|
+ '<span class="as-alert-notice-label">공지사항</span>'
|
||||||
|
+ (incident.noticeSubject ? '<strong>' + escapeHtml(incident.noticeSubject) + '</strong>' : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="as-alert-notice-body editor-content">' + incident.noticeDetail + '</div>'
|
||||||
|
+ link
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 | 공지 2열 배치. 데스크탑에서만 두 칸으로 갈라지고 좁은 화면에서는 세로로 쌓인다.
|
||||||
|
* 한쪽만 있으면 감싸지 않는다 (혼자 반쪽 폭을 차지하지 않게).
|
||||||
|
*/
|
||||||
|
function splitHtml(left, right) {
|
||||||
|
if (!left) return right;
|
||||||
|
if (!right) return left;
|
||||||
|
return '<div class="as-card-split">' + left + right + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- ❶ 진행 중 장애 ----------------
|
// ---------------- ❶ 진행 중 장애 ----------------
|
||||||
@@ -137,8 +179,9 @@
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
|
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
|
||||||
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
|
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
|
||||||
+ apiPillsHtml(incident.apis, '영향 API:')
|
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
|
||||||
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
|
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
|
||||||
|
noticeHtml(incident))
|
||||||
+ '</section>';
|
+ '</section>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -206,7 +249,7 @@
|
|||||||
document.getElementById('myApisCount').textContent = apis.length + '건';
|
document.getElementById('myApisCount').textContent = apis.length + '건';
|
||||||
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
|
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
|
||||||
const cls = badgeClass[api.currentStatus] || 'is-muted';
|
const cls = badgeClass[api.currentStatus] || 'is-muted';
|
||||||
return '<tr data-api-id="' + escapeHtml(api.apiId) + '">'
|
return '<tr data-api-id="' + escapeHtml(api.apiId) + '" data-api-name="' + escapeHtml(api.apiName) + '">'
|
||||||
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
|
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
|
||||||
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
|
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
|
||||||
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
|
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
|
||||||
@@ -216,7 +259,10 @@
|
|||||||
card.style.display = '';
|
card.style.display = '';
|
||||||
card.querySelectorAll('tbody tr').forEach(function (row) {
|
card.querySelectorAll('tbody tr').forEach(function (row) {
|
||||||
row.addEventListener('click', function () {
|
row.addEventListener('click', function () {
|
||||||
window.location.href = issuesHref({ apiId: row.getAttribute('data-api-id') });
|
window.location.href = issuesHref({
|
||||||
|
apiId: row.getAttribute('data-api-id'),
|
||||||
|
apiName: row.getAttribute('data-api-name')
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,8 +293,9 @@
|
|||||||
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
|
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
|
||||||
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
|
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
|
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
|
||||||
+ apiPillsHtml(card.impactedApis, '영향 API:')
|
noticeHtml(card))
|
||||||
|
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
|
||||||
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
|
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
|
||||||
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
|
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -258,8 +305,8 @@
|
|||||||
|
|
||||||
// ---------------- ❺ 지난 이슈 사항 ----------------
|
// ---------------- ❺ 지난 이슈 사항 ----------------
|
||||||
function issueCardHtml(issue) {
|
function issueCardHtml(issue) {
|
||||||
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
|
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
|
||||||
const kindBadge = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
|
const kindBadge = kindClass;
|
||||||
|
|
||||||
let inner;
|
let inner;
|
||||||
if (issue.kind === 'MAINTENANCE') {
|
if (issue.kind === 'MAINTENANCE') {
|
||||||
@@ -295,8 +342,8 @@
|
|||||||
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
||||||
+ ' </div>'
|
+ ' </div>'
|
||||||
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
||||||
+ inner
|
+ splitHtml(inner, noticeHtml(issue))
|
||||||
+ apiPillsHtml(issue.impactedApis, '영향 API:')
|
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
|
||||||
+ '</article>'
|
+ '</article>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,11 @@ const customPopups = {
|
|||||||
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
|
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
|
||||||
|
|
||||||
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
|
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
|
||||||
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
|
// type 은 열려 있는 동안만 password 로 전환한다.
|
||||||
|
// 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
|
||||||
|
// 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
|
||||||
|
$('#passwordPopupInput').val('').removeClass('error')
|
||||||
|
.attr('type', 'password').attr('placeholder', placeholder);
|
||||||
$('#passwordPopupError').removeClass('show').text('');
|
$('#passwordPopupError').removeClass('show').text('');
|
||||||
|
|
||||||
// 팝업 표시 (modal 구조 사용)
|
// 팝업 표시 (modal 구조 사용)
|
||||||
@@ -219,8 +223,8 @@ const customPopups = {
|
|||||||
// Body 스크롤 복원
|
// Body 스크롤 복원
|
||||||
$('body').css('overflow', '');
|
$('body').css('overflow', '');
|
||||||
|
|
||||||
// 입력 필드 초기화
|
// 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다)
|
||||||
$('#passwordPopupInput').val('').removeClass('error');
|
$('#passwordPopupInput').val('').removeClass('error').attr('type', 'text');
|
||||||
$('#passwordPopupError').removeClass('show').text('');
|
$('#passwordPopupError').removeClass('show').text('');
|
||||||
|
|
||||||
// 이벤트 리스너 제거
|
// 이벤트 리스너 제거
|
||||||
|
|||||||
@@ -285,20 +285,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pulsing {
|
.pulsing {
|
||||||
animation: pulse 2s ease-in-out infinite;
|
transform-origin: 400px 110px;
|
||||||
|
transform-box: fill-box;
|
||||||
|
animation: pulseLine 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes pulseLine {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateX(75px) scale(1.1);
|
||||||
|
/* 하나의 transform에 한 줄로 작성 */
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1);
|
transform: translate(0, 0) scale(1);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: scale(1.1);
|
transform: translateX(75px) scale(1.1);
|
||||||
opacity: 0.5;
|
/* 하나의 transform에 한 줄로 작성 */
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
background-color: #fcfcfc;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #7f8a95;
|
color: #7f8a95;
|
||||||
|
|||||||
@@ -1010,6 +1010,7 @@ select.form-control {
|
|||||||
resize: none;
|
resize: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
|
background-color: #fcfcfc;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #94A3B8;
|
color: #94A3B8;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--accent-light);
|
background-color: var(--accent-light);
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@
|
|||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
& + p {
|
&+p {
|
||||||
margin-top: $spacing-md;
|
margin-top: $spacing-md;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,6 @@
|
|||||||
.btn,
|
.btn,
|
||||||
.btn-modal-cancel,
|
.btn-modal-cancel,
|
||||||
.btn-modal-confirm {
|
.btn-modal-confirm {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -330,4 +329,4 @@
|
|||||||
font-size: $font-size-base;
|
font-size: $font-size-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-link--external {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.footer-link-external-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.footer-separator {
|
.footer-separator {
|
||||||
color: #D1D5DB;
|
color: #D1D5DB;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
height: clamp(44px, 11.73vw, 60px);
|
height: clamp(44px, 11.73vw, 60px);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -128,9 +128,31 @@
|
|||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
|
||||||
|
img { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.mobile-logo-link {
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logo-text {
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
// "API / Portal" 로 접히지 않게
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +180,7 @@
|
|||||||
|
|
||||||
// Logo with modern styling
|
// Logo with modern styling
|
||||||
.logo {
|
.logo {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -202,7 +224,6 @@
|
|||||||
|
|
||||||
// Logo link styles
|
// Logo link styles
|
||||||
.logo-link,
|
.logo-link,
|
||||||
.mobile-logo-link,
|
|
||||||
.drawer-logo-link {
|
.drawer-logo-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -252,7 +273,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +284,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,7 +958,7 @@
|
|||||||
|
|
||||||
.sub-menu {
|
.sub-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 17px);
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
@@ -982,6 +1003,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary-blue);
|
color: var(--primary-blue);
|
||||||
@@ -1096,7 +1119,7 @@
|
|||||||
// Mobile Responsive Design
|
// Mobile Responsive Design
|
||||||
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
|
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
|
||||||
// ===========================
|
// ===========================
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.global-header {
|
.global-header {
|
||||||
// Figma: 44px 높이, 반투명 배경
|
// Figma: 44px 높이, 반투명 배경
|
||||||
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
|
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
|
||||||
@@ -1247,7 +1270,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mobile responsive
|
// Mobile responsive
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.header-user-info {
|
.header-user-info {
|
||||||
display: none; // Hide on mobile, use drawer instead
|
display: none; // Hide on mobile, use drawer instead
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,7 +571,7 @@
|
|||||||
// Result Page Styles (아이디 찾기 결과 페이지)
|
// Result Page Styles (아이디 찾기 결과 페이지)
|
||||||
.account-recovery-result {
|
.account-recovery-result {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 60px 40px;
|
padding: 35px 0px;
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
@@ -690,6 +690,44 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
|
|
||||||
|
&.info-box-highlight {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 73, 180, 0.06) 0%, rgba(0, 73, 180, 0.02) 100%);
|
||||||
|
border: 1.5px solid rgba(0, 73, 180, 0.25);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transform: translateY(15px);
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.highlight-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
animation: pulseIcon 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1e293b;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0049B4;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #4685ef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
.info-text {
|
.info-text {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -725,6 +763,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseIcon {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: drop-shadow(0 0 0px rgba(0, 73, 180, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0, 73, 180, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Mobile Design (sm: 768px breakpoint)
|
// Mobile Design (sm: 768px breakpoint)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -785,7 +844,7 @@
|
|||||||
.auth-input-group {
|
.auth-input-group {
|
||||||
.auth-timer {
|
.auth-timer {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
right: 100px;
|
right: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,16 +467,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-badge {
|
/* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다.
|
||||||
|
기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
|
||||||
|
&-badges {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 39px;
|
top: 39px;
|
||||||
left: 32px;
|
left: 32px;
|
||||||
|
right: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-badge {
|
||||||
background-color: #f2f2f3;
|
background-color: #f2f2f3;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 2px 20px;
|
padding: 2px 20px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -484,9 +495,34 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1b4ab7;
|
color: #1b4ab7;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 현재 상태 태그. 색 규격은 API Status 화면(.as-badge)과 동일.
|
||||||
|
상태를 못 받았을 때(빈 값)는 자리만 차지하지 않도록 숨긴다 */
|
||||||
|
&-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #eef1f5;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
&:empty { display: none; }
|
||||||
|
|
||||||
|
&.is-outage { background: #feeae7; color: #c8362f; }
|
||||||
|
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
|
||||||
|
&.is-degraded { background: #fef3c7; color: #c77800; }
|
||||||
|
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
|
||||||
|
}
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 86px;
|
top: 86px;
|
||||||
@@ -846,7 +882,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API Overview Card (Flat Style)
|
// API Overview Card (Flat Style)
|
||||||
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
|
/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */
|
||||||
.api-basic-info-header {
|
.api-basic-info-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -855,6 +891,41 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 현재 상태 배지 + "상태 이력 확인" 버튼.
|
||||||
|
색 규격은 API Status 화면(.as-badge)과 동일하게 맞춘다 */
|
||||||
|
.api-status-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.api-status-inline__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-status-inline__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #eef1f5;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
&.is-outage { background: #feeae7; color: #c8362f; }
|
||||||
|
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
|
||||||
|
&.is-degraded { background: #fef3c7; color: #c77800; }
|
||||||
|
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
|
||||||
|
&.is-loading { color: #6b7280; }
|
||||||
|
&.is-muted { background: #eef1f5; color: #6b7280; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 하단 중앙 "API 사용 신청" 버튼 */
|
/* 하단 중앙 "API 사용 신청" 버튼 */
|
||||||
.api-apply-footer {
|
.api-apply-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
// 설계: architecture/01-api-status/02-screen-design
|
// 설계: architecture/01-api-status/02-screen-design
|
||||||
// 타이틀 배너: Figma eapim-portal node 1:16
|
// 타이틀 배너: Figma eapim-portal node 1:16
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
@use '../abstracts/variables' as *;
|
||||||
|
|
||||||
|
// API Status 화면은 제목 포함 전부 본문 폰트(Spoqa Han Sans)를 쓴다.
|
||||||
|
// 전역 타이포가 h1~h6 에 $font-family-heading(OneShinhan 우선)을 지정하므로 페이지 범위에서 되돌린다.
|
||||||
|
.as-title-banner,
|
||||||
|
.as-title-banner h1,
|
||||||
|
.api-status,
|
||||||
|
.api-status h1,
|
||||||
|
.api-status h2,
|
||||||
|
.api-status h3,
|
||||||
|
.api-status h4 {
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
}
|
||||||
|
|
||||||
// 타이틀 배너 — 레이아웃의 .container 안에 놓이므로 컨테이너 폭을 따른다
|
// 타이틀 배너 — 레이아웃의 .container 안에 놓이므로 컨테이너 폭을 따른다
|
||||||
.as-title-banner {
|
.as-title-banner {
|
||||||
@@ -92,6 +105,9 @@
|
|||||||
--as-pill: 999px;
|
--as-pill: 999px;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
// minmax(0,1fr): grid 자식의 암묵적 min-width(auto)를 해제한다.
|
||||||
|
// 90일 막대(90개 × 최소폭)가 카드의 최소 폭을 밀어올려 좁은 화면에서 카드가 잘리는 것 방지
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 0 0 70px;
|
padding: 0 0 70px;
|
||||||
|
|
||||||
@@ -237,6 +253,83 @@
|
|||||||
color: var(--as-muted);
|
color: var(--as-muted);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||||
|
.as-api-pill.is-gw {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--as-border-strong);
|
||||||
|
color: var(--as-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임라인 | 공지 2열. 데스크탑(961px~)에서만 갈라지고 그 아래는 세로로 쌓인다.
|
||||||
|
// minmax(0, 1fr) 은 공지 본문의 긴 URL·표가 칸을 밀어내지 못하게 하는 grid 관용구.
|
||||||
|
.as-card-split {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 감싼 두 블록의 자체 세로 margin 은 grid gap 으로 대체한다.
|
||||||
|
// 카드 컨텍스트를 함께 써서 원 규칙(.as-alert-card .as-alert-timeline 등)보다 특이도를 높인다.
|
||||||
|
.as-card-split > .as-alert-notice { margin-top: 0; }
|
||||||
|
.as-alert-card .as-card-split > .as-alert-timeline { margin-top: 0; }
|
||||||
|
.as-maint-card .as-card-split > .as-maint-body { margin-top: 0; }
|
||||||
|
|
||||||
|
// 이슈에 연결된 공지 본문 (진행 중 장애 카드 · 점검 카드 공용).
|
||||||
|
// 본문 길이가 제각각이라 높이를 제한하고 안쪽만 스크롤시킨다.
|
||||||
|
.as-alert-notice {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-radius: var(--as-radius);
|
||||||
|
background: var(--as-gray-bg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
|
||||||
|
.as-alert-notice-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
strong { font-size: 14px; color: var(--as-text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--as-pill);
|
||||||
|
background: var(--as-info-bg);
|
||||||
|
color: var(--as-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-body {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--as-text-2);
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
img, table { max-width: 100%; }
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-alert-notice-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--as-info);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 진행 중 장애 카드 ----------------
|
// ---------------- 진행 중 장애 카드 ----------------
|
||||||
@@ -368,6 +461,8 @@
|
|||||||
&.is-maintenance { background: var(--as-info); }
|
&.is-maintenance { background: var(--as-info); }
|
||||||
&.is-degraded { background: var(--as-warn); }
|
&.is-degraded { background: var(--as-warn); }
|
||||||
&.is-none { background: var(--as-none-bg); }
|
&.is-none { background: var(--as-none-bg); }
|
||||||
|
// 장애·지연·점검 중 2종 이상이 겹친 날
|
||||||
|
&.is-mixed,
|
||||||
&.is-both { background: var(--as-both); }
|
&.is-both { background: var(--as-both); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,6 +572,7 @@
|
|||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
&.is-incident { border-left: 4px solid var(--as-err); }
|
&.is-incident { border-left: 4px solid var(--as-err); }
|
||||||
|
&.is-degraded { border-left: 4px solid var(--as-warn); }
|
||||||
&.is-maintenance { border-left: 4px solid var(--as-info); }
|
&.is-maintenance { border-left: 4px solid var(--as-info); }
|
||||||
|
|
||||||
.as-issue-meta-row {
|
.as-issue-meta-row {
|
||||||
@@ -498,6 +594,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-incident .as-issue-title { color: var(--as-err); }
|
&.is-incident .as-issue-title { color: var(--as-err); }
|
||||||
|
&.is-degraded .as-issue-title { color: var(--as-warn); }
|
||||||
&.is-maintenance .as-issue-title { color: var(--as-info); }
|
&.is-maintenance .as-issue-title { color: var(--as-info); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +682,10 @@
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
&.has-incident { background: var(--as-err); }
|
&.has-incident { background: var(--as-err); }
|
||||||
|
&.has-degraded { background: var(--as-warn); }
|
||||||
&.has-maintenance { background: var(--as-info); }
|
&.has-maintenance { background: var(--as-info); }
|
||||||
|
// 유형이 2종 이상 섞인 날
|
||||||
|
&.has-mixed,
|
||||||
&.has-both { background: var(--as-both); }
|
&.has-both { background: var(--as-both); }
|
||||||
&.is-selected { outline: 2px solid var(--as-text-2); outline-offset: 1px; }
|
&.is-selected { outline: 2px solid var(--as-text-2); outline-offset: 1px; }
|
||||||
&:hover { opacity: 0.75; }
|
&:hover { opacity: 0.75; }
|
||||||
@@ -732,6 +832,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 포탈에 게시되지 않은 API 필터 안내
|
||||||
|
.as-unlisted-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 1px solid var(--as-border);
|
||||||
|
border-left: 4px solid var(--as-muted);
|
||||||
|
border-radius: var(--as-radius);
|
||||||
|
background: #f6f8fa;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--as-text-3);
|
||||||
|
|
||||||
|
b { color: var(--as-text); }
|
||||||
|
|
||||||
|
.as-unlisted-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--as-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.as-pager {
|
.as-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -805,7 +927,14 @@
|
|||||||
.as-filter-right { justify-content: space-between; }
|
.as-filter-right { justify-content: space-between; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.as-index-bar { height: 36px; }
|
// 90일 바: 셀을 균등 축소하지 않고 최소 터치 폭을 보장한 뒤 가로 스크롤
|
||||||
|
.as-index-bar {
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
.as-index-cell { flex: 0 0 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-uptime .as-bar-chart .as-bar { flex: 0 0 12px; }
|
||||||
|
|
||||||
.as-issue-card { padding: 18px 16px; }
|
.as-issue-card { padding: 18px 16px; }
|
||||||
.as-issue-title { font-size: 17px; }
|
.as-issue-title { font-size: 17px; }
|
||||||
|
|||||||
@@ -168,16 +168,16 @@
|
|||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid #dadada;
|
border: 1px solid #DFDFDF;
|
||||||
border-radius: $border-radius-md;
|
border-radius: $border-radius-md;
|
||||||
background-color: $white;
|
background-color: #fcfcfc;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #dadada;
|
color: #94A3B8;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -1130,7 +1130,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background-color: #E2E8F0 !important;
|
background-color: #ededed !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2929,7 +2929,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
.s1-title,
|
.s1-title,
|
||||||
.s2-title,
|
.s2-title,
|
||||||
.s3-title {
|
.s3-title {
|
||||||
font-size: 30px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0d0e11;
|
color: #0d0e11;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -3261,7 +3261,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.s1-upload-inner > img {
|
.s1-upload-inner>img {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,342 +423,6 @@
|
|||||||
// API Search section styles - 검색 섹션
|
// API Search section styles - 검색 섹션
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/* 원래 있던 검색 영역 스타일 (주석 처리)
|
|
||||||
.api-search-section {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
//margin-top: -72px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #e9f9ff;
|
|
||||||
height: 283px;
|
|
||||||
|
|
||||||
.search-background {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url('/img/bg_main_intersect.svg');
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-content-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 36px;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 22px;
|
|
||||||
padding-top: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 캐릭터와 텍스트 가로 배치
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 7px;
|
|
||||||
padding-top: 18px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-character {
|
|
||||||
width: 139px;
|
|
||||||
height: 133px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
width: 108px;
|
|
||||||
height: 104px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 68x65px
|
|
||||||
width: 61px;
|
|
||||||
height: 59px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-text-content {
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 텍스트 왼쪽 정렬
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-title {
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #000000;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 14px, Bold
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0px 0 27px;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
padding: 27px 18px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 좌측 24px, 우측 25px 패딩으로 좌측 정렬
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 0px 25px 11px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 816px;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 288px 고정 너비
|
|
||||||
//width: 288px;
|
|
||||||
//max-width: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 72px;
|
|
||||||
background: #FFFFFF;
|
|
||||||
border: 5px solid #0049B4;
|
|
||||||
border-radius: 45px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 22px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
height: 54px;
|
|
||||||
border: 4px solid #0049B4;
|
|
||||||
padding: 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 320x36px, 1px 파란색 테두리
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid #0049B4;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 0 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #000000;
|
|
||||||
padding: 0 18px;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #B3B3B3;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 0 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 10px, Medium, placeholder 색상 #8C959F
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0 7px;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #8C959F;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 30px;
|
|
||||||
height: 31px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
width: 25px;
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 16x16px 아이콘
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-section {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 11px;
|
|
||||||
padding: 0 18px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 0 18px 27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 가로 배치, 작은 간격
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 11px;
|
|
||||||
padding: 0 18px 18px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-label {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #000000;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 11px, Bold
|
|
||||||
font-size: 11px;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-list {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 13px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
justify-content: center;
|
|
||||||
gap: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 7px 간격, wrap
|
|
||||||
gap: 7px;
|
|
||||||
justify-content: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-link {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #000000;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #0049B4;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 11px
|
|
||||||
font-size: 11px;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashtag-separator {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #000000;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 세로 구분선 (|)
|
|
||||||
font-size: 9px;
|
|
||||||
color: #212529;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 피그마 시안 적용 카드 레이아웃 스타일 */
|
/* 피그마 시안 적용 카드 레이아웃 스타일 */
|
||||||
.search-container {
|
.search-container {
|
||||||
@@ -1195,8 +859,9 @@
|
|||||||
|
|
||||||
.api-cards-container {
|
.api-cards-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 29px;
|
gap: 29px;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
|
||||||
//@include respond-to('lg') {
|
//@include respond-to('lg') {
|
||||||
// gap: 22px;
|
// gap: 22px;
|
||||||
@@ -1217,6 +882,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.api-card {
|
.api-card {
|
||||||
|
// 1행 4열 고정 (gap 29px * 3 = 87px)
|
||||||
|
flex: 0 0 calc((100% - 87px) / 4);
|
||||||
|
max-width: calc((100% - 87px) / 4);
|
||||||
|
box-sizing: border-box;
|
||||||
height: 288px;
|
height: 288px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1245,12 +914,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('md') {
|
@include respond-to('md') {
|
||||||
|
flex: 0 0 calc(50% - 9px);
|
||||||
|
max-width: calc(50% - 9px);
|
||||||
width: calc(50% - 9px);
|
width: calc(50% - 9px);
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 144px width, auto height, padding 18px 17px
|
// Figma 모바일: 144px width, auto height, padding 18px 17px
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
@@ -1288,14 +961,21 @@
|
|||||||
|
|
||||||
.card-description {
|
.card-description {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
color: var(--text-gray);
|
color: var(--text-gray);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 3em;
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
// Figma 모바일: 11px Medium, 색상 #515151
|
// Figma 모바일: 11px Medium, 색상 #515151
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
@@ -1425,7 +1105,7 @@
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1447,7 +1127,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-description {
|
.info-description {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
margin-bottom: 36px;
|
margin-bottom: 36px;
|
||||||
@@ -1459,7 +1139,7 @@
|
|||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
order: 3;
|
order: 3;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
@@ -1637,424 +1317,6 @@
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Support Center Section - 비지니스의 시작 (Figma Design)
|
// Support Center Section - 비지니스의 시작 (Figma Design)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
/* 원래 있던 support-center 스타일 (주석 처리)
|
|
||||||
.support-center {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
padding-top: 92px;
|
|
||||||
padding-bottom: 113px;
|
|
||||||
background: #eef7fd;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 648px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
height: auto;
|
|
||||||
padding-bottom: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 353px 높이, 패딩 54px 18px
|
|
||||||
min-height: unset;
|
|
||||||
height: auto;
|
|
||||||
padding: 54px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-background {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 1920px;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 15px; // Figma: top-[2911px] relative to page, section starts at ~2895px
|
|
||||||
left: 46%; // Approximate positioning
|
|
||||||
width: 624px;
|
|
||||||
height: 537px;
|
|
||||||
background-image: url('/img/bg_support_center.png'); // Assuming this image exists or use placeholder
|
|
||||||
background-size: cover;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
opacity: 0.5; // Adjust based on visual
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
|
|
||||||
// Figma 모바일: 포인트 이미지 상단 우측
|
|
||||||
&::before {
|
|
||||||
top: 0;
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
width: 168px;
|
|
||||||
height: 159px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 23px;
|
|
||||||
height: 443px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
padding: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
margin-bottom: 36px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 타이틀 영역
|
|
||||||
margin-bottom: 22px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 25px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #000000;
|
|
||||||
margin: 0 0 $spacing-lg 0;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 타이틀 스타일
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-regular {
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bold {
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 18px Bold
|
|
||||||
font-size: 18px;
|
|
||||||
letter-spacing: -0.4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 29px;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: stretch;
|
|
||||||
max-width: 1228px; // 598 + 283 + 283 + (32 * 2) = 1228px
|
|
||||||
height: auto;
|
|
||||||
min-height: 288px;
|
|
||||||
margin: 0 auto;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
@include respond-to('mobile') {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 18px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// 가로 배치는 카드가 너무 좁아지므로 1열 세로 배치로 변경
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 14px;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 통합 Support Card
|
|
||||||
.support-card {
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 18px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 288px; // 모든 카드 동일 높이
|
|
||||||
|
|
||||||
@include respond-to('mobile') {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 598px;
|
|
||||||
height: auto;
|
|
||||||
min-height: 234px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 101x168px 카드
|
|
||||||
width: calc(33.333% - 7px);
|
|
||||||
min-width: 0;
|
|
||||||
height: 151px;
|
|
||||||
min-height: 151px;
|
|
||||||
max-width: none;
|
|
||||||
border-radius: 11px;
|
|
||||||
padding: 23px 0 23px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 9px 27px rgba(0, 73, 180, 0.15);
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 43px 아이콘
|
|
||||||
width: 43px !important;
|
|
||||||
height: 43px !important;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 29px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #212529;
|
|
||||||
margin: 0 0 9px 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 15-14px Bold
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-family: $font-family-primary;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #515961;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 9px Regular
|
|
||||||
font-size: 9px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 공지사항 카드
|
|
||||||
&-notice {
|
|
||||||
width: 349px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 45px 36px;
|
|
||||||
background: #0049B4;
|
|
||||||
|
|
||||||
@include respond-to('mobile') {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 598px;
|
|
||||||
padding: 45px 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 공지사항 카드 스타일
|
|
||||||
width: 100%;
|
|
||||||
padding: 23px 20px 23px;
|
|
||||||
gap: 17px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
width: 108px;
|
|
||||||
height: 108px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 27px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 29px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #FFFFFF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken(#0049B4, 5%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FAQ & Q&A Cards
|
|
||||||
&-faq,
|
|
||||||
&-qna {
|
|
||||||
width: 349px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 45px 36px;
|
|
||||||
|
|
||||||
@include respond-to('mobile') {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 598px;
|
|
||||||
padding: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: FAQ/Q&A 카드 스타일
|
|
||||||
width: 100%;
|
|
||||||
padding: 23px 20px 23px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
width: 108px;
|
|
||||||
height: 108px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
flex: none;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 27px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 29px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 14px Bold
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
@include respond-to('md') {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
|
||||||
// Figma 모바일: 9px Regular
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FAQ Card Specifics
|
|
||||||
&--faq {
|
|
||||||
background: #DAF0FF;
|
|
||||||
|
|
||||||
.card-content h3 {
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Q&A Card Specifics
|
|
||||||
&--qna {
|
|
||||||
background: #D9F6F8;
|
|
||||||
|
|
||||||
.card-content h3 {
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */
|
/* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */
|
||||||
.support-center {
|
.support-center {
|
||||||
@@ -2130,9 +1392,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: var(--text-gray);
|
color: var(--text-gray);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-arrow {
|
.card-arrow {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
border-bottom: 1px solid #000;
|
border-bottom: 1px solid #818181;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-label-text {
|
.form-label-text {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
|
|
||||||
.form-input:disabled,
|
.form-input:disabled,
|
||||||
.input-readonly {
|
.input-readonly {
|
||||||
background: #e9ecef !important;
|
background: #ededed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compound-input {
|
.compound-input {
|
||||||
@@ -289,6 +289,7 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
color: #4685ef
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
|||||||
@@ -136,9 +136,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--completed {
|
&--completed {
|
||||||
background: #e5f0ff;
|
background: #d6fbd0;
|
||||||
color: #0a4ea3;
|
color: #11af4c;
|
||||||
border: 1px solid #b3d4ff;
|
border: 1px solid #a0d4b2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--closed {
|
&--closed {
|
||||||
@@ -159,6 +159,12 @@
|
|||||||
border: 1px solid #b3d4ff;
|
border: 1px solid #b3d4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--reviewing {
|
||||||
|
background: #e5f0ff;
|
||||||
|
color: #0a4ea3;
|
||||||
|
border: 1px solid #b3d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
&--test {
|
&--test {
|
||||||
background: #e6fffa;
|
background: #e6fffa;
|
||||||
color: #0d9488;
|
color: #0d9488;
|
||||||
@@ -352,6 +358,14 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
|
||||||
|
&.is-gw {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-affected-toggle {
|
.btn-affected-toggle {
|
||||||
@@ -443,6 +457,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 장애 처리 타임라인 — 상단 장애정보 표와 같은 규격의 간결한 표
|
||||||
|
.notice-detail-timeline {
|
||||||
|
padding-bottom: 40px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
margin-bottom: $spacing-3xl;
|
||||||
|
|
||||||
|
.notice-timeline-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-bottom: 1px solid #e3e8f0;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #f9f9f9;
|
||||||
|
height: 43px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000;
|
||||||
|
border-bottom: 1px solid #e3e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #e3e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ts {
|
||||||
|
color: #555;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-body {
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-md) {
|
||||||
|
table-layout: auto;
|
||||||
|
|
||||||
|
colgroup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th,
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ts {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 배지 — API Status 타임라인 dot 색 규격과 동일
|
||||||
|
.timeline-state {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #eef1f5;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
&.state-RESOLVED { background: #e7f6ec; color: #1b8c4a; }
|
||||||
|
&.state-MONITORING,
|
||||||
|
&.state-INVESTIGATING { background: #fef3c7; color: #c77800; }
|
||||||
|
&.state-IDENTIFIED { background: #fdeee2; color: #c95a0f; }
|
||||||
|
&.state-CANCELED { background: #eef1f5; color: #6b7280; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notice Detail Actions
|
// Notice Detail Actions
|
||||||
.notice-detail-actions {
|
.notice-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -34,6 +34,53 @@
|
|||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
padding: $spacing-lg $spacing-md;
|
padding: $spacing-lg $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invitation_alert_banner {
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
border: 1.5px solid #2196F3;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.08);
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transform: translateY(15px);
|
||||||
|
opacity: 0;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
|
||||||
|
.banner-content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
animation: pulseIcon 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text-area {
|
||||||
|
strong {
|
||||||
|
color: #1976D2;
|
||||||
|
font-size: 17px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -90,7 +137,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
padding: 0 0 $spacing-md 0;
|
padding: 0 0 $spacing-md 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid #212529;
|
border-bottom: 1px solid #818181;
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -315,6 +362,16 @@
|
|||||||
background-position: right 8px center;
|
background-position: right 8px center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-form-select.phone-prefix {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 90px;
|
||||||
|
max-width: 130px;
|
||||||
|
padding-right: 28px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
color: $text-gray;
|
color: $text-gray;
|
||||||
font-weight: $font-weight-semibold;
|
font-weight: $font-weight-semibold;
|
||||||
@@ -336,29 +393,31 @@
|
|||||||
// Select Dropdown
|
// Select Dropdown
|
||||||
.org-form-select {
|
.org-form-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
height: 48px;
|
||||||
border: 1px solid $border-gray;
|
padding: 0 16px;
|
||||||
border-radius: $border-radius-md;
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $text-dark;
|
color: #212529;
|
||||||
background: $white;
|
background: $white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: $transition-base;
|
transition: $transition-base;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right $spacing-lg center;
|
background-position: right 16px center;
|
||||||
padding-right: $spacing-3xl;
|
padding-right: $spacing-3xl;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $primary-blue;
|
border-color: #3ba4ed;
|
||||||
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1);
|
box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background-color: $gray-bg;
|
background-color: #F8F9FA;
|
||||||
color: $text-light;
|
border-color: #E2E8F0;
|
||||||
|
color: #94A3B8;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,26 +386,31 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
padding-bottom: 100px;
|
padding-bottom: 100px;
|
||||||
|
|
||||||
.signup-info-box {
|
.signup-info-box {
|
||||||
background: #f5f5f5;
|
background: #1d2a5e; // Deep slate dark card
|
||||||
border-radius: 12px;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
padding: 30px 40px;
|
border-left: 5px solid #2a69de; // Bright tech blue accent line
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 30px;
|
gap: 28px;
|
||||||
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
width: 76px;
|
width: 56px;
|
||||||
height: 76px;
|
height: 56px;
|
||||||
background: #fff;
|
background: #48507b;
|
||||||
border-radius: 50%;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 40px;
|
width: 28px;
|
||||||
height: 40px;
|
height: 28px;
|
||||||
|
filter: brightness(0) invert(1); // Turns the green check into clean white icon
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,183 +422,397 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
|
|
||||||
li {
|
li {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 14px;
|
padding-left: 20px;
|
||||||
color: #535151;
|
color: #94a3b8; // Sophisticated dim gray-blue
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '·';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 4px;
|
||||||
color: #535151;
|
top: 10px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: #38bdf8; // Neon cyan dot
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&+li {
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.signup-timeline {
|
// Member Type Tabs
|
||||||
|
.signup-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-bottom: 1px solid #e3e8f0;
|
||||||
|
margin-top: 40px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.signup-tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 50%;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #2a69de;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 2px solid #2a69de;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-timeline-container {
|
||||||
|
margin-bottom: 50px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.signup-timeline {
|
||||||
|
display: none; // Hidden by default, toggled via JS
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 80px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 17px; // center of line
|
||||||
|
top: 47px;
|
||||||
|
bottom: 47px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e6eefb; // Light blue line
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
// Glowing Dot
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #2a69de;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-box {
|
||||||
|
width: 95px;
|
||||||
|
min-height: 95px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e3e8f0;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e3e8f0;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px 30px;
|
||||||
|
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 40px;
|
justify-content: center;
|
||||||
position: relative;
|
min-height: 95px;
|
||||||
padding-left: 80px;
|
|
||||||
|
|
||||||
&::before {
|
&-header {
|
||||||
content: '';
|
display: flex;
|
||||||
position: absolute;
|
align-items: center;
|
||||||
left: 17px; // center of line
|
margin-bottom: 6px;
|
||||||
top: 47px;
|
}
|
||||||
bottom: 47px;
|
|
||||||
width: 2px;
|
&-num {
|
||||||
background: #e6eefb; // Light blue line
|
font-size: 14px;
|
||||||
z-index: 0;
|
font-weight: 700;
|
||||||
|
color: #1b4ab7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: #1b4ab7;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signup-step {
|
.one {
|
||||||
|
color: #2C4A8C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two {
|
||||||
|
color: #1F7A5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three {
|
||||||
|
color: #F98E24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.four {
|
||||||
|
color: #2480F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.five {
|
||||||
|
color: #FF5C5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.six {
|
||||||
|
color: #5B4AEE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한별 이용 가능 서비스 (TBD placeholder)
|
||||||
|
.signup-roles-tbd {
|
||||||
|
margin-top: 40px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #140064;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 40px;
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px dashed #e3e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #0049B4;
|
||||||
|
background: #eff9fe;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #6e7781;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한별 이용 가능 서비스 테이블 디자인 추가
|
||||||
|
.signup-roles-table-section {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0 0 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
padding-left: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
|
||||||
// Glowing Dot
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
|
left: 0;
|
||||||
top: 50%;
|
top: 4px;
|
||||||
transform: translateY(-50%);
|
bottom: 4px;
|
||||||
width: 16px;
|
width: 4px;
|
||||||
height: 16px;
|
background: #0049B4;
|
||||||
background: #2a69de;
|
border-radius: 2px;
|
||||||
border-radius: 50%;
|
}
|
||||||
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
|
}
|
||||||
z-index: 2;
|
|
||||||
|
.signup-roles-table-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon-box {
|
&::-webkit-scrollbar-thumb {
|
||||||
width: 95px;
|
background: #cbd5e1;
|
||||||
height: 95px;
|
border-radius: 3px;
|
||||||
background: #fff;
|
}
|
||||||
border: 1px solid #e3e8f0;
|
}
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
|
||||||
|
|
||||||
svg {
|
.signup-roles-table {
|
||||||
width: 30px;
|
width: 100%;
|
||||||
height: 30px;
|
border-collapse: collapse;
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
thead {
|
||||||
flex: 1;
|
background: #f8fafc;
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e3e8f0;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 0 30px;
|
|
||||||
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
height: 95px;
|
|
||||||
|
|
||||||
&-num {
|
th {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&.col-feature {
|
||||||
|
width: 35%;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.col-role {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.col-role-group {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.col-sub-role {
|
||||||
|
width: 17.5%;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-feature {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// O / X 배지 디자인
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1b4ab7;
|
|
||||||
margin-bottom: 4px;
|
&--allowed {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--denied {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #000;
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.one {
|
|
||||||
color: #2C4A8C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.two {
|
|
||||||
color: #1F7A5C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.three {
|
|
||||||
color: #F98E24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.four {
|
|
||||||
color: #2480F9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.five {
|
|
||||||
color: #FF5C5C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.six {
|
|
||||||
color: #5B4AEE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-action {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 권한별 이용 가능 서비스 (TBD placeholder)
|
|
||||||
.signup-roles-tbd {
|
|
||||||
margin-top: 40px;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #140064;
|
|
||||||
margin: 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 48px 24px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px dashed #e3e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #0049B4;
|
|
||||||
background: #eff9fe;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__desc {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
color: #6e7781;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,12 +849,25 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.signup-guide-v2 {
|
.signup-guide-v2 {
|
||||||
|
.signup-timeline {
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none; // Hides the vertical timeline line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.signup-step {
|
.signup-step {
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none; // Hides the glowing timeline dot
|
||||||
|
}
|
||||||
|
|
||||||
&__icon-box {
|
&__icon-box {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
min-height: 60px;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -646,25 +878,13 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
&__card {
|
&__card {
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
padding: 10px 15px;
|
padding: 12px 18px;
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signup-timeline {
|
|
||||||
padding-left: 50px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 17px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-step::before {
|
|
||||||
left: -41px; // adjusted for new padding
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,23 +896,23 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
padding: 56px 16px 32px;
|
padding: 56px 16px 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.service_guide {
|
.service_guide {
|
||||||
.title {
|
.title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: #140064;
|
color: #140064;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
color: #495057;
|
color: #495057;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,9 +1337,10 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
|
|
||||||
&__prereq-title {
|
&__prereq-title {
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 8px;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $o2leg-text-dark;
|
color: $o2leg-text-dark;
|
||||||
|
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__prereq-body p {
|
&__prereq-body p {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
|
font-size: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -653,7 +654,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
border-bottom: 1.5px solid #4a4a4a;
|
border-bottom: 1.5px solid #818181;
|
||||||
|
|
||||||
.head-title-group {
|
.head-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -704,7 +705,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
.input-display-box {
|
.input-display-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background-color: #eeeeee;
|
background-color: #efefef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -714,6 +715,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #DFDFDF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badges-row {
|
.badges-row {
|
||||||
@@ -767,7 +769,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background-color: #eeeeee;
|
background-color: #efefef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -778,6 +780,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
border: 1px solid #DFDFDF;
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -69,9 +69,18 @@
|
|||||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
|
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
|
||||||
<!-- API Overview Card (Merged with Additional Information) -->
|
<!-- API Overview Card (Merged with Additional Information) -->
|
||||||
<div class="api-overview-card">
|
<div class="api-overview-card">
|
||||||
|
<!--/* 현재 상태는 게시된 장애·점검 공지 기준(/apistatus/current.json). 조회 실패해도 화면은 유지 */-->
|
||||||
<div class="org-section-header org-section-header--agreement api-basic-info-header">
|
<div class="org-section-header org-section-header--agreement api-basic-info-header">
|
||||||
<h3>기본 정보</h3>
|
<h3>기본 정보</h3>
|
||||||
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
|
<div class="api-status-inline" id="apiStatusInline"
|
||||||
|
th:attr="data-api-id=${apiSpecInfo.apiId},
|
||||||
|
data-status-url=@{/apistatus/current.json}">
|
||||||
|
<span class="api-status-inline__label">현재 상태</span>
|
||||||
|
<span class="api-status-inline__badge is-loading" id="apiStatusBadge">조회 중</span>
|
||||||
|
<!--/* apiName 은 표시용 - 이슈 이력 화면 필터는 apiId 로 동작한다 */-->
|
||||||
|
<a class="btn-action-primary md"
|
||||||
|
th:href="@{/apistatus/issues(apiId=${apiSpecInfo.apiId},apiName=${apiSpecInfo.apiName})}">상태 이력 확인</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="api-overview-header">
|
<div class="api-overview-header">
|
||||||
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
|
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
|
||||||
@@ -475,6 +484,51 @@
|
|||||||
btn.addEventListener('click', requestApiUse);
|
btn.addEventListener('click', requestApiUse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 기본 정보: 현재 상태 =====
|
||||||
|
// 게시된 장애·점검 공지 기준(/apistatus/current.json). 상태 이력은 /apistatus/issues?apiId= 로 이어진다.
|
||||||
|
(function loadApiCurrentStatus() {
|
||||||
|
const box = document.getElementById('apiStatusInline');
|
||||||
|
const badge = document.getElementById('apiStatusBadge');
|
||||||
|
if (!box || !badge) return;
|
||||||
|
|
||||||
|
const apiId = box.getAttribute('data-api-id');
|
||||||
|
const statusUrl = box.getAttribute('data-status-url');
|
||||||
|
if (!apiId || !statusUrl) return;
|
||||||
|
|
||||||
|
const BADGE_CLASS = {
|
||||||
|
OUTAGE: 'is-outage',
|
||||||
|
DEGRADED: 'is-degraded',
|
||||||
|
MAINTENANCE: 'is-maintenance',
|
||||||
|
NORMAL: 'is-normal'
|
||||||
|
};
|
||||||
|
|
||||||
|
function paint(cls, text, tooltip) {
|
||||||
|
badge.className = 'api-status-inline__badge ' + cls;
|
||||||
|
badge.textContent = text;
|
||||||
|
if (tooltip) badge.setAttribute('title', tooltip);
|
||||||
|
else badge.removeAttribute('title');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(statusUrl + '?apiId=' + encodeURIComponent(apiId), { credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (status) {
|
||||||
|
const tooltip = [
|
||||||
|
status.activeIncidentTitle,
|
||||||
|
status.statusSince ? '시작 ' + status.statusSince : null,
|
||||||
|
status.expectedEndAt ? '종료 예정 ' + status.expectedEndAt : null
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
paint(BADGE_CLASS[status.currentStatus] || 'is-muted',
|
||||||
|
status.currentStatusLabel || '정상', tooltip);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('API 현재 상태 조회 실패', e);
|
||||||
|
paint('is-muted', '상태 확인 불가');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
|
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
|
||||||
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
|
||||||
const appsSelect = document.getElementById('apps');
|
const appsSelect = document.getElementById('apps');
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -75,13 +75,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Cards Grid -->
|
<!-- API Cards Grid -->
|
||||||
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
|
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}"
|
||||||
|
th:attr="data-status-url=@{/apistatus/current-list.json}">
|
||||||
<div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
|
<div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
|
|
||||||
<!-- Group Badge -->
|
<!-- Group Badge + 현재 상태 태그 -->
|
||||||
<div class="api-card-badge">
|
<div class="api-card-badges">
|
||||||
<span th:text="${api.apiGroupName}">그룹 이름</span>
|
<div class="api-card-badge">
|
||||||
|
<span th:text="${api.apiGroupName}">그룹 이름</span>
|
||||||
|
</div>
|
||||||
|
<!--/* 상태 태그 노출은 PTL_PROPERTY(djb.apistatus.api-list-status-badge) 로 제어.
|
||||||
|
값은 로드 후 JS가 채우며, 비어 있는 동안은 CSS(:empty)로 숨긴다 */-->
|
||||||
|
<span th:if="${apiStatusBadgeEnabled}" class="api-card-status"
|
||||||
|
th:attr="data-api-id=${api.apiId}"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Name -->
|
<!-- API Name -->
|
||||||
@@ -152,6 +159,62 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 카드 현재 상태 태그 =====
|
||||||
|
// 게시된 장애·점검 공지 기준(/apistatus/current-list.json). 태그 자체는 PTL_PROPERTY 로 노출 제어된다.
|
||||||
|
(function loadApiCardStatuses() {
|
||||||
|
const grid = document.querySelector('.api-card-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const statusUrl = grid.getAttribute('data-status-url');
|
||||||
|
const slots = Array.prototype.slice.call(grid.querySelectorAll('.api-card-status[data-api-id]'));
|
||||||
|
if (!statusUrl || !slots.length) return;
|
||||||
|
|
||||||
|
const BADGE_CLASS = {
|
||||||
|
OUTAGE: 'is-outage',
|
||||||
|
DEGRADED: 'is-degraded',
|
||||||
|
MAINTENANCE: 'is-maintenance',
|
||||||
|
NORMAL: 'is-normal'
|
||||||
|
};
|
||||||
|
const CHUNK = 50; // 서버 일괄 조회 상한(100) 이내로 나눠 요청
|
||||||
|
|
||||||
|
// 같은 API 가 여러 카드에 나올 수 있어 apiId → 슬롯 목록으로 묶는다
|
||||||
|
const slotsByApiId = {};
|
||||||
|
slots.forEach(function (el) {
|
||||||
|
const apiId = el.getAttribute('data-api-id');
|
||||||
|
if (!apiId) return;
|
||||||
|
(slotsByApiId[apiId] = slotsByApiId[apiId] || []).push(el);
|
||||||
|
});
|
||||||
|
const apiIds = Object.keys(slotsByApiId);
|
||||||
|
|
||||||
|
function paint(list) {
|
||||||
|
(list || []).forEach(function (status) {
|
||||||
|
const targets = slotsByApiId[status.apiId];
|
||||||
|
const cls = BADGE_CLASS[status.currentStatus];
|
||||||
|
if (!targets || !cls) return;
|
||||||
|
targets.forEach(function (el) {
|
||||||
|
el.className = 'api-card-status ' + cls;
|
||||||
|
el.textContent = status.currentStatusLabel || '';
|
||||||
|
if (status.activeIncidentTitle) el.setAttribute('title', status.activeIncidentTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < apiIds.length; index += CHUNK) {
|
||||||
|
const query = apiIds.slice(index, index + CHUNK).map(function (apiId) {
|
||||||
|
return 'apiId=' + encodeURIComponent(apiId);
|
||||||
|
}).join('&');
|
||||||
|
|
||||||
|
fetch(statusUrl + '?' + query, { credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(paint)
|
||||||
|
// 상태 조회 실패는 목록 자체를 막지 않는다 (태그는 비어 있는 채로 숨겨진다)
|
||||||
|
.catch(function (e) { console.error('API 현재 상태 조회 실패', e); });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Category/Service Selection
|
// Category/Service Selection
|
||||||
const menuTitles = document.querySelectorAll('.js-menu-trigger');
|
const menuTitles = document.querySelectorAll('.js-menu-trigger');
|
||||||
menuTitles.forEach(function (title) {
|
menuTitles.forEach(function (title) {
|
||||||
|
|||||||
@@ -1,61 +1,64 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
layout:decorate="~{layout/djbank_title_layout}">
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
<h1>본인 확인</h1>
|
<h1>본인 확인</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section layout:fragment="contentFragment">
|
<section layout:fragment="contentFragment">
|
||||||
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
||||||
<div class="app-management-content">
|
<div class="app-management-content">
|
||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">본인 확인</h2>
|
<h2 class="page-outer-title">본인 확인</h2>
|
||||||
|
|
||||||
<form th:action="@{/auth/stepup/password}" method="post">
|
<form th:action="@{/auth/stepup/password}" method="post">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
|
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
||||||
|
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
<div class="info-notice-box">
|
<div class="info-notice-box">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
|
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
<path d="M12 16v-4"></path>
|
stroke-linejoin="round">
|
||||||
<path d="M12 8h.01"></path>
|
<circle cx="12" cy="12" r="10" />
|
||||||
</svg>
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</div>
|
</svg>
|
||||||
|
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-label-wrapper">
|
|
||||||
<span class="form-label-text">현재 비밀번호</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field-wrapper">
|
|
||||||
<input type="password" name="currentPassword" class="form-input"
|
<div class="form-row">
|
||||||
placeholder="비밀번호 입력" required autofocus>
|
<div class="form-label-wrapper">
|
||||||
|
<span class="form-label-text">현재 비밀번호</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-wrapper">
|
||||||
|
<input type="password" name="currentPassword" class="form-input"
|
||||||
|
placeholder="비밀번호 입력" required autofocus>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions" style="justify-content: flex-end;">
|
<div class="form-actions" style="justify-content: flex-end;">
|
||||||
<div class="right-buttons">
|
<div class="right-buttons">
|
||||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<script th:if="${error}" th:inline="javascript">
|
||||||
<script th:if="${error}" th:inline="javascript">
|
$(document).ready(function () {
|
||||||
$(document).ready(function () {
|
customPopups.showAlert([[${ error }]]);
|
||||||
customPopups.showAlert([[${error}]]);
|
})
|
||||||
})
|
</script>
|
||||||
</script>
|
</section>
|
||||||
</section>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
|
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<!-- TODO: Update icon to faq icon -->
|
<!-- TODO: Update icon to faq icon -->
|
||||||
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
|
<img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
|
|||||||
@@ -76,9 +76,9 @@
|
|||||||
<!-- Table Header -->
|
<!-- Table Header -->
|
||||||
<div class="board-table-header">
|
<div class="board-table-header">
|
||||||
<div class="header-cell" style="width: 80px;">NO</div>
|
<div class="header-cell" style="width: 80px;">NO</div>
|
||||||
|
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||||
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
||||||
<div class="header-cell" style="width: 120px;">작성자</div>
|
<div class="header-cell" style="width: 120px;">작성자</div>
|
||||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
|
||||||
<div class="header-cell" style="width: 80px;">조회수</div>
|
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,9 +89,19 @@
|
|||||||
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
||||||
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||||
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
||||||
<div class="row-cell row-cell--number" data-label="NO"
|
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||||
<div class="row-cell row-cell--title" data-label="제목">
|
|
||||||
|
<!-- 처리상태 (Moved here) -->
|
||||||
|
<div class="row-cell row-cell--status" style="width: 120px;" data-label="처리상태">
|
||||||
|
<span class="notice-type-badge"
|
||||||
|
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--reviewing' : 'notice-type-badge--pending'))}"
|
||||||
|
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||||
|
답변대기
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
||||||
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
||||||
class="notice-title-link"
|
class="notice-title-link"
|
||||||
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||||
@@ -99,7 +109,7 @@
|
|||||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
|
|
||||||
<!-- Private/Lock Icon -->
|
<!-- Private/Lock Icon -->
|
||||||
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}">
|
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}" style="margin-right: 4px;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-label="비공개">
|
aria-label="비공개">
|
||||||
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
||||||
@@ -117,22 +127,14 @@
|
|||||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell row-cell--writer" data-label="작성자">
|
<div class="row-cell row-cell--writer" style="width: 120px;" data-label="작성자">
|
||||||
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
||||||
<span class="inquiry-writer-org"
|
<span class="inquiry-writer-org"
|
||||||
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||||
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell row-cell--status" data-label="처리상태">
|
<div class="row-cell row-cell--views" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||||
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
|
<div class="row-cell row-cell--date" style="width: 120px;" data-label="등록일"
|
||||||
<span class="notice-type-badge lg"
|
|
||||||
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--maintenance' : 'notice-type-badge--pending'))}"
|
|
||||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
|
||||||
답변대기
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
|
||||||
<div class="row-cell row-cell--date" data-label="등록일"
|
|
||||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<td
|
<td
|
||||||
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||||
-</td>
|
-</td>
|
||||||
<th>끝</th>
|
<th>종료</th>
|
||||||
<td
|
<td
|
||||||
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
|
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
|
||||||
-</td>
|
-</td>
|
||||||
@@ -78,29 +78,27 @@
|
|||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
<tr
|
||||||
|
th:if="${(portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()) or portalNotice.hiddenApiCount > 0}">
|
||||||
<th>영향 API</th>
|
<th>영향 API</th>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
<div class="affected-apis-wrapper">
|
<div class="affected-apis-wrapper">
|
||||||
<!-- First 3 APIs (Always visible) -->
|
<!-- 개발자포탈에 게시된 API 만 개별 노출한다 (인터페이스 ID 는 표시하지 않음) -->
|
||||||
<div class="affected-apis-list">
|
<div class="affected-apis-list">
|
||||||
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
||||||
th:if="${iterStat.index < 3}">
|
th:if="${iterStat.index < 3}" th:text="${api.apiName}">API 명</span>
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
|
||||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
|
||||||
th:text="| - ${api.apiName}|"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Rest of APIs (Hidden by default) -->
|
<!-- Rest of APIs (Hidden by default) -->
|
||||||
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
||||||
<span class="affected-api-badge extra-api"
|
<span class="affected-api-badge extra-api"
|
||||||
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
||||||
style="display: none;">
|
style="display: none;" th:text="${api.apiName}">API 명</span>
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span
|
|
||||||
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
|
||||||
th:text="| - ${api.apiName}|"></span>
|
|
||||||
</span>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 게시되지 않은 GW 인터페이스는 건수로만 묶어서 표기 -->
|
||||||
|
<span class="affected-api-badge is-gw" th:if="${portalNotice.hiddenApiCount > 0}"
|
||||||
|
th:text="|GW 인터페이스 ${portalNotice.hiddenApiCount}건|"
|
||||||
|
title="개발자포탈에 게시되지 않은 게이트웨이 인터페이스입니다">GW 인터페이스 0건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toggle Button -->
|
<!-- Toggle Button -->
|
||||||
@@ -141,6 +139,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 장애 처리 타임라인 (공개 항목이 있을 때만) -->
|
||||||
|
<div class="notice-detail-timeline" th:if="${portalNotice.hasTimeline()}">
|
||||||
|
<h3 class="notice-timeline-title">처리 경과</h3>
|
||||||
|
<table class="detail-table timeline-table">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 160px;">
|
||||||
|
<col style="width: 110px;">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>일시</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>내용</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="entry : ${portalNotice.timeline}">
|
||||||
|
<td class="timeline-ts"
|
||||||
|
th:text="${entry.eventAt != null ? #temporals.format(entry.eventAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||||
|
2026-08-04 11:44</td>
|
||||||
|
<td>
|
||||||
|
<span class="timeline-state"
|
||||||
|
th:classappend="${'state-' + (entry.stateAfter != null ? entry.stateAfter : 'NONE')}"
|
||||||
|
th:text="${entry.labelKo != null ? entry.labelKo : '진행 상황'}">원인 확인</span>
|
||||||
|
</td>
|
||||||
|
<td class="timeline-body" th:text="${entry.body}">진행 내용</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="notice-detail-actions">
|
<div class="notice-detail-actions">
|
||||||
<button type="button" class="btn-action-primary btn-notice-list"
|
<button type="button" class="btn-action-primary btn-notice-list"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
|
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -84,7 +84,13 @@
|
|||||||
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
|
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
|
||||||
<span class="notice-number"
|
<span class="notice-number"
|
||||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
<span class="notice-pin-icon" th:if="${notice.fixYn == 'Y'}"
|
||||||
|
style="margin-right: 6px; display: inline-flex; align-items: center; vertical-align: middle; color: #ef4444;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 12V4H17V2H7V4H8V12L6 14V16H11V22L12 23L13 22V16H18V14L16 12Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="notice-type-badge notice-type-badge--incident"
|
<span class="notice-type-badge notice-type-badge--incident"
|
||||||
th:if="${notice.noticeType == '3'}">장애</span>
|
th:if="${notice.noticeType == '3'}">장애</span>
|
||||||
<span class="notice-type-badge notice-type-badge--maintenance"
|
<span class="notice-type-badge notice-type-badge--maintenance"
|
||||||
|
|||||||
@@ -1,66 +1,73 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
<body>
|
|
||||||
<section layout:fragment="title">
|
|
||||||
<div class="page-title-banner">
|
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
|
||||||
<h1>아이디 찾기</h1>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<th:block layout:fragment="contentFragment">
|
|
||||||
<div class="account-recovery-page">
|
|
||||||
<div class="account-recovery-container">
|
|
||||||
<div class="account-recovery-card">
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<div class="account-recovery-tabs">
|
|
||||||
<a href="#" class="tab-link active">아이디찾기</a>
|
|
||||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Result Content Area -->
|
<body>
|
||||||
<div class="account-recovery-result">
|
<section layout:fragment="title">
|
||||||
<div class="result-header">
|
<div class="page-title-banner">
|
||||||
<i class="fas fa-check-circle result-icon"></i>
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
|
<h1>아이디 찾기</h1>
|
||||||
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
|
</div>
|
||||||
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
|
</section>
|
||||||
</p>
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="account-recovery-page">
|
||||||
|
<div class="account-recovery-container">
|
||||||
|
<div class="account-recovery-card">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="account-recovery-tabs">
|
||||||
|
<a href="#" class="tab-link active">아이디찾기</a>
|
||||||
|
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Found Users List -->
|
<!-- Result Content Area -->
|
||||||
<div class="found-users-list">
|
<div class="account-recovery-result">
|
||||||
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
<div class="result-header">
|
||||||
<div class="user-info">
|
<i class="fas fa-check-circle result-icon"></i>
|
||||||
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
|
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
|
||||||
<span class="user-date">
|
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
|
||||||
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
|
||||||
</span>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Found Users List -->
|
||||||
|
<div class="found-users-list">
|
||||||
|
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
|
||||||
|
<span class="user-date">
|
||||||
|
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Box -->
|
||||||
|
<div class="result-info-box">
|
||||||
|
<p class="info-text">
|
||||||
|
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
비밀번호가 기억나지 않는 경우에는
|
||||||
|
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>를
|
||||||
|
이용해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info Box -->
|
<!-- Action Buttons -->
|
||||||
<div class="result-info-box">
|
<div class="form-actions">
|
||||||
<p class="info-text">
|
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
|
||||||
<i class="fas fa-info-circle"></i>
|
<a th:href="@{/login}" class="submit-button">로그인</a>
|
||||||
비밀번호가 기억나지 않는 경우에는
|
|
||||||
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>를
|
|
||||||
이용해 주세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="form-actions">
|
|
||||||
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
|
|
||||||
<a th:href="@{/login}" class="submit-button">로그인</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</th:block>
|
||||||
</th:block>
|
|
||||||
</body>
|
</body>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<p class="hero-subtitle">세상의 모든 서비스</p>
|
<p class="hero-subtitle">세상의 모든 서비스</p>
|
||||||
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
|
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
|
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-image-content">
|
<div class="hero-image-content">
|
||||||
<!-- Inline SVG Tech Illustration -->
|
<!-- Inline SVG Tech Illustration -->
|
||||||
@@ -47,7 +47,8 @@
|
|||||||
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
|
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
|
||||||
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
|
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" class="btn-hero-signup">개발 가이드 보기 <i class="bi bi-chevron-right"></i></a>
|
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
|
||||||
|
class="bi bi-chevron-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-image-content">
|
<div class="hero-image-content">
|
||||||
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
<p class="hero-subtitle">상상하는 금융의 구현</p>
|
<p class="hero-subtitle">상상하는 금융의 구현</p>
|
||||||
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
|
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
|
||||||
</div>
|
</div>
|
||||||
<a href="#" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
|
<a th:href="@{/partnership}" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-image-content">
|
<div class="hero-image-content">
|
||||||
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -146,40 +147,6 @@
|
|||||||
<!-- API 검색 섹션 -->
|
<!-- API 검색 섹션 -->
|
||||||
<section class="api-search-section">
|
<section class="api-search-section">
|
||||||
<div class="search-background"></div>
|
<div class="search-background"></div>
|
||||||
<!--/* <div class="search-content-wrapper">*/-->
|
|
||||||
<!--/* <div class="search-character">*/-->
|
|
||||||
<!--/* <img th:src="@{/img/img_search_character.png}" alt="검색">*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* <div class="search-text-content">*/-->
|
|
||||||
<!--/* <h2 class="search-title">*/-->
|
|
||||||
<!--/* 원하는 API를<br>*/-->
|
|
||||||
<!-- 지금 검색해 보세요.-->
|
|
||||||
<!--/* </h2>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* <div class="search-input-wrapper">*/-->
|
|
||||||
<!--/* <form th:action="@{/apis}" method="get" class="search-form">*/-->
|
|
||||||
<!--/* <div class="search-box">*/-->
|
|
||||||
<!--/* <input type="text" class="search-input" placeholder="어떤 API를 칮고 계신가요?" id="apiSearchInput" name="keyword">*/-->
|
|
||||||
<!--/* <button class="search-icon-button" type="submit">*/-->
|
|
||||||
<!--/* <svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">*/-->
|
|
||||||
<!--/* <path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>*/-->
|
|
||||||
<!--/* </svg>*/-->
|
|
||||||
<!--/* </button>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* </form>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* <div class="hashtag-section">*/-->
|
|
||||||
<!--/* <span class="hashtag-label">자주찾는 API</span>*/-->
|
|
||||||
<!--/* <div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">*/-->
|
|
||||||
<!--/* <th:block th:each="hashtag, iterStat : ${hashtags}">*/-->
|
|
||||||
<!--/* <a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">*/-->
|
|
||||||
<!--/* </a>*/-->
|
|
||||||
<!--/* <span class="hashtag-separator" th:unless="${iterStat.last}">|</span>*/-->
|
|
||||||
<!--/* </th:block>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
<!--/* </div>*/-->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 피그마 시안 적용 카드 레이아웃 -->
|
<!-- 피그마 시안 적용 카드 레이아웃 -->
|
||||||
@@ -259,8 +226,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-cards-container">
|
<div class="api-cards-container">
|
||||||
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
|
<!-- main.service.list 에 등록된 서비스 전체 표시 (1행 4열, 초과분은 다음 행으로) -->
|
||||||
<div class="api-card" th:each="service, iterStat : ${services}" th:if="${iterStat.index < 4}"
|
<div class="api-card" th:each="service : ${services}"
|
||||||
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
|
||||||
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
|
||||||
<p class="card-description">
|
<p class="card-description">
|
||||||
@@ -328,7 +295,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
|
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
|
||||||
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
|
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
|
||||||
|
class="bi bi-patch-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-image-box">
|
<div class="info-image-box">
|
||||||
@@ -597,268 +565,284 @@
|
|||||||
|
|
||||||
<!-- 로그인 후 처리 스크립트 -->
|
<!-- 로그인 후 처리 스크립트 -->
|
||||||
<script th:src="@{/js/login-success-handler.js}"></script>
|
<script th:src="@{/js/login-success-handler.js}"></script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
LoginSuccessHandler.init({
|
LoginSuccessHandler.init({
|
||||||
successMsg: [[${ success }]],
|
successMsg: [[${ success }]],
|
||||||
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
|
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
|
||||||
dormantAccount: [[${ session.dormantAccount }]],
|
dormantAccount: [[${ session.dormantAccount }]],
|
||||||
passwordExpired: [[${ session.passwordExpired }]],
|
passwordExpired: [[${ session.passwordExpired }]],
|
||||||
pendingInvitation: [[${ session.pendingInvitation }]],
|
pendingInvitation: [[${ session.pendingInvitation }]],
|
||||||
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
|
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
|
||||||
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
|
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
|
||||||
needClientRegister: [[${ needClientRegister }]],
|
needClientRegister: [[${ needClientRegister }]],
|
||||||
sessionSuccessMsg: [[${ session.success }]],
|
sessionSuccessMsg: [[${ session.success }]],
|
||||||
redirectUrl: [[${ session.redirectUrl }]]
|
redirectUrl: [[${ session.redirectUrl }]]
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// ========================================
|
|
||||||
// Hero Carousel
|
|
||||||
// ========================================
|
|
||||||
const heroCarousel = {
|
|
||||||
currentSlide: 0,
|
|
||||||
totalSlides: 3,
|
|
||||||
autoPlayInterval: null,
|
|
||||||
autoPlayDelay: 5000,
|
|
||||||
isPlaying: true,
|
|
||||||
|
|
||||||
init: function () {
|
|
||||||
this.slides = document.querySelectorAll('.hero-slide');
|
|
||||||
this.indicators = document.querySelectorAll('.hero-indicator');
|
|
||||||
this.prevBtn = document.getElementById('heroPrevBtn');
|
|
||||||
this.nextBtn = document.getElementById('heroNextBtn');
|
|
||||||
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
|
|
||||||
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
|
|
||||||
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
|
|
||||||
|
|
||||||
if (!this.slides.length) return;
|
|
||||||
|
|
||||||
this.bindEvents();
|
|
||||||
this.startAutoPlay();
|
|
||||||
},
|
|
||||||
|
|
||||||
bindEvents: function () {
|
|
||||||
// Previous button
|
|
||||||
if (this.prevBtn) {
|
|
||||||
this.prevBtn.addEventListener('click', () => {
|
|
||||||
this.goToSlide(this.currentSlide - 1);
|
|
||||||
this.resetAutoPlay();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next button
|
|
||||||
if (this.nextBtn) {
|
|
||||||
this.nextBtn.addEventListener('click', () => {
|
|
||||||
this.goToSlide(this.currentSlide + 1);
|
|
||||||
this.resetAutoPlay();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indicators
|
|
||||||
this.indicators.forEach((indicator, index) => {
|
|
||||||
indicator.addEventListener('click', () => {
|
|
||||||
this.goToSlide(index);
|
|
||||||
this.resetAutoPlay();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto play toggle button
|
|
||||||
if (this.autoplayToggle) {
|
|
||||||
this.autoplayToggle.addEventListener('click', () => {
|
|
||||||
this.toggleAutoPlay();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause on hover (only if playing)
|
|
||||||
const container = document.querySelector('.hero-carousel-container');
|
|
||||||
if (container) {
|
|
||||||
container.addEventListener('mouseenter', () => {
|
|
||||||
if (this.isPlaying) {
|
|
||||||
this.pauseAutoPlay();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
container.addEventListener('mouseleave', () => {
|
|
||||||
if (this.isPlaying) {
|
|
||||||
this.startAutoPlay();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch events for mobile
|
|
||||||
let touchStartX = 0;
|
|
||||||
let touchEndX = 0;
|
|
||||||
|
|
||||||
if (container) {
|
|
||||||
container.addEventListener('touchstart', (e) => {
|
|
||||||
touchStartX = e.changedTouches[0].screenX;
|
|
||||||
});
|
|
||||||
|
|
||||||
container.addEventListener('touchend', (e) => {
|
|
||||||
touchEndX = e.changedTouches[0].screenX;
|
|
||||||
this.handleSwipe(touchStartX, touchEndX);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
goToSlide: function (index) {
|
|
||||||
// Wrap around
|
|
||||||
if (index < 0) {
|
|
||||||
index = this.totalSlides - 1;
|
|
||||||
} else if (index >= this.totalSlides) {
|
|
||||||
index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update slides
|
|
||||||
this.slides.forEach((slide, i) => {
|
|
||||||
slide.classList.toggle('active', i === index);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update indicators
|
|
||||||
this.indicators.forEach((indicator, i) => {
|
|
||||||
indicator.classList.toggle('active', i === index);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.currentSlide = index;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSwipe: function (startX, endX) {
|
|
||||||
const minSwipeDistance = 50;
|
|
||||||
const distance = startX - endX;
|
|
||||||
|
|
||||||
if (Math.abs(distance) > minSwipeDistance) {
|
|
||||||
if (distance > 0) {
|
|
||||||
// Swipe left - next slide
|
|
||||||
this.goToSlide(this.currentSlide + 1);
|
|
||||||
} else {
|
|
||||||
// Swipe right - previous slide
|
|
||||||
this.goToSlide(this.currentSlide - 1);
|
|
||||||
}
|
|
||||||
this.resetAutoPlay();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
startAutoPlay: function () {
|
|
||||||
this.pauseAutoPlay();
|
|
||||||
this.autoPlayInterval = setInterval(() => {
|
|
||||||
this.goToSlide(this.currentSlide + 1);
|
|
||||||
}, this.autoPlayDelay);
|
|
||||||
},
|
|
||||||
|
|
||||||
pauseAutoPlay: function () {
|
|
||||||
if (this.autoPlayInterval) {
|
|
||||||
clearInterval(this.autoPlayInterval);
|
|
||||||
this.autoPlayInterval = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
resetAutoPlay: function () {
|
|
||||||
if (this.isPlaying) {
|
|
||||||
this.pauseAutoPlay();
|
|
||||||
this.startAutoPlay();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleAutoPlay: function () {
|
|
||||||
this.isPlaying = !this.isPlaying;
|
|
||||||
|
|
||||||
if (this.isPlaying) {
|
|
||||||
// Start auto play
|
|
||||||
this.startAutoPlay();
|
|
||||||
// Update icons
|
|
||||||
if (this.pauseIcon) this.pauseIcon.classList.add('active');
|
|
||||||
if (this.playIcon) this.playIcon.classList.remove('active');
|
|
||||||
// Update aria-label
|
|
||||||
if (this.autoplayToggle) {
|
|
||||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Stop auto play
|
|
||||||
this.pauseAutoPlay();
|
|
||||||
// Update icons
|
|
||||||
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
|
|
||||||
if (this.playIcon) this.playIcon.classList.add('active');
|
|
||||||
// Update aria-label
|
|
||||||
if (this.autoplayToggle) {
|
|
||||||
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize hero carousel
|
|
||||||
heroCarousel.init();
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// API 검색 기능
|
|
||||||
// ========================================
|
|
||||||
const searchInput = document.getElementById('apiSearchInput');
|
|
||||||
const searchForm = document.querySelector('.search-form');
|
|
||||||
const hashtags = document.querySelectorAll('.hashtag-link');
|
|
||||||
|
|
||||||
// 해시태그 클릭 이벤트
|
|
||||||
hashtags.forEach(tag => {
|
|
||||||
tag.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const searchTerm = this.getAttribute('data-search');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = searchTerm;
|
|
||||||
}
|
|
||||||
// 폼 제출
|
|
||||||
if (searchForm) {
|
|
||||||
searchForm.submit();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
// ========================================
|
<script>
|
||||||
// 숫자 카운트업 애니메이션
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
// ========================================
|
// ========================================
|
||||||
const observerOptions = {
|
// Hero Carousel
|
||||||
threshold: 0.5,
|
// ========================================
|
||||||
rootMargin: '0px 0px -100px 0px'
|
const heroCarousel = {
|
||||||
};
|
currentSlide: 0,
|
||||||
|
totalSlides: 3,
|
||||||
|
autoPlayInterval: null,
|
||||||
|
autoPlayDelay: 5000,
|
||||||
|
isPlaying: true,
|
||||||
|
|
||||||
const animateNumbers = (entries, observer) => {
|
init: function () {
|
||||||
entries.forEach(entry => {
|
this.slides = document.querySelectorAll('.hero-slide');
|
||||||
if (entry.isIntersecting) {
|
this.indicators = document.querySelectorAll('.hero-indicator');
|
||||||
const numbers = entry.target.querySelectorAll('.stat-number');
|
this.prevBtn = document.getElementById('heroPrevBtn');
|
||||||
|
this.nextBtn = document.getElementById('heroNextBtn');
|
||||||
|
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
|
||||||
|
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
|
||||||
|
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
|
||||||
|
|
||||||
numbers.forEach(number => {
|
if (!this.slides.length) return;
|
||||||
const target = parseInt(number.getAttribute('data-target'));
|
|
||||||
const duration = 2000; // 2초
|
|
||||||
const increment = target / (duration / 16);
|
|
||||||
let current = 0;
|
|
||||||
|
|
||||||
const updateNumber = () => {
|
this.bindEvents();
|
||||||
current += increment;
|
this.startAutoPlay();
|
||||||
if (current < target) {
|
},
|
||||||
number.textContent = Math.floor(current).toLocaleString('ko-KR');
|
|
||||||
requestAnimationFrame(updateNumber);
|
bindEvents: function () {
|
||||||
} else {
|
// Previous button
|
||||||
number.textContent = target.toLocaleString('ko-KR');
|
if (this.prevBtn) {
|
||||||
|
this.prevBtn.addEventListener('click', () => {
|
||||||
|
this.goToSlide(this.currentSlide - 1);
|
||||||
|
this.resetAutoPlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if (this.nextBtn) {
|
||||||
|
this.nextBtn.addEventListener('click', () => {
|
||||||
|
this.goToSlide(this.currentSlide + 1);
|
||||||
|
this.resetAutoPlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicators
|
||||||
|
this.indicators.forEach((indicator, index) => {
|
||||||
|
indicator.addEventListener('click', () => {
|
||||||
|
this.goToSlide(index);
|
||||||
|
this.resetAutoPlay();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto play toggle button
|
||||||
|
if (this.autoplayToggle) {
|
||||||
|
this.autoplayToggle.addEventListener('click', () => {
|
||||||
|
this.toggleAutoPlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause on hover (only if playing)
|
||||||
|
const container = document.querySelector('.hero-carousel-container');
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('mouseenter', () => {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.pauseAutoPlay();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
container.addEventListener('mouseleave', () => {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.startAutoPlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateNumber();
|
// Touch events for mobile
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchEndX = 0;
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('touchstart', (e) => {
|
||||||
|
touchStartX = e.changedTouches[0].screenX;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('touchend', (e) => {
|
||||||
|
touchEndX = e.changedTouches[0].screenX;
|
||||||
|
this.handleSwipe(touchStartX, touchEndX);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToSlide: function (index) {
|
||||||
|
// Wrap around
|
||||||
|
if (index < 0) {
|
||||||
|
index = this.totalSlides - 1;
|
||||||
|
} else if (index >= this.totalSlides) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update slides
|
||||||
|
this.slides.forEach((slide, i) => {
|
||||||
|
slide.classList.toggle('active', i === index);
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.unobserve(entry.target);
|
// Update indicators
|
||||||
|
this.indicators.forEach((indicator, i) => {
|
||||||
|
indicator.classList.toggle('active', i === index);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrigger draw-line and pulsing animations for Slide 3 (index 2)
|
||||||
|
if (index === 2) {
|
||||||
|
const drawLinePath = this.slides[2].querySelector('.draw-line');
|
||||||
|
const pulsingCircle = this.slides[2].querySelector('.pulsing');
|
||||||
|
if (drawLinePath) {
|
||||||
|
drawLinePath.style.animation = 'none';
|
||||||
|
drawLinePath.offsetHeight;
|
||||||
|
drawLinePath.style.animation = null;
|
||||||
|
}
|
||||||
|
if (pulsingCircle) {
|
||||||
|
pulsingCircle.style.animation = 'none';
|
||||||
|
pulsingCircle.offsetHeight;
|
||||||
|
pulsingCircle.style.animation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSlide = index;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSwipe: function (startX, endX) {
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
const distance = startX - endX;
|
||||||
|
|
||||||
|
if (Math.abs(distance) > minSwipeDistance) {
|
||||||
|
if (distance > 0) {
|
||||||
|
// Swipe left - next slide
|
||||||
|
this.goToSlide(this.currentSlide + 1);
|
||||||
|
} else {
|
||||||
|
// Swipe right - previous slide
|
||||||
|
this.goToSlide(this.currentSlide - 1);
|
||||||
|
}
|
||||||
|
this.resetAutoPlay();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startAutoPlay: function () {
|
||||||
|
this.pauseAutoPlay();
|
||||||
|
this.autoPlayInterval = setInterval(() => {
|
||||||
|
this.goToSlide(this.currentSlide + 1);
|
||||||
|
}, this.autoPlayDelay);
|
||||||
|
},
|
||||||
|
|
||||||
|
pauseAutoPlay: function () {
|
||||||
|
if (this.autoPlayInterval) {
|
||||||
|
clearInterval(this.autoPlayInterval);
|
||||||
|
this.autoPlayInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAutoPlay: function () {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.pauseAutoPlay();
|
||||||
|
this.startAutoPlay();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAutoPlay: function () {
|
||||||
|
this.isPlaying = !this.isPlaying;
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
// Start auto play
|
||||||
|
this.startAutoPlay();
|
||||||
|
// Update icons
|
||||||
|
if (this.pauseIcon) this.pauseIcon.classList.add('active');
|
||||||
|
if (this.playIcon) this.playIcon.classList.remove('active');
|
||||||
|
// Update aria-label
|
||||||
|
if (this.autoplayToggle) {
|
||||||
|
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stop auto play
|
||||||
|
this.pauseAutoPlay();
|
||||||
|
// Update icons
|
||||||
|
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
|
||||||
|
if (this.playIcon) this.playIcon.classList.add('active');
|
||||||
|
// Update aria-label
|
||||||
|
if (this.autoplayToggle) {
|
||||||
|
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize hero carousel
|
||||||
|
heroCarousel.init();
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API 검색 기능
|
||||||
|
// ========================================
|
||||||
|
const searchInput = document.getElementById('apiSearchInput');
|
||||||
|
const searchForm = document.querySelector('.search-form');
|
||||||
|
const hashtags = document.querySelectorAll('.hashtag-link');
|
||||||
|
|
||||||
|
// 해시태그 클릭 이벤트
|
||||||
|
hashtags.forEach(tag => {
|
||||||
|
tag.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchTerm = this.getAttribute('data-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = searchTerm;
|
||||||
|
}
|
||||||
|
// 폼 제출
|
||||||
|
if (searchForm) {
|
||||||
|
searchForm.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(animateNumbers, observerOptions);
|
// ========================================
|
||||||
const statsSection = document.querySelector('.api-stats-section');
|
// 숫자 카운트업 애니메이션
|
||||||
|
// ========================================
|
||||||
|
const observerOptions = {
|
||||||
|
threshold: 0.5,
|
||||||
|
rootMargin: '0px 0px -100px 0px'
|
||||||
|
};
|
||||||
|
|
||||||
if (statsSection) {
|
const animateNumbers = (entries, observer) => {
|
||||||
observer.observe(statsSection);
|
entries.forEach(entry => {
|
||||||
}
|
if (entry.isIntersecting) {
|
||||||
});
|
const numbers = entry.target.querySelectorAll('.stat-number');
|
||||||
</script>
|
|
||||||
|
numbers.forEach(number => {
|
||||||
|
const target = parseInt(number.getAttribute('data-target'));
|
||||||
|
const duration = 2000; // 2초
|
||||||
|
const increment = target / (duration / 16);
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
const updateNumber = () => {
|
||||||
|
current += increment;
|
||||||
|
if (current < target) {
|
||||||
|
number.textContent = Math.floor(current).toLocaleString('ko-KR');
|
||||||
|
requestAnimationFrame(updateNumber);
|
||||||
|
} else {
|
||||||
|
number.textContent = target.toLocaleString('ko-KR');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateNumber();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(animateNumbers, observerOptions);
|
||||||
|
const statsSection = document.querySelector('.api-stats-section');
|
||||||
|
|
||||||
|
if (statsSection) {
|
||||||
|
observer.observe(statsSection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user