20 Commits

Author SHA1 Message Date
Rinjae 5585df9133 메뉴 기능 추가 - menu.yml GNB 정의 - 접근 인터셉터/뷰 주입/내부 API 구현 2026-08-05 14:19:33 +09:00
Rinjae 2c63fa1557 개발자포탈 미게시 GW 인터페이스 표시 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- hiddenApiCount 필드로 미게시 인터페이스 집계 노출
- API Pills 및 관련 CSS/JS 수정
- 영향 인터페이스 노출 로직 개선 및 VisibleApis 클래스 적용
2026-08-05 10:44:50 +09:00
Rinjae d71af34950 공지 연계 기능 추가 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 진행 중, 과거 장애/점검 카드에 공지 제목 및 본문 추가.
- 공지 본문 2열 레이아웃 및 세로 스크롤 스타일 적용.
- CSS 반응형 수정(768px → 1024px).
2026-08-05 09:33:22 +09:00
Rinjae fbc145d145 로고 워드마크 정렬 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모바일 로고 텍스트 및 링크 baseline 맞춤.
- CSS 수정 및 스타일 정렬 개선.
2026-08-04 19:27:54 +09:00
Rinjae f36b1a6478 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-08-04 19:23:45 +09:00
Rinjae 8f03209f8a UI 문구 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 공지 상세 화면 "끝" → "종료"로 수정
- Webhook 가이드 "장애 시작" → "장애 발생"으로 변경
2026-08-04 16:20:11 +09:00
hong 8743592fde fix : footer 수정 2026-08-04 14:59:49 +09:00
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +09:00
Rinjae b45a7a162a - IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
2026-08-04 13:10:31 +09:00
Rinjae 07fbe1ba54 장애·지연 구분 및 집계 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 지연 유형 로직 및 UI/스타일 업데이트
- JPQL 조회와 공개 조건 로직 전면 개정
2026-08-03 16:57:57 +09:00
Rinjae 4916bdf4af API 상태 지연 필터 및 UI/로직 개선:
- 장애·지연 구분 및 집계 로직 추가 대응
- 지연 유형 반영 색상/태그/레이블/UI 경로 업데이트
2026-08-03 15:33:21 +09:00
Rinjae 35621b5174 Merge branch 'master' into feats/api-status 2026-07-31 17:35:11 +09:00
Rinjae b97b0fcf7b "SBOM 생성 및 변환 추가 - CycloneDX -> Excel(xlsx) 지원 - Jenkins 단계에 SBOM 빌드 및 아티팩트 보관 연결"
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-07-31 17:31:45 +09:00
Rinjae 6cb98d36bb API Status 개선 및 조회 기간 설정 추가 2026-07-31 16:22:43 +09:00
Rinjae fd229fca43 API Status 페이지 기능 및 UI 업데이트:
- 상태 모니터링 시각 및 세부 정보 노출 관련 로직 추가
- 지연 필터 및 상대 시간 표기 기능 구현
- 점검/장애 필터 옵션 및 탐지 메타 정보 조회 로직 보완
2026-07-31 14:42:09 +09:00
Rinjae 5e8d08f1af API Status 페이지 포탈 미게시 API 안내 추가:
- 포탈 미게시 API 필터 안내 배너 HTML 및 JS 추가
- 조회 시 API 명 표시 및 API 옵션 외 필터 유지 로직 변경
2026-07-31 14:10:39 +09:00
Rinjae e7945233fe Merged from master 2026-07-31 13:41:25 +09:00
Rinjae 0629e842a3 푸터 외부 링크 스타일 추가 및 SCSS 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- footer-link--external 스타일 추가로 외부 링크 시각화
- 불필요한 CSS 줄바꿈 및 공백 제거로 파일 최적화
2026-07-31 13:33:41 +09:00
Rinjae 6fa2167378 Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현 2026-07-30 19:33:11 +09:00
110 changed files with 6390 additions and 3607 deletions
+15
View File
@@ -80,6 +80,21 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+15
View File
@@ -101,5 +101,20 @@ pipeline {
}
}
}
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
stage('SBOM') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh 'gradle sbomXlsx --no-daemon'
}
}
post {
always {
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
}
}
}
}
}
+1
View File
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
## 문서
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
+2
View File
@@ -204,3 +204,5 @@ task printSourceSets {
}
}
}
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
+326
View File
@@ -0,0 +1,326 @@
/*
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
*
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
*
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
* classpath 나 WAR 산출물에는 포함되지 않는다.
*
* 시트: 요약 / WAR 기준
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
*/
buildscript {
repositories {
maven {
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
allowInsecureProtocol = true
}
mavenCentral()
}
dependencies {
classpath 'org.apache.poi:poi-ooxml:3.17'
}
}
import groovy.json.JsonSlurper
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
ext.SBOM_CELL_LIMIT = 32000
task sbomXlsx {
group = 'sbom'
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
if (!project.hasProperty('sbomJson')) {
dependsOn 'cyclonedxBom'
}
doLast {
File src = resolveBomJson(project)
File out = project.hasProperty('sbomOut')
? project.file(project.property('sbomOut'))
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
out.parentFile.mkdirs()
def bom = new JsonSlurper().parse(src, 'UTF-8')
def deploy = collectDeployJars(project)
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
def wb = new XSSFWorkbook()
def st = createStyles(wb)
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
writeWarSheet(wb, st, warRows)
out.withOutputStream { os -> wb.write(os) }
wb.close()
int unmatched = warRows.count { it.matched == 'N' }
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
}
}
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
String sbomFileName(Project p) {
def warTask = p.tasks.findByName('war')
if (warTask != null) {
String archive = warTask.archiveFileName.get()
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
}
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
return "${p.name}${ver}-sbom.xlsx"
}
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
File resolveBomJson(Project p) {
if (p.hasProperty('sbomJson')) {
File f = p.file(p.property('sbomJson'))
if (!f.exists()) {
throw new GradleException("bom.json 없음: ${f.absolutePath}")
}
return f
}
def candidates = [
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
new File(p.buildDir, 'reports/bom.json'),
]
File found = candidates.find { it.exists() }
if (found == null) {
throw new GradleException(
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
}
return found
}
/**
* 실제 배포물에 packaging 되는 jar 목록.
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
*
* @return [label: 기준 설명, jars: 행 목록]
*/
Map collectDeployJars(Project p) {
def cfg = p.configurations.findByName('runtimeClasspath')
if (cfg == null) {
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
return [label: '(없음)', jars: []]
}
def warTask = p.tasks.findByName('war')
def files
String label
if (warTask != null) {
files = warTask.classpath.files
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
} else {
files = cfg.files
label = 'runtimeClasspath (war 태스크 없음)'
}
def coordByFile = [:]
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
def id = a.moduleVersion.id
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
}
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
def c = coordByFile[f]
[
file : f.name,
group : c?.group ?: '',
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
version: c?.version ?: '',
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
]
}.sort { it.file }
return [label: label, jars: jars]
}
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
Map indexComponents(bom) {
String rootRef = bom.metadata?.component?.'bom-ref'
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
def index = [:]
bom.components?.each { c ->
def hashes = [:]
c.hashes?.each { h -> hashes[h.alg] = h.content }
def licenses = (c.licenses ?: []).collect { l ->
l.license?.id ?: l.license?.name ?: l.expression ?: ''
}.findAll { it }
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
licenses : licenses.join('; '),
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
purl : c.purl ?: '',
sha256 : hashes['SHA-256'] ?: '',
sha1 : hashes['SHA-1'] ?: '',
description: c.description ?: '',
]
}
return index
}
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
List joinWarRows(List warJars, Map index) {
def result = []
warJars.eachWithIndex { j, i ->
def m = j.coord ? index[j.coord] : null
result << [
no : i + 1,
file : j.file,
group : j.group,
name : j.name,
version : j.version,
direct : m?.direct ?: '',
licenses : m?.licenses ?: '',
licenseList: m?.licenseList ?: ['(미상)'],
purl : m?.purl ?: '',
sha256 : m?.sha256 ?: '',
sha1 : m?.sha1 ?: '',
matched : (m != null) ? 'Y' : 'N',
description: m?.description ?: '',
]
}
return result
}
Map createStyles(wb) {
def headFont = wb.createFont()
headFont.setBold(true)
headFont.setColor(IndexedColors.WHITE.getIndex())
def head = wb.createCellStyle()
head.setFont(headFont)
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
head.setAlignment(HorizontalAlignment.CENTER)
head.setVerticalAlignment(VerticalAlignment.CENTER)
head.setBorderBottom(BorderStyle.THIN)
def body = wb.createCellStyle()
body.setVerticalAlignment(VerticalAlignment.TOP)
def wrap = wb.createCellStyle()
wrap.setVerticalAlignment(VerticalAlignment.TOP)
wrap.setWrapText(true)
def labelFont = wb.createFont()
labelFont.setBold(true)
def label = wb.createCellStyle()
label.setFont(labelFont)
return [head: head, body: body, wrap: wrap, label: label]
}
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
def row = sheet.createRow(0)
row.setHeightInPoints(20f)
headers.eachWithIndex { h, i ->
def cell = row.createCell(i)
cell.setCellValue(h)
cell.setCellStyle(style)
sheet.setColumnWidth(i, widths[i] * 256)
}
sheet.createFreezePane(0, 1)
}
def cellOf(row, int idx, value, style) {
def cell = row.createCell(idx)
String s = (value == null) ? '' : value.toString()
if (s.length() > SBOM_CELL_LIMIT) {
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
}
cell.setCellValue(s)
cell.setCellStyle(style)
return cell
}
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
def sheet = wb.createSheet('요약')
def comp = bom.metadata?.component ?: [:]
def tool = bom.metadata?.tools?.components?.getAt(0)
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
def items = [
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
['serialNumber', bom.serialNumber ?: ''],
['생성 시각', bom.metadata?.timestamp ?: ''],
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
['원본 파일', src.absolutePath],
['배포 수록 jar', warRows.size()],
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
['라이선스 종류', licenseKinds.size()],
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
]
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
items.eachWithIndex { item, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, item[0], st.label)
cellOf(row, 1, item[1], st.body)
}
// 라이선스 분포 (요약 하단)
def byLicense = [:].withDefault { 0 }
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
int base = items.size() + 2
def hdr = sheet.createRow(base)
cellOf(hdr, 0, '라이선스', st.head)
cellOf(hdr, 1, 'jar 수', st.head)
sorted.eachWithIndex { e, i ->
def row = sheet.createRow(base + 1 + i)
cellOf(row, 0, e.key, st.body)
cellOf(row, 1, e.value, st.body)
}
}
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
def writeWarSheet(wb, st, List warRows) {
def sheet = wb.createSheet('WAR 기준')
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
writeHeader(sheet, st.head, headers, widths)
warRows.eachWithIndex { r, i ->
def row = sheet.createRow(i + 1)
cellOf(row, 0, r.no, st.body)
cellOf(row, 1, r.file, st.body)
cellOf(row, 2, r.group, st.body)
cellOf(row, 3, r.name, st.body)
cellOf(row, 4, r.version, st.body)
cellOf(row, 5, r.direct, st.body)
cellOf(row, 6, r.licenses, st.body)
cellOf(row, 7, r.purl, st.body)
cellOf(row, 8, r.sha256, st.body)
cellOf(row, 9, r.sha1, st.body)
cellOf(row, 10, r.matched, st.body)
cellOf(row, 11, r.description, st.wrap)
}
if (!warRows.isEmpty()) {
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
}
}
@@ -0,0 +1,85 @@
# 메뉴 관리 개발 가이드
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
## 구성 요소
| 구성 | 위치 | 역할 |
|---|---|---|
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
## menu.yml 스키마
```yaml
portal-menu:
items:
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
name: "고객지원"
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
children:
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
```
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
## 시딩 규칙 (MenuSeeder)
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
2. **배치**: `PTL_MENU_PLACEMENT`**비어있을 때만** 기본 배치로 최초 시딩.
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
## 캐시와 리로드
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
## 새 메뉴 추가 절차
**기본 메뉴(코드 배포와 함께)**
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
커스텀 항목은 미배치 시 삭제된다.
## 로컬 개발 주의
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
## 역할(roles.yml) 변경
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
admin 권한 선택 체크박스 소스 전용.
- 역할 추가 시 `roles.yml``authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
@@ -11,8 +11,8 @@ import java.time.LocalDateTime;
/**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob}(Quartz, 매분)이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 갱신 시각" 표시에 사용한다.</p>
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@@ -10,7 +10,7 @@ import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* 탐지 결과가 마지막으로 갱신된 시각. 데이터가 없으면 empty.
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -31,6 +32,7 @@ public class ApiController {
private final ApiService apiService;
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -86,6 +88,8 @@ public class ApiController {
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList";
}
@@ -78,7 +78,7 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
private List<TimelineEntryDTO> timeline = Collections.emptyList();
public boolean hasTimeline() {
return timeline != null && !timeline.isEmpty();
}
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@EMSDataSource
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
/**
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
*/
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
}
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusAssembler apiStatusAssembler;
private final ApiStatusCatalogService apiStatusCatalogService;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
dto.setTimeline(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
int hiddenCount = 0;
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hiddenCount++;
continue;
}
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
}
dto.setAffectedApis(apis);
dto.setHiddenApiCount(hiddenCount);
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
dto.setTimeline(degrading
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
: Collections.emptyList());
}
}
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
private final SwingNotifier swingNotifier;
@Override
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
private final SwingNotifier swingNotifier;
private final FileService fileService;
@Override
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
@@ -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.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.djb.menu.PortalRolesProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -50,7 +50,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final PortalUserRepository portalUserRepository;
private final PortalUserMapper portalUserMapper;
private final PortalProperties portalProperties;
private final PortalRolesProperties portalRolesProperties;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
@@ -76,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
List<String> roles = portalRolesProperties.getAuthorities(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
@@ -109,6 +109,7 @@ public class PortalConfigSecurity {
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -44,6 +44,7 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
private final Environment environment;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@@ -58,10 +59,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
com.eactive.apim.portal.djb.menu.MenuService menuService) {
this.environment = environment;
this.twoFactorService = twoFactorService;
this.twoFactorProperties = twoFactorProperties;
this.menuService = menuService;
}
@@ -131,6 +134,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/auth/2fa/**");
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
.addPathPatterns("/**")
.excludePathPatterns(staticExcludes)
.excludePathPatterns("/internal/**", "/auth/2fa/**",
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
}
@Bean
@@ -1,13 +1,11 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Data
@Component
@@ -25,8 +23,6 @@ public class PortalProperties {
private String authVirtualCode = "";
private Map<RoleCode, List<String>> portalSecurity;
private FileProperties file = new FileProperties();
/**
@@ -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.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
@@ -30,6 +32,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@@ -49,20 +52,27 @@ public class ApiStatusController {
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
private static final int MAX_PAGE_SIZE = 50;
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
private static final int MAX_STATUS_BATCH = 100;
private final ApiStatusQueryService apiStatusQueryService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusIssueHistoryService issueHistoryService;
private final MyApiStatusQueryService myApiStatusQueryService;
private final ApiStatusCatalogService catalogService;
private final ApiCurrentStatusService apiCurrentStatusService;
/** P1 - API Status 메인 */
@GetMapping
public ModelAndView index() {
ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
mav.addObject("windowDays", catalogService.getWindowDays());
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;
}
@@ -78,11 +88,12 @@ public class ApiStatusController {
@RequestParam(value = "kind", required = false) String kind) {
LocalDate today = ApiStatusSupport.now().toLocalDate();
int windowDays = catalogService.getWindowDays();
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
mav.addObject("windowDays", ApiStatusSupport.DEFAULT_WINDOW_DAYS);
mav.addObject("windowDays", windowDays);
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("selectedApiId", apiId);
mav.addObject("selectedKind", kind);
@@ -100,8 +111,8 @@ public class ApiStatusController {
@GetMapping("/uptime.json")
@ResponseBody
public List<DailyStatDTO> uptime(
@RequestParam(value = "days", defaultValue = "90") int days) {
return uptimeService.getDailyStats(days);
@RequestParam(value = "days", defaultValue = "0") int days) {
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
}
/** P3 - 진행 중 장애 */
@@ -149,10 +160,10 @@ public class ApiStatusController {
@GetMapping("/issues/dates.json")
@ResponseBody
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 = "kind", required = false) String kind) {
return issueHistoryService.getIssueDates(days, apiId, kind);
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
}
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
@@ -170,4 +181,31 @@ public class ApiStatusController {
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
}
/**
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
*
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
*/
@GetMapping("/current.json")
@ResponseBody
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
return apiCurrentStatusService.getStatus(apiId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.badRequest().build());
}
/**
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
*/
@GetMapping("/current-list.json")
@ResponseBody
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
}
}
@@ -15,6 +15,10 @@ public class ActiveIncidentDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
@@ -24,5 +28,7 @@ public class ActiveIncidentDTO {
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -0,0 +1,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 */
private double uptimeRatio;
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
private long incidentMinutes;
/** 그 중 지연(자동 탐지) 분 */
private long delayMinutes;
private long maintenanceMinutes;
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
@@ -16,9 +16,14 @@ public class IssueDateEntryDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate date;
/** INCIDENT / MAINTENANCE */
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
private List<String> kinds = new ArrayList<>();
/** 장애 건수 (자동 탐지 지연 제외) */
private int incCount;
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
private int dlyCount;
private int mntCount;
}
@@ -15,6 +15,10 @@ public class MaintenanceCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@@ -23,6 +27,8 @@ public class MaintenanceCardDTO {
private LocalDateTime endAt;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime registeredAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@@ -16,6 +16,10 @@ public class PastIssueCardDTO {
private Long incidentId;
private String noticeId;
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
private String noticeSubject;
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
private String noticeDetail;
private String kind;
private String state;
private String stateLabel;
@@ -29,5 +33,7 @@ public class PastIssueCardDTO {
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
private int hiddenApiCount;
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -15,31 +15,52 @@ import java.util.List;
import java.util.Optional;
/**
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건(공지 게시 + 초안 아님)을 항상 적용한다.
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
*
* <p>PTL_NOTICE 와의 조인은 두 가지 역할을 한다.
* (1) 관리자가 미게시(USE_YN='N')한 장애/점검을 숨긴다.
* (2) 공지가 삭제된 고아 장애 행을 자연히 제외한다 (물리 FK 없음 - ADR-F10).</p>
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
* <ul>
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
* </ul>
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.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
+ " AND i.kind = :kind"
+ " AND i.kind IN :kinds"
+ " AND i.state NOT IN :closedStates"
+ " 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);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
@@ -48,21 +69,15 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
@Param("now") LocalDateTime now);
/**
* 종결된 이슈 (P6). 장애 종결 상태, 점검은 종료 시각 경과.
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
*/
@Query(value = "SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query(value = "SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
+ " 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))"
+ " AND " + CLOSED_CONDITION
+ " ORDER BY i.startedAt DESC",
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i, PortalNotice n"
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
+ " 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))")
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
Pageable pageable);
@@ -71,7 +86,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
@@ -82,7 +97,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
/**
* 기간과 겹치고 특정 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
+ " AND a.incidentId = i.incidentId"
+ " AND a.apiId = :apiId"
@@ -96,7 +111,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
/**
* 공개 상세 (P7)
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
@@ -0,0 +1,274 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* API 별 현재 상태(정상/점검/지연/장애) 조회.
*
* <p>"내 API 현황"(P4)과 API 상세 화면이 이 서비스를 공유해 판정 기준을 하나로 유지한다.
* 근거는 <b>게시된</b> 장애·점검 공지뿐이며(PTL_NOTICE.USE_YN='Y' + DRAFT_YN='N'),
* 미게시 건은 다른 API Status 화면과 마찬가지로 반영하지 않는다.</p>
*
* <p>판정 규칙 (심각한 쪽 우선)
* <ul>
* <li>진행 중 장애 → 장애(OUTAGE). 단 모니터링 상태이거나 자동 탐지 지연 건이면 지연(DEGRADED)</li>
* <li>이미 시작된 점검 → 점검(MAINTENANCE)</li>
* <li>해당 없음 → 정상(NORMAL)</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiCurrentStatusService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
/** API 한 건의 현재 상태. apiId 가 비어 있으면 empty */
public Optional<ApiCurrentStatusDTO> getStatus(String apiId) {
if (StringUtils.isBlank(apiId)) {
return Optional.empty();
}
return getStatuses(Collections.singletonList(apiId)).stream().findFirst();
}
/**
* 여러 API 의 현재 상태 (API 명·가동률 포함). 요청 순서를 유지하며 중복 apiId 는 한 번만 반환한다.
*/
public List<ApiCurrentStatusDTO> getStatuses(Collection<String> apiIds) {
Set<String> ids = normalize(apiIds);
if (ids.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = resolveStatuses(ids);
Map<String, String> names = resolveApiNames(ids);
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(ids, catalogService.getWindowDays());
List<ApiCurrentStatusDTO> result = new ArrayList<>(ids.size());
for (String apiId : ids) {
ApiCurrentStatusDTO dto = statuses.get(apiId);
dto.setApiName(names.get(apiId));
dto.setUptimeRatio(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
return result;
}
/**
* 상태만 계산한다 (API 명·가동률 제외). 대상 API 를 이미 알고 있고 이름·가동률을
* 따로 채우는 호출자(P4 내 API 현황)를 위한 경량 경로다.
*/
public Map<String, ApiCurrentStatusDTO> resolveStatuses(Set<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
LocalDateTime now = ApiStatusSupport.now();
int windowDays = catalogService.getWindowDays();
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(apiIds);
Map<String, DjbApistatusIncident> maintenanceByApi = mapStartedMaintenance(apiIds, now);
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(apiIds, now, windowDays);
Map<String, ApiCurrentStatusDTO> result = new LinkedHashMap<>();
for (String apiId : apiIds) {
DjbApistatusIncident open = openByApi.get(apiId);
DjbApistatusIncident maintenance = maintenanceByApi.get(apiId);
ApiCurrentStatusDTO dto = new ApiCurrentStatusDTO();
dto.setApiId(apiId);
dto.setCurrentStatus(resolveStatus(open, maintenance != null));
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
dto.setWindowDays(windowDays);
// 장애가 점검보다 심각하므로 근거 이슈도 장애를 우선한다
DjbApistatusIncident active = open != null ? open : maintenance;
if (active != null) {
dto.setActiveIncidentId(active.getIncidentId());
dto.setActiveIncidentKind(active.getKind() == null ? null : active.getKind().name());
dto.setActiveIncidentTitle(active.getTitle());
dto.setStatusSince(active.getStartedAt());
dto.setExpectedEndAt(active.getEndAt());
}
result.put(apiId, dto);
}
return result;
}
/** 빈 값·중복 제거. 요청 순서는 유지한다 */
private Set<String> normalize(Collection<String> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptySet();
}
return apiIds.stream()
.filter(StringUtils::isNotBlank)
.map(StringUtils::trim)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* API 명은 공개 목록(DISPLAY_YN='Y')에서만 가져온다. 비공개 API 의 이름이 새어나가지 않게 한다.
*/
private Map<String, String> resolveApiNames(Set<String> apiIds) {
List<ApiSpecInfo> specs =
apiSpecInfoRepository.findAllByDisplayYnAndApiIdIn("Y", new ArrayList<>(apiIds));
Map<String, String> names = new HashMap<>();
for (ApiSpecInfo spec : specs) {
names.put(spec.getApiId(), StringUtils.defaultIfBlank(spec.getApiName(), spec.getApiId()));
}
return names;
}
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = openIncidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId()) || api.getRecoveredAt() != null) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
// 같은 API 에 여러 장애가 열려 있으면 더 심각한(장애 > 지연, 조사중 > 모니터링) 쪽을 우선한다
if (previous == null || severity(incident) < severity(previous)) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList());
if (maintenances.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = maintenances.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, DjbApistatusIncident> byApi = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
DjbApistatusIncident previous = byApi.get(api.getApiId());
if (previous == null
|| (incident.getStartedAt() != null && previous.getStartedAt() != null
&& incident.getStartedAt().isBefore(previous.getStartedAt()))) {
byApi.put(api.getApiId(), incident);
}
}
return byApi;
}
/** 조회 기간 내 마지막 장애·지연 발생 시각 */
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<String, LocalDateTime> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
LocalDateTime startedAt = byId.get(api.getIncidentId()).getStartedAt();
LocalDateTime previous = result.get(api.getApiId());
if (startedAt != null && (previous == null || startedAt.isAfter(previous))) {
result.put(api.getApiId(), startedAt);
}
}
return result;
}
/**
* 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다.
*
* <p>KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라
* 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.</p>
*/
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
if (open != null) {
boolean degraded = open.getKind() == IncidentKind.DELAY
|| open.getState() == IncidentState.MONITORING;
return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
}
if (underMaintenance) {
return ApiStatusSupport.STATUS_MAINTENANCE;
}
return ApiStatusSupport.STATUS_NORMAL;
}
/**
* 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각).
* 지연은 장애보다 뒤로 민다.
*/
private int severity(DjbApistatusIncident incident) {
if (incident == null) {
return 99;
}
int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0;
IncidentState state = incident.getState();
if (state == null) {
return base + 9;
}
switch (state) {
case INVESTIGATING: return base;
case IDENTIFIED: return base + 1;
// 모니터링 단계는 지연으로 표기되므로 같은 장애라도 뒤로 민다
case MONITORING: return base + 12;
default: return base + 8;
}
}
}
@@ -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.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.apps.community.notice.repository.PortalNoticeRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@@ -37,6 +40,42 @@ public class ApiStatusAssembler {
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository timelineRepository;
private final PortalNoticeRepository portalNoticeRepository;
private final ApiStatusCatalogService catalogService;
/**
* 영향 인터페이스를 "개발자포탈에 게시된 API" 와 그 외 GW 인터페이스로 가른 결과.
* 게시된 것만 개별 노출하고 나머지는 건수로만 알린다.
*/
@Getter
@RequiredArgsConstructor
public static class VisibleApis {
private final List<AffectedApiDTO> visible;
private final int hiddenCount;
}
/**
* 이슈 영향 인터페이스에서 현재 사용자에게 공개된 API 만 남긴다.
*
* <p>이름은 이슈에 캐시된 EAI 서비스명이 아니라 포털 노출명(PTL_API_SPEC_INFO)을 쓴다 -
* 같은 API 가 화면마다 다른 이름으로 보이지 않게 한다.</p>
*/
private VisibleApis splitByVisibility(List<AffectedApiDTO> apis, Map<String, String> visibleNames) {
if (apis == null || apis.isEmpty()) {
return new VisibleApis(Collections.emptyList(), 0);
}
List<AffectedApiDTO> visible = new ArrayList<>();
int hidden = 0;
for (AffectedApiDTO api : apis) {
String publishedName = visibleNames.get(api.getApiId());
if (publishedName == null) {
hidden++;
continue;
}
visible.add(new AffectedApiDTO(api.getApiId(), publishedName, api.getRecoveredAt()));
}
return new VisibleApis(visible, hidden);
}
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
@@ -69,6 +108,30 @@ public class ApiStatusAssembler {
return result;
}
/**
* 이슈에 연결된 공지. 게시 중(USE_YN='Y')인 것만 담는다.
*
* <p>조회 자체의 공개 조건({@code ApiStatusIncidentQueryRepository.VISIBLE})과 같은 기준이라
* 여기서 빠지는 건은 공지가 삭제된 고아 행뿐이다.</p>
*/
public Map<String, PortalNotice> loadNotices(Collection<String> noticeIds) {
if (noticeIds == null || noticeIds.isEmpty()) {
return Collections.emptyMap();
}
List<String> ids = noticeIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyMap();
}
Map<String, PortalNotice> result = new HashMap<>();
for (PortalNotice notice : portalNoticeRepository.findByIdInAndUseYn(ids, "Y")) {
result.put(notice.getId(), notice);
}
return result;
}
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
if (incidents.isEmpty()) {
return Collections.emptyList();
@@ -76,6 +139,10 @@ public class ApiStatusAssembler {
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<ActiveIncidentDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
@@ -89,7 +156,16 @@ public class ApiStatusAssembler {
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
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());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
@@ -104,19 +180,33 @@ public class ApiStatusAssembler {
return Collections.emptyList();
}
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<MaintenanceCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
MaintenanceCardDTO dto = new MaintenanceCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: ApiStatusSupport.minutesBetween(incident.getStartedAt(), incident.getEndAt()));
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.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
@@ -125,7 +215,7 @@ public class ApiStatusAssembler {
}
/**
* 지난 이슈 카드. 장애 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
*/
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
@@ -133,18 +223,29 @@ public class ApiStatusAssembler {
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<String, String> visibleNames = catalogService.getVisibleApiNames();
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
Map<String, PortalNotice> notices = loadNotices(incidents.stream()
.map(DjbApistatusIncident::getNoticeId)
.collect(Collectors.toList()));
List<PastIssueCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
PastIssueCardDTO dto = new PastIssueCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
PortalNotice notice = incident.getNoticeId() == null ? null : notices.get(incident.getNoticeId());
if (notice != null) {
dto.setNoticeSubject(notice.getNoticeSubject());
dto.setNoticeDetail(notice.getNoticeDetail());
}
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
@@ -155,7 +256,10 @@ public class ApiStatusAssembler {
dto.setDateGroup(incident.getStartedAt() == null ? null : incident.getStartedAt().toLocalDate());
dto.setDurationMinutes(incident.getEndAt() == null ? null
: 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()));
result.add(dto);
}
@@ -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.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* API Status 화면의 부가 조회 - 필터용 API 목록, 탐지 결과 갱신 시각.
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/
@Slf4j
@Service
@@ -26,9 +32,32 @@ import java.util.stream.Collectors;
@Transactional(readOnly = true)
public class ApiStatusCatalogService {
public static final String PROPERTY_GROUP = "Portal";
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
/** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
private static final int MIN_WINDOW_DAYS = 1;
private static final int MAX_WINDOW_DAYS = 365;
private final ApiSearchFacade apiSearchFacade;
private final PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/**
* 이슈 이력 필터용 API 목록.
*
@@ -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 {
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) {
// 게이트웨이 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 갱신 시각 조회 실패", e);
log.warn("API 상태 변경 시각 조회 실패", e);
return null;
}
}
@@ -32,6 +32,7 @@ public class ApiStatusIssueHistoryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/**
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
@@ -68,17 +69,7 @@ public class ApiStatusIssueHistoryService {
while (!cursor.isAfter(last)) {
IssueDateEntryDTO entry = byDate.get(cursor);
if (entry != null) {
if (incident.getKind() == IncidentKind.MAINTENANCE) {
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());
}
}
tally(entry, incident);
}
cursor = cursor.plusDays(1);
}
@@ -87,6 +78,24 @@ public class ApiStatusIssueHistoryService {
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일 전체.
*/
@@ -97,7 +106,7 @@ public class ApiStatusIssueHistoryService {
if (date == null) {
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();
} else {
from = date.atStartOfDay();
@@ -125,24 +134,24 @@ public class ApiStatusIssueHistoryService {
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
IncidentKind selected = parseKind(kind);
if (selected == null) {
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
return incidents;
}
return incidents.stream()
.filter(incident -> incident.getKind() == selected)
.collect(Collectors.toList());
return incidents.stream().filter(filter).collect(Collectors.toList());
}
/**
* 유형 필터. 미지정/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)) {
return null;
}
try {
return IncidentKind.valueOf(kind.toUpperCase());
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
return null;
}
@@ -26,11 +26,11 @@ public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
/** P3 - 진행 중 장애 */
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
return assembler.toActiveIncidents(incidents, now);
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import java.time.Duration;
@@ -36,6 +38,39 @@ public final class ApiStatusSupport {
return LocalDateTime.now(ZONE);
}
/** 지연 건인지 여부 (장애와 구분) */
public static boolean isDelay(DjbApistatusIncident incident) {
return incident != null && incident.getKind() == IncidentKind.DELAY;
}
/** 이슈 종류에 대응하는 현재 상태 코드 */
public static String statusOf(IncidentKind kind) {
if (kind == null) {
return STATUS_NORMAL;
}
switch (kind) {
case INCIDENT: return STATUS_OUTAGE;
case DELAY: return STATUS_DEGRADED;
case MAINTENANCE: return STATUS_MAINTENANCE;
default: return STATUS_NORMAL;
}
}
/**
* 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
*/
public static int statusRank(String status) {
if (status == null) {
return 9;
}
switch (status) {
case STATUS_OUTAGE: return 0;
case STATUS_MAINTENANCE: return 1;
case STATUS_DEGRADED: return 2;
default: return 3;
}
}
public static String stateLabel(IncidentState state) {
if (state == null) {
return null;
@@ -72,4 +107,26 @@ public final class ApiStatusSupport {
}
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) + "일 전";
}
}
@@ -64,9 +64,10 @@ public class ApiStatusUptimeService {
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
@@ -116,7 +117,11 @@ public class ApiStatusUptimeService {
return dto;
}
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
List<long[]> incidentIntervals = new ArrayList<>();
List<long[]> outageIntervals = new ArrayList<>();
List<long[]> delayIntervals = new ArrayList<>();
List<long[]> maintenanceIntervals = new ArrayList<>();
Set<Long> seen = new HashSet<>();
@@ -131,6 +136,11 @@ public class ApiStatusUptimeService {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
if (incident.getKind() == IncidentKind.DELAY) {
delayIntervals.add(interval);
} else {
outageIntervals.add(interval);
}
}
if (seen.add(incident.getIncidentId())) {
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
@@ -141,18 +151,24 @@ public class ApiStatusUptimeService {
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
long incidentMinutes = unionMinutes(incidentIntervals);
long outageMinutes = unionMinutes(outageIntervals);
long delayMinutes = unionMinutes(delayIntervals);
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
dto.setIncidentMinutes(incidentMinutes);
dto.setDelayMinutes(delayMinutes);
dto.setMaintenanceMinutes(maintenanceMinutes);
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
if (incidentMinutes > 0) {
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
if (outageMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
} else if (maintenanceMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
} else if (delayMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
} else {
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
}
@@ -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.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import 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.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
*
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.</p>
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
*/
@Service
@RequiredArgsConstructor
@@ -40,9 +31,9 @@ import java.util.stream.Collectors;
public class MyApiStatusQueryService {
private final CredentialRepository credentialRepository;
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiCurrentStatusService apiCurrentStatusService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
public List<MyApiStatusDTO> getMyApiStatuses() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -55,30 +46,29 @@ public class MyApiStatusQueryService {
return Collections.emptyList();
}
LocalDateTime now = ApiStatusSupport.now();
Map<String, DjbApistatusIncident> openByApi = mapOpenIncidents(myApis.keySet());
Set<String> underMaintenance = collectApisUnderMaintenance(myApis.keySet(), now);
Map<String, LocalDateTime> lastIncidentAt = collectLastIncidentAt(myApis.keySet(), now);
Map<String, Double> uptimes = uptimeService.getApiUptimeRatios(
myApis.keySet(), ApiStatusSupport.DEFAULT_WINDOW_DAYS);
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
Map<String, Double> uptimes =
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
List<MyApiStatusDTO> result = new ArrayList<>();
for (Map.Entry<String, String> entry : myApis.entrySet()) {
String apiId = entry.getKey();
DjbApistatusIncident open = openByApi.get(apiId);
ApiCurrentStatusDTO status = statuses.get(apiId);
MyApiStatusDTO dto = new MyApiStatusDTO();
dto.setApiId(apiId);
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
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.setActiveIncidentId(open == null ? null : open.getIncidentId());
dto.setLastIncidentAt(lastIncidentAt.get(apiId));
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
result.sort(Comparator.comparingInt((MyApiStatusDTO dto) -> statusRank(dto.getCurrentStatus()))
result.sort(Comparator
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
return result;
}
@@ -101,113 +91,4 @@ public class MyApiStatusQueryService {
}
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;
}
}
}
@@ -3,20 +3,13 @@ package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
*
* <p>역할 기반 알림 대상 조회는
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
*/
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -1,57 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommunityAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
if (admins == null || admins.isEmpty()) {
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
return;
}
for (UserInfo admin : admins) {
try {
MessageRecipient recipient = toRecipient(admin);
messageHandlerService.publishEvent(code, recipient, params);
} catch (Exception e) {
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
admin.getUserid(), code.name(), e);
}
}
} catch (Exception e) {
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
}
}
private MessageRecipient toRecipient(UserInfo admin) {
MessageRecipient r = new MessageRecipient();
r.setUsername(admin.getUsername());
r.setUserId(admin.getEmad());
r.setPhone(admin.getCphnno());
r.setMessengerId(admin.getUserid());
return r;
}
}
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -47,7 +48,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final CommunityAdminNotifier adminNotifier;
private final SwingNotifier swingNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@@ -107,7 +108,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
@@ -1,10 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbAdminRole {
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
public static final String PORTAL_ADMIN = "portal-admin";
private DjbAdminRole() {
}
}
@@ -0,0 +1,70 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 메뉴 접근 권한(ACCESS_ROLES) 서버측 집행.
*
* <p>요청 경로가 접근 권한이 설정된 메뉴 경로와 정확히 일치할 때만 검사한다
* (하위 경로는 기존 안전망 @Secured / PageRoute.role 에 위임 — 사용자 결정).
* 미충족 시 익명은 로그인으로, 인증 사용자는 홈으로 리다이렉트한다.
* 메뉴에 등록되지 않은 경로는 통과시킨다.
*/
@Slf4j
@RequiredArgsConstructor
public class MenuAccessInterceptor implements HandlerInterceptor {
private final MenuService menuService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String path = currentPath(request);
List<String> requiredRoles = menuService.getSnapshot().getAccessRolesByPath().get(path);
if (requiredRoles == null || requiredRoles.isEmpty()) {
return true;
}
if (isAllowed(requiredRoles)) {
return true;
}
if (!SecurityUtil.isAuthenticated()) {
response.sendRedirect(request.getContextPath() + "/login");
} else {
log.info("메뉴 접근 권한 미충족 - path: {}, 필요 권한: {}, 사용자: {}",
path, requiredRoles, SecurityUtil.getCurrentLoginId());
response.sendRedirect(request.getContextPath() + "/");
}
return false;
}
private boolean isAllowed(List<String> requiredRoles) {
for (String role : requiredRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (SecurityUtil.isAuthenticated()) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
private String currentPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return uri;
}
}
@@ -0,0 +1,99 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
*
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
*
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/menu")
@RequiredArgsConstructor
public class MenuInternalController {
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "menu.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
private final MenuService menuService;
private final PortalPropertyService portalPropertyService;
@PostMapping("/reload")
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
if (!isAllowed(request)) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
}
MenuService.MenuSnapshot snapshot = menuService.reload();
log.info("메뉴 캐시 리로드 명령 수신 - from: {}", request.getRemoteAddr());
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "OK");
body.put("itemCount", snapshot.getItemCount());
body.put("reloadedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return ResponseEntity.ok(body);
}
/**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
*/
private boolean isAllowed(HttpServletRequest request) {
String remote = canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
.map(String::trim)
.filter(ip -> !ip.isEmpty())
.map(MenuInternalController::canonicalize)
.collect(Collectors.toSet());
if (viaProxy || !allowed.contains(remote)) {
log.warn("메뉴 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
return false;
}
return true;
}
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, "메뉴 내부 API(리로드) 허용 IP 목록(콤마 구분)");
} catch (Exception e) {
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
/** IPv6 loopback 표기 통일 */
private static String canonicalize(String ip) {
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
}
}
@@ -0,0 +1,94 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* 모든 뷰 요청에 role 필터가 적용된 {@code menuView} 를 주입한다
* (breadcrumb 의 GlobalControllerAdvice 와 동일한 방식).
*
* <p>노출 판정: EXPOSE_ROLES 비어있음(전체) ∥ AUTHENTICATED(로그인) ∥
* 역할 any-of. 경로 없는 그룹은 노출 자식이 하나도 없으면 제외한다.
*/
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class MenuModelAdvice {
private final MenuService menuService;
@ModelAttribute("menuView")
public MenuView menuView(HttpServletRequest request) {
try {
MenuService.MenuSnapshot snapshot = menuService.getSnapshot();
boolean authenticated = SecurityUtil.isAuthenticated();
List<MenuNode> gnb = new ArrayList<>();
MenuNode mypage = null;
for (MenuNode root : snapshot.getRoots()) {
MenuNode filtered = filter(root, authenticated);
if (filtered == null) {
continue;
}
if (PortalMenuItem.SECTION_MYPAGE.equals(filtered.getSection())) {
if (mypage == null) {
mypage = filtered;
}
} else {
gnb.add(filtered);
}
}
return new MenuView(gnb, mypage, request.getRequestURI());
} catch (Exception e) {
// 메뉴 조회 실패가 화면 전체를 막지 않도록 빈 메뉴로 폴백
log.error("메뉴 뷰 구성 실패 - 빈 메뉴로 렌더링합니다.", e);
return MenuView.empty();
}
}
private MenuNode filter(MenuNode node, boolean authenticated) {
if (!isExposed(node, authenticated)) {
return null;
}
MenuNode copy = node.copyWithoutChildren();
List<MenuNode> children = new ArrayList<>();
for (MenuNode child : node.getChildren()) {
MenuNode filteredChild = filter(child, authenticated);
if (filteredChild != null) {
children.add(filteredChild);
}
}
copy.setChildren(children);
// 경로 없는 그룹은 노출할 자식이 없으면 통째로 제외
if (copy.isGroup() && !copy.hasPath() && children.isEmpty()) {
return null;
}
return copy;
}
private boolean isExposed(MenuNode node, boolean authenticated) {
List<String> exposeRoles = node.getExposeRoles();
if (exposeRoles.isEmpty()) {
return true;
}
for (String role : exposeRoles) {
if (PortalMenuItem.ROLE_AUTHENTICATED.equals(role)) {
if (authenticated) {
return true;
}
} else if (SecurityUtil.hasRole(role)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 렌더용 메뉴 노드. {@link MenuService} 스냅샷 트리와
* {@link MenuModelAdvice} 의 요청별 필터 사본 양쪽에 사용한다.
*/
@Data
public class MenuNode {
private String menuId;
private String name;
/** 이동 경로 (null = 클릭 없는 그룹) */
private String path;
private boolean group;
/** GNB | MYPAGE */
private String section;
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuNode> children = new ArrayList<>();
/** 필터 사본 생성 (children 제외 필드 복사) */
public MenuNode copyWithoutChildren() {
MenuNode copy = new MenuNode();
copy.menuId = menuId;
copy.name = name;
copy.path = path;
copy.group = group;
copy.section = section;
copy.icon = icon;
copy.newWindow = newWindow;
copy.exposeRoles = exposeRoles;
copy.accessRoles = accessRoles;
return copy;
}
public boolean hasPath() {
return path != null && !path.isEmpty();
}
}
@@ -0,0 +1,360 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.entity.PortalRole;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthority;
import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId;
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleAuthorityRepository;
import com.eactive.apim.portal.menu.repository.PortalRoleRepository;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 부팅 시 menu.yml / roles.yml → DB 적재.
*
* <ul>
* <li>역할: PTL_ROLE upsert + PTL_ROLE_AUTHORITY 재구축 (roles.yml 미러)</li>
* <li>메뉴 항목: id 기준 upsert — yml 기본값이 바뀌면 DFLT_* 를 갱신하고,
* 관리자가 수정하지 않은(현재값==구 기본값) 필드만 새 기본값을 따라간다</li>
* <li>배치: PTL_MENU_PLACEMENT 가 비어있을 때만 DFLT_* 로 최초 시딩</li>
* <li>yml 에서 사라진 PORTAL 항목은 경고 로그만 남기고 삭제하지 않는다</li>
* </ul>
*
* 부팅 컨텍스트에는 인증 주체가 없어 감사(@CreatedBy)가 비므로,
* 신규 행은 {@code createdBy=SYSTEM} 을 명시 세팅한다
* (AuditingHandler 는 auditor 부재 시 기존 값을 보존).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MenuSeeder implements ApplicationListener<ApplicationReadyEvent> {
static final String SYSTEM_AUDITOR = "SYSTEM";
private static final Pattern MENU_ID_PATTERN = Pattern.compile("^[a-z0-9-]+$");
private final PortalMenuYmlProperties menuYmlProperties;
private final PortalRolesProperties rolesProperties;
private final PortalMenuItemRepository menuItemRepository;
private final PortalMenuPlacementRepository menuPlacementRepository;
private final PortalRoleRepository roleRepository;
private final PortalRoleAuthorityRepository roleAuthorityRepository;
private final PortalMenuDataService menuDataService;
private final MenuService menuService;
@Override
@Transactional
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
seedRoles();
seedMenuItems();
seedPlacementsIfEmpty();
menuService.reload();
} catch (Exception e) {
// 시딩 실패가 기동 자체를 막지 않도록 로그만 남긴다 (메뉴는 기존 DB 값으로 동작)
log.error("메뉴/역할 시딩 실패 - 기존 DB 데이터로 동작합니다.", e);
}
}
// ─────────────── 역할 ───────────────
private void seedRoles() {
int sortOrder = 10;
int upserted = 0;
Set<String> definedCodes = new HashSet<>();
for (PortalRolesProperties.RoleDef roleDef : rolesProperties.getRoles()) {
upserted += upsertRole(roleDef.getCode(), roleDef.getName(), PortalRole.TYPE_BASE, sortOrder) ? 1 : 0;
definedCodes.add(roleDef.getCode());
sortOrder += 10;
}
for (Map.Entry<String, String> entry : rolesProperties.getAuthorityNames().entrySet()) {
if (definedCodes.contains(entry.getKey())) {
continue;
}
upserted += upsertRole(entry.getKey(), entry.getValue(), PortalRole.TYPE_AUTHORITY, sortOrder) ? 1 : 0;
sortOrder += 10;
}
// 역할→권한 매핑은 순수 미러 — 통째로 재구축
roleAuthorityRepository.deleteAllInBatch();
roleAuthorityRepository.flush();
List<PortalRoleAuthority> mappings = rolesProperties.getRoles().stream()
.flatMap(roleDef -> roleDef.getAuthorities().stream()
.map(authority -> {
PortalRoleAuthority mapping = new PortalRoleAuthority();
mapping.setId(new PortalRoleAuthorityId(roleDef.getCode(), authority));
mapping.setCreatedBy(SYSTEM_AUDITOR);
return mapping;
}))
.collect(Collectors.toList());
roleAuthorityRepository.saveAll(mappings);
log.info("역할 시딩 완료 - PTL_ROLE upsert {}건, PTL_ROLE_AUTHORITY {}건 재구축", upserted, mappings.size());
}
private boolean upsertRole(String code, String name, String type, int sortOrder) {
PortalRole role = roleRepository.findById(code).orElse(null);
if (role == null) {
role = new PortalRole();
role.setRoleCode(code);
role.setCreatedBy(SYSTEM_AUDITOR);
} else if (Objects.equals(role.getRoleName(), name)
&& Objects.equals(role.getRoleType(), type)
&& Objects.equals(role.getSortOrder(), sortOrder)) {
return false;
}
role.setRoleName(name);
role.setRoleType(type);
role.setSortOrder(sortOrder);
roleRepository.save(role);
return true;
}
// ─────────────── 메뉴 항목 ───────────────
private void seedMenuItems() {
List<FlatMenuDef> flatDefs = flatten(menuYmlProperties.getItems());
validate(flatDefs);
int inserted = 0;
int updated = 0;
for (FlatMenuDef def : flatDefs) {
PortalMenuItem existing = menuItemRepository.findById(def.id).orElse(null);
if (existing == null) {
menuItemRepository.save(newItem(def));
inserted++;
} else if (updateItem(existing, def)) {
menuItemRepository.save(existing);
updated++;
}
}
// yml 에서 사라진 PORTAL 항목 경고 (자동 삭제 금지)
Set<String> ymlIds = flatDefs.stream().map(def -> def.id).collect(Collectors.toSet());
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL).stream()
.map(PortalMenuItem::getMenuId)
.filter(id -> !ymlIds.contains(id))
.forEach(id -> log.warn("menu.yml 에 없는 기본 메뉴 항목이 DB에 남아 있습니다 (수동 정리 필요): {}", id));
log.info("메뉴 항목 시딩 완료 - 신규 {}건, 갱신 {}건, 유지 {}건",
inserted, updated, flatDefs.size() - inserted - updated);
}
private PortalMenuItem newItem(FlatMenuDef def) {
PortalMenuItem item = new PortalMenuItem();
item.setMenuId(def.id);
item.setSourceType(PortalMenuItem.SOURCE_PORTAL);
item.setCreatedBy(SYSTEM_AUDITOR);
item.setMenuName(def.name());
item.setMenuPath(def.path());
item.setExposeRoles(def.exposeCsv());
item.setAccessRoles(def.accessCsv());
applyStructure(item, def);
applyDefaults(item, def);
return item;
}
/**
* 기존 항목 upsert 갱신. 내용 필드는 "관리자 미수정(현재값==구 기본값) 시에만"
* 새 기본값을 따라가고, 구조 필드(group/section/icon/new-window)와 DFLT_* 는
* 항상 yml 값으로 맞춘다.
*
* @return 변경이 있었으면 true
*/
private boolean updateItem(PortalMenuItem item, FlatMenuDef def) {
boolean[] changed = {false};
followDefault(item, changed, def.name(), PortalMenuItem::getDfltMenuName,
PortalMenuItem::getMenuName, PortalMenuItem::setMenuName, PortalMenuItem::setDfltMenuName);
followDefault(item, changed, def.path(), PortalMenuItem::getDfltMenuPath,
PortalMenuItem::getMenuPath, PortalMenuItem::setMenuPath, PortalMenuItem::setDfltMenuPath);
followDefault(item, changed, def.exposeCsv(), PortalMenuItem::getDfltExposeRoles,
PortalMenuItem::getExposeRoles, PortalMenuItem::setExposeRoles, PortalMenuItem::setDfltExposeRoles);
followDefault(item, changed, def.accessCsv(), PortalMenuItem::getDfltAccessRoles,
PortalMenuItem::getAccessRoles, PortalMenuItem::setAccessRoles, PortalMenuItem::setDfltAccessRoles);
String groupYn = def.source.isGroup() ? "Y" : "N";
String section = def.section();
String newWindowYn = def.source.isNewWindow() ? "Y" : "N";
if (!Objects.equals(item.getGroupYn(), groupYn)
|| !Objects.equals(item.getMenuSection(), section)
|| !Objects.equals(item.getIconClass(), def.source.getIcon())
|| !Objects.equals(item.getNewWindowYn(), newWindowYn)) {
applyStructure(item, def);
changed[0] = true;
}
if (!Objects.equals(item.getDfltParentId(), def.parentId)
|| !Objects.equals(item.getDfltSortOrder(), def.sortOrder)) {
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
changed[0] = true;
}
return changed[0];
}
/**
* 내용 필드 1개에 대한 기본값 추종 처리: yml 기본값이 바뀌었을 때
* 현재값이 구 기본값과 같으면(관리자 미수정) 현재값도 새 기본값으로 갱신한다.
*/
private void followDefault(PortalMenuItem item, boolean[] changed, String newDefault,
Function<PortalMenuItem, String> defaultGetter,
Function<PortalMenuItem, String> currentGetter,
BiConsumer<PortalMenuItem, String> currentSetter,
BiConsumer<PortalMenuItem, String> defaultSetter) {
String oldDefault = defaultGetter.apply(item);
if (Objects.equals(oldDefault, newDefault)) {
return;
}
if (Objects.equals(currentGetter.apply(item), oldDefault)) {
currentSetter.accept(item, newDefault);
}
defaultSetter.accept(item, newDefault);
changed[0] = true;
}
private void applyStructure(PortalMenuItem item, FlatMenuDef def) {
item.setGroupYn(def.source.isGroup() ? "Y" : "N");
item.setMenuSection(def.section());
item.setIconClass(def.source.getIcon());
item.setNewWindowYn(def.source.isNewWindow() ? "Y" : "N");
}
private void applyDefaults(PortalMenuItem item, FlatMenuDef def) {
item.setDfltMenuName(def.name());
item.setDfltMenuPath(def.path());
item.setDfltExposeRoles(def.exposeCsv());
item.setDfltAccessRoles(def.accessCsv());
item.setDfltParentId(def.parentId);
item.setDfltSortOrder(def.sortOrder);
item.setDfltVisibleYn("Y");
}
// ─────────────── 배치 ───────────────
private void seedPlacementsIfEmpty() {
if (menuPlacementRepository.count() > 0) {
log.info("메뉴 배치 존재 - 최초 시딩 건너뜀 (관리자 배치 보존)");
return;
}
List<PortalMenuItem> portalItems =
menuItemRepository.findAllBySourceType(PortalMenuItem.SOURCE_PORTAL);
List<PortalMenuPlacement> placements = menuDataService.buildDefaultPlacements(portalItems);
placements.forEach(placement -> placement.setCreatedBy(SYSTEM_AUDITOR));
menuPlacementRepository.saveAll(placements);
log.info("메뉴 배치 최초 시딩 완료 - {}건", placements.size());
}
// ─────────────── 평탄화 / 검증 ───────────────
private List<FlatMenuDef> flatten(List<PortalMenuYmlProperties.MenuItemDef> roots) {
List<FlatMenuDef> result = new ArrayList<>();
int sortOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef root : roots) {
result.add(new FlatMenuDef(root, null, sortOrder, null));
int childOrder = 10;
for (PortalMenuYmlProperties.MenuItemDef child : root.getChildren()) {
result.add(new FlatMenuDef(child, root.getId(), childOrder, root));
childOrder += 10;
}
sortOrder += 10;
}
return result;
}
private void validate(List<FlatMenuDef> defs) {
Set<String> seen = new HashSet<>();
for (FlatMenuDef def : defs) {
if (def.id == null || !MENU_ID_PATTERN.matcher(def.id).matches()) {
throw new IllegalStateException("menu.yml 항목 id 형식 오류(kebab-case 필수): " + def.id);
}
if (!seen.add(def.id)) {
throw new IllegalStateException("menu.yml 항목 id 중복: " + def.id);
}
}
}
/** yml 트리 항목의 평탄화 뷰 (부모/기본 정렬 포함) */
private static class FlatMenuDef {
final PortalMenuYmlProperties.MenuItemDef source;
final String parentId;
final Integer sortOrder;
final PortalMenuYmlProperties.MenuItemDef parent;
final String id;
FlatMenuDef(PortalMenuYmlProperties.MenuItemDef source, String parentId, Integer sortOrder,
PortalMenuYmlProperties.MenuItemDef parent) {
this.source = source;
this.parentId = parentId;
this.sortOrder = sortOrder;
this.parent = parent;
this.id = source.getId();
}
String name() {
return source.getName();
}
String path() {
return trimToNull(source.getPath());
}
/** 자식은 섹션 미지정 시 부모 섹션 상속 */
String section() {
String own = trimToNull(source.getSection());
if (own != null) {
return own;
}
if (parent != null) {
String parentSection = trimToNull(parent.getSection());
if (parentSection != null) {
return parentSection;
}
}
return PortalMenuItem.SECTION_GNB;
}
String exposeCsv() {
return toCsv(source.getExposeRoles());
}
String accessCsv() {
return toCsv(source.getAccessRoles());
}
private static String toCsv(List<String> roles) {
if (roles == null || roles.isEmpty()) {
return null;
}
return String.join(",", roles);
}
private static String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}
}
@@ -0,0 +1,179 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
import com.eactive.apim.portal.menu.service.PortalMenuDataService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 메뉴 트리 스냅샷 캐시.
*
* <p>매 요청 DB 조회를 피하기 위해 role 비의존 트리를 메모리에 유지한다
* (기존 수제 캐시 관례: PageService 의 volatile 필드). TTL 은
* PTL_PROPERTY {@code Portal / menu.cache.ttl-seconds} (기본 3600초)이며,
* eapim-admin 의 reload 명령(/internal/menu/reload)으로 즉시 갱신된다.
* 요청별 role 필터링은 {@link MenuModelAdvice} 가 수행한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MenuService {
static final String PROP_GROUP = "Portal";
static final String PROP_CACHE_TTL = "menu.cache.ttl-seconds";
static final String DEFAULT_CACHE_TTL = "3600";
/** 스냅샷이 비어있을 때의 재시도 TTL(초) — 시딩 전 기동 직후 요청 대비 */
private static final long EMPTY_RETRY_TTL_SECONDS = 60;
private final PortalMenuDataService menuDataService;
private final PortalPropertyService portalPropertyService;
private volatile MenuSnapshot snapshot;
public MenuSnapshot getSnapshot() {
MenuSnapshot current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
synchronized (this) {
current = snapshot;
if (current != null && !current.isExpired()) {
return current;
}
return reload();
}
}
/**
* DB 에서 트리를 재구축한다. 시더 완료 시점과 admin reload 명령이 호출한다.
*/
public synchronized MenuSnapshot reload() {
long ttlSeconds = resolveTtlSeconds();
List<PortalMenuItem> items = menuDataService.loadAllItems();
List<PortalMenuPlacement> placements = menuDataService.loadAllPlacements();
Map<String, PortalMenuItem> itemsById = items.stream()
.collect(Collectors.toMap(PortalMenuItem::getMenuId, item -> item, (a, b) -> a, LinkedHashMap::new));
// 가시(visible=Y) 배치만 렌더 트리에 포함
Map<String, List<PortalMenuPlacement>> byParent = placements.stream()
.filter(PortalMenuPlacement::isVisible)
.filter(placement -> itemsById.containsKey(placement.getMenuId()))
.collect(Collectors.groupingBy(
placement -> placement.getParentId() == null ? "" : placement.getParentId(),
LinkedHashMap::new, Collectors.toList()));
List<MenuNode> roots = buildNodes(byParent.getOrDefault("", Collections.emptyList()), itemsById, byParent);
// 접근 권한 맵은 배치/노출과 무관하게 항목 기준으로 구성 (숨김 메뉴도 접근 통제 유지)
Map<String, List<String>> accessRolesByPath = new LinkedHashMap<>();
for (PortalMenuItem item : items) {
if (item.getMenuPath() != null && item.getAccessRoles() != null
&& !item.getAccessRoles().trim().isEmpty()) {
accessRolesByPath.put(normalizePath(item.getMenuPath()), splitCsv(item.getAccessRoles()));
}
}
if (roots.isEmpty()) {
ttlSeconds = Math.min(ttlSeconds, EMPTY_RETRY_TTL_SECONDS);
}
MenuSnapshot rebuilt = new MenuSnapshot(roots, accessRolesByPath, items.size(),
System.currentTimeMillis(), ttlSeconds);
snapshot = rebuilt;
log.info("메뉴 캐시 갱신 - 항목 {}건, 최상위 {}건, TTL {}초", items.size(), roots.size(), ttlSeconds);
return rebuilt;
}
private List<MenuNode> buildNodes(List<PortalMenuPlacement> placements,
Map<String, PortalMenuItem> itemsById,
Map<String, List<PortalMenuPlacement>> byParent) {
List<MenuNode> nodes = new ArrayList<>();
for (PortalMenuPlacement placement : placements) {
PortalMenuItem item = itemsById.get(placement.getMenuId());
MenuNode node = toNode(item);
node.setChildren(buildNodes(
byParent.getOrDefault(item.getMenuId(), Collections.emptyList()), itemsById, byParent));
nodes.add(node);
}
return nodes;
}
private MenuNode toNode(PortalMenuItem item) {
MenuNode node = new MenuNode();
node.setMenuId(item.getMenuId());
node.setName(item.getMenuName());
node.setPath(normalizePath(item.getMenuPath()));
node.setGroup(item.isGroup());
node.setSection(item.getMenuSection());
node.setIcon(item.getIconClass());
node.setNewWindow("Y".equals(item.getNewWindowYn()));
node.setExposeRoles(splitCsv(item.getExposeRoles()));
node.setAccessRoles(splitCsv(item.getAccessRoles()));
return node;
}
private long resolveTtlSeconds() {
try {
String value = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_CACHE_TTL,
DEFAULT_CACHE_TTL, "포탈 메뉴 캐시 TTL(초)");
return Long.parseLong(value.trim());
} catch (Exception e) {
log.warn("메뉴 캐시 TTL 조회 실패 - 기본값 {}초 사용", DEFAULT_CACHE_TTL, e);
return Long.parseLong(DEFAULT_CACHE_TTL);
}
}
private static List<String> splitCsv(String csv) {
if (csv == null || csv.trim().isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(token -> !token.isEmpty())
.collect(Collectors.toList());
}
private static String normalizePath(String path) {
if (path == null || path.trim().isEmpty()) {
return null;
}
String normalized = path.trim();
int queryIndex = normalized.indexOf('?');
return queryIndex >= 0 ? normalized.substring(0, queryIndex) : normalized;
}
/** role 비의존 메뉴 스냅샷 (불변 취급) */
@Getter
public static class MenuSnapshot {
private final List<MenuNode> roots;
private final Map<String, List<String>> accessRolesByPath;
private final int itemCount;
private final long loadedAt;
private final long ttlSeconds;
MenuSnapshot(List<MenuNode> roots, Map<String, List<String>> accessRolesByPath,
int itemCount, long loadedAt, long ttlSeconds) {
this.roots = roots;
this.accessRolesByPath = accessRolesByPath;
this.itemCount = itemCount;
this.loadedAt = loadedAt;
this.ttlSeconds = ttlSeconds;
}
boolean isExpired() {
return System.currentTimeMillis() - loadedAt > ttlSeconds * 1000L;
}
}
}
@@ -0,0 +1,108 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Getter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 요청별(role 필터 적용) 메뉴 뷰 — 템플릿 모델 {@code menuView}.
*
* <ul>
* <li>{@link #getGnb()} — 상단 글로벌 네비(GNB 섹션 최상위)</li>
* <li>{@link #getMypage()} — 마이페이지 드롭다운 루트 (미노출 시 null)</li>
* <li>{@link #activeGroup(String)} — service_sidebar 용 활성 그룹 탐색</li>
* </ul>
*/
@Getter
public class MenuView {
/**
* service_sidebar 기존 호출부의 activeMenu 키 → 메뉴 ID 브리지.
* 호출 페이지 수정 없이 DB 메뉴 기반 사이드바로 전환하기 위한 레거시 맵.
*/
private static final Map<String, String> LEGACY_ACTIVE_KEYS = new HashMap<>();
static {
LEGACY_ACTIVE_KEYS.put("intro", "service-intro");
LEGACY_ACTIVE_KEYS.put("guide", "service-guide");
LEGACY_ACTIVE_KEYS.put("oauth2", "service-oauth2-guide");
LEGACY_ACTIVE_KEYS.put("webhookGuide", "service-webhook-dev-guide");
LEGACY_ACTIVE_KEYS.put("notice", "support-notice");
LEGACY_ACTIVE_KEYS.put("faq", "support-faq");
LEGACY_ACTIVE_KEYS.put("qna", "support-qna");
LEGACY_ACTIVE_KEYS.put("feedback", "support-partnership");
LEGACY_ACTIVE_KEYS.put("users", "my-page-users");
LEGACY_ACTIVE_KEYS.put("apiKey", "my-page-clients");
LEGACY_ACTIVE_KEYS.put("webhook", "my-page-webhook");
LEGACY_ACTIVE_KEYS.put("statistics", "my-page-statistics");
LEGACY_ACTIVE_KEYS.put("profile", "my-page-profile");
LEGACY_ACTIVE_KEYS.put("password", "my-page-password");
}
private final List<MenuNode> gnb;
private final MenuNode mypage;
private final String currentPath;
public MenuView(List<MenuNode> gnb, MenuNode mypage, String currentPath) {
this.gnb = gnb;
this.mypage = mypage;
this.currentPath = currentPath;
}
/**
* 사이드바용 활성 그룹. legacy activeMenu 키 → 메뉴 ID 우선,
* 실패 시 현재 경로(정확/prefix) 매칭으로 폴백.
*/
public MenuNode activeGroup(String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
MenuNode byId = targetId == null ? null : findRootContaining(node -> targetId.equals(node.getMenuId()));
if (byId != null) {
return byId;
}
return findRootContaining(this::matchesCurrentPath);
}
/**
* 사이드바 항목 활성 여부: legacy 키 매칭 우선, 경로 매칭 폴백.
*/
public boolean isActive(MenuNode node, String activeMenu) {
String targetId = activeMenu == null ? null : LEGACY_ACTIVE_KEYS.get(activeMenu);
if (targetId != null) {
return targetId.equals(node.getMenuId());
}
return matchesCurrentPath(node);
}
private MenuNode findRootContaining(java.util.function.Predicate<MenuNode> predicate) {
for (MenuNode root : allRoots()) {
if (predicate.test(root) || root.getChildren().stream().anyMatch(predicate)) {
return root;
}
}
return null;
}
private List<MenuNode> allRoots() {
if (mypage == null) {
return gnb;
}
java.util.ArrayList<MenuNode> roots = new java.util.ArrayList<>(gnb);
roots.add(mypage);
return roots;
}
private boolean matchesCurrentPath(MenuNode node) {
if (!node.hasPath() || currentPath == null) {
return false;
}
return currentPath.equals(node.getPath())
|| currentPath.startsWith(node.getPath() + "/");
}
public static MenuView empty() {
return new MenuView(Collections.emptyList(), null, null);
}
}
@@ -0,0 +1,38 @@
package com.eactive.apim.portal.djb.menu;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* menu.yml 바인딩 (spring.config.import 로 로드).
* 트리(children 중첩) 형태 그대로 바인딩하며, 평탄화는 {@link MenuSeeder} 가 수행한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-menu")
public class PortalMenuYmlProperties {
private List<MenuItemDef> items = new ArrayList<>();
@Data
public static class MenuItemDef {
/** kebab-case 자연키 (^[a-z0-9-]+$) */
private String id;
private String name;
private String path;
/** true = 클릭 없는 상위 그룹 */
private boolean group;
/** GNB(기본) | MYPAGE */
private String section;
/** FontAwesome 아이콘 클래스 (마이페이지 드롭다운) */
private String icon;
private boolean newWindow;
private List<String> exposeRoles = new ArrayList<>();
private List<String> accessRoles = new ArrayList<>();
private List<MenuItemDef> children = new ArrayList<>();
}
}
@@ -0,0 +1,47 @@
package com.eactive.apim.portal.djb.menu;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* roles.yml 바인딩 (spring.config.import 로 로드).
* 기존 application.yml 의 {@code portal.portal_security} 를 대체한다.
* 로그인 시 권한 확장(PortalUserAuthService)이 직접 사용하며,
* DB 미러(PTL_ROLE / PTL_ROLE_AUTHORITY)는 {@link MenuSeeder} 가 적재한다.
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal-roles")
public class PortalRolesProperties {
private List<RoleDef> roles = new ArrayList<>();
/** AUTHORITY 유형 역할 코드 → 한글 라벨 (admin UI 표기용) */
private Map<String, String> authorityNames = new LinkedHashMap<>();
@Data
public static class RoleDef {
private String code;
private String name;
private List<String> authorities = new ArrayList<>();
}
/**
* 기본 역할의 파생 권한 목록. 미정의 역할이면 빈 목록.
*/
public List<String> getAuthorities(RoleCode roleCode) {
return roles.stream()
.filter(role -> role.getCode().equals(roleCode.name()))
.findFirst()
.map(RoleDef::getAuthorities)
.orElse(Collections.emptyList());
}
}
@@ -0,0 +1,40 @@
package com.eactive.apim.portal.djb.swing;
/**
* Swing 메신저 수신자 ID 가 행번(직원번호)이 아닐 때의 처리 정책.
*
* <p>Swing 메신저는 {@code TSEAIRM02.USERID} 를 수신자 코드로 사용하며 운영 직원은 숫자 행번을 쓴다.
* portal-admin 역할이 부여된 개발용 계정(영문 ID 등)은 Swing 에 존재하지 않아 발송이 무의미하므로
* 이 정책으로 처리 방식을 선택한다. PTL_PROPERTY
* {@code Portal / djb.swing.notify.non-employee.policy} 로 지정한다.</p>
*/
public enum EmployeeIdPolicy {
/** 정상 발송 (PTL_MESSAGE_REQUEST 에 PENDING 적재) */
SEND,
/** 발송 + WARN 로그 */
SEND_WARN,
/** 무시 처리 — REQUEST_STATUS='SKIPPED' 로 적재해 이력만 남기고 배치는 수집하지 않음 */
SKIP,
/** 거부 — PTL_MESSAGE_REQUEST 에 적재하지 않음 + INFO 로그 */
REJECT_LOG,
/** 완전 거부 — 적재하지 않고 로그도 남기지 않음 */
REJECT;
/** 프로퍼티 문자열 → enum. 값이 없거나 알 수 없으면 기본값 {@link #SKIP}. */
public static EmployeeIdPolicy from(String value) {
if (value != null) {
String trimmed = value.trim();
for (EmployeeIdPolicy policy : values()) {
if (policy.name().equalsIgnoreCase(trimmed)) {
return policy;
}
}
}
return SKIP;
}
}
@@ -0,0 +1,160 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.entity.MessageTemplate;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Swing 메신저 발송 요청을 PTL_MESSAGE_REQUEST 에 직접 적재한다.
*
* <p>TSEAIRM02(직원) → PTL_MESSAGE_TEMPLATE(본문) → PTL_MESSAGE_REQUEST(발송 요청) 순으로
* 처리하며, 실제 발송은 기존과 동일하게 eapim-admin 의 {@code UmsDispatchJob}(5초 주기)이
* {@code REQUEST_STATUS='PENDING'} 행을 수집해 수행한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SwingMessageWriter {
private static final String MESSAGE_TYPE_MESSENGER = "MESSENGER";
private static final String STATUS_PENDING = "PENDING";
/** 행번 아닌 ID 를 무시 처리할 때 쓰는 신설 상태값. 배치(PENDING 수집)가 건드리지 않는다. */
private static final String STATUS_SKIPPED = "SKIPPED";
private static final String ENABLED = "Y";
private final SwingStaffRepository swingStaffRepository;
private final MessageTemplateRepository messageTemplateRepository;
private final MessageRequestRepository messageRequestRepository;
private final SwingNotifyProperties properties;
@Transactional
public void write(MessageCode code, Map<String, Object> params) {
String role = properties.getTargetRole();
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
if (staffs == null || staffs.isEmpty()) {
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
return;
}
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
if (template == null) {
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
return;
}
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
code.name(), template.getEnableMessenger());
return;
}
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
for (UserInfo staff : staffs) {
try {
writeOne(code, template, params, staff, umsIds, policy);
} catch (Exception e) {
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
}
}
}
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
EmployeeIdPolicy policy) {
String messengerId = staff.getUserid();
String requestStatus = STATUS_PENDING;
if (!properties.isEmployeeId(messengerId)) {
switch (policy) {
case REJECT:
return;
case REJECT_LOG:
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
return;
case SKIP:
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
STATUS_SKIPPED, messengerId, code.name());
requestStatus = STATUS_SKIPPED;
break;
case SEND_WARN:
log.warn("행번이 아닌 ID — 발송 진행. Swing 미등록 계정이면 발송 실패한다. userid={}, code={}",
messengerId, code.name());
break;
case SEND:
default:
break;
}
}
Map<String, String> messageParams = toStringParams(params);
messageParams.put("userId", messengerId);
messageParams.putIfAbsent("USER_ID", messengerId);
if (StringUtils.hasText(staff.getUsername())) {
messageParams.put("userName", staff.getUsername());
messageParams.putIfAbsent("USER_NAME", staff.getUsername());
}
MessageRequest request = new MessageRequest();
request.setMessageCode(code);
request.setMessageType(MESSAGE_TYPE_MESSENGER);
request.setSubject(buildMessage(template.getSubjectTemplate(), messageParams));
request.setMessage(buildMessage(template.getMessengerTemplate(), messageParams));
request.setUsername(staff.getUsername());
request.setUserId(messengerId);
request.setMessengerId(messengerId);
request.setEaiInterfaceId(umsIds.getInterfaceId());
request.setServiceId(umsIds.getServiceId());
request.setRequestDate(LocalDateTime.now());
request.setRequestStatus(requestStatus);
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
messageRequestRepository.save(request);
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
}
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
private static Map<String, String> toStringParams(Map<String, Object> params) {
Map<String, String> result = new HashMap<>();
if (params != null) {
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
result.put(entry.getKey(), String.valueOf(entry.getValue()));
}
}
return result;
}
/** 템플릿 본문의 {@code %KEY%} 플레이스홀더를 치환한다(정규식 아닌 리터럴 치환). */
private static String buildMessage(String contents, Map<String, String> params) {
if (!StringUtils.hasText(contents)) {
return "";
}
String result = contents;
for (Map.Entry<String, String> entry : params.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
result = result.replace("%" + entry.getKey() + "%", entry.getValue());
}
return result;
}
}
@@ -0,0 +1,43 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.djb.swing.config.SwingAsyncConfig;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* DjBankSwing(사내 메신저) 전용 알림 발행자.
*
* <p>포털 사용자용 범용 발송 경로({@code MessageHandlerService} → {@code MessageSendService})를
* 타지 않고 TSEAIRM02 / PTL_MESSAGE_TEMPLATE / PTL_MESSAGE_REQUEST 를 직접 다룬다.
* 내부 직원은 수신자 ID 체계(행번)와 채널(메신저 단일)이 포털 사용자와 완전히 달라 분리했다.</p>
*
* <p>비동기 실행이므로 게시물 등록 트랜잭션과 분리된다. 발송 적재 실패가 등록을 되돌리지 않는 대신,
* 등록이 롤백돼도 알림은 남을 수 있다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SwingNotifier {
private final SwingMessageWriter swingMessageWriter;
/**
* 대상 역할(PTL_PROPERTY {@code djb.swing.notify.role}) 직원 전원에게 메신저 알림을 적재한다.
*
* @param code 메세지 코드 (PTL_MESSAGE_TEMPLATE.MESSAGE_CODE 와 동일)
* @param params 템플릿 {@code %KEY%} 치환 파라미터
*/
@Async(SwingAsyncConfig.EXECUTOR)
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
swingMessageWriter.write(code, params);
} catch (Exception e) {
log.warn("Swing 알림 발행 실패 — code={}", code == null ? null : code.name(), e);
}
}
}
@@ -0,0 +1,112 @@
package com.eactive.apim.portal.djb.swing;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Swing 알림 관련 PTL_PROPERTY 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다.
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
* 생성하므로 별도 초기 데이터 없이도 동작한다.</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SwingNotifyProperties {
public static final String GROUP = "Portal";
public static final String KEY_TARGET_ROLE = "djb.swing.notify.role";
public static final String KEY_EMPLOYEE_ID_PATTERN = "djb.swing.notify.employee-id.pattern";
public static final String KEY_NON_EMPLOYEE_POLICY = "djb.swing.notify.non-employee.policy";
/** UMS 메신저 연계 식별자. 기존 발송 경로(MessageSendService)와 같은 키를 그대로 읽는다. */
public static final String KEY_MESSENGER_IF_ID = "djb.ums.messenger.if_id";
public static final String KEY_MESSENGER_TX_ID = "djb.ums.messenger.tx_id";
private static final String DEFAULT_TARGET_ROLE = "portal-admin";
private static final String DEFAULT_EMPLOYEE_ID_PATTERN = "^[0-9]+$";
private final PortalPropertyService portalPropertyService;
/** 컴파일된 행번 정규식 캐시. 프로퍼티 값이 바뀌면 다시 컴파일한다. */
private volatile String cachedPatternValue;
private volatile Pattern cachedPattern;
/** 알림 대상 직원을 식별하는 TSEAIRM02.ROLEIDNFINAME 역할명 */
public String getTargetRole() {
return resolve(KEY_TARGET_ROLE, DEFAULT_TARGET_ROLE,
"Swing 알림 대상 직원 역할명 (TSEAIRM02.ROLEIDNFINAME 의 콤마 구분 토큰)");
}
/** 행번 아닌 ID 처리 정책 */
public EmployeeIdPolicy getNonEmployeePolicy() {
return EmployeeIdPolicy.from(resolve(KEY_NON_EMPLOYEE_POLICY, EmployeeIdPolicy.SKIP.name(),
"행번 아닌 ID 처리 정책 (SEND/SEND_WARN/SKIP/REJECT_LOG/REJECT)"));
}
/**
* 행번(직원번호) 형식인지 여부. 정규식이 잘못 지정된 경우 기본 정규식으로 폴백한다.
*
* @param userId TSEAIRM02.USERID
*/
public boolean isEmployeeId(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return false;
}
return employeeIdPattern().matcher(userId.trim()).matches();
}
/** UMS 메신저 연계 식별자(EAI 인터페이스 ID / 서비스 ID) */
public UmsMessengerIds getUmsMessengerIds() {
// 환경별 실제 연계값이라 임의 기본값을 만들면 위험 — getOrCreateProperty 대신 조회만 한다.
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap(GROUP);
return new UmsMessengerIds(
portalProperties.get(KEY_MESSENGER_IF_ID),
portalProperties.get(KEY_MESSENGER_TX_ID));
}
private Pattern employeeIdPattern() {
String value = resolve(KEY_EMPLOYEE_ID_PATTERN, DEFAULT_EMPLOYEE_ID_PATTERN,
"행번(직원번호) 판별 정규식. 미매치 시 개발용 ID 로 간주");
Pattern cached = this.cachedPattern;
if (cached != null && value != null && value.equals(this.cachedPatternValue)) {
return cached;
}
Pattern compiled;
try {
compiled = Pattern.compile(value);
} catch (Exception e) {
log.warn("행번 판별 정규식이 잘못되어 기본값으로 대체합니다 — pattern={}", value, e);
compiled = Pattern.compile(DEFAULT_EMPLOYEE_ID_PATTERN);
}
this.cachedPatternValue = value;
this.cachedPattern = compiled;
return compiled;
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
/** UMS 메신저 연계 식별자 묶음 */
@Getter
public static class UmsMessengerIds {
private final String interfaceId;
private final String serviceId;
public UmsMessengerIds(String interfaceId, String serviceId) {
this.interfaceId = interfaceId;
this.serviceId = serviceId;
}
}
}
@@ -0,0 +1,34 @@
package com.eactive.apim.portal.djb.swing.config;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Swing 알림 전용 비동기 실행기.
*
* <p>게시물 등록 트랜잭션이 메신저 알림 적재를 기다리지 않도록 분리한다.
* eapim-admin 의 {@code WebhookAsyncConfig} 와 동일한 구성.</p>
*/
@Configuration
@EnableAsync
public class SwingAsyncConfig {
public static final String EXECUTOR = "swingNotifyExecutor";
@Bean(name = EXECUTOR)
public Executor swingNotifyExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("swing-notify-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
@@ -0,0 +1,27 @@
package com.eactive.apim.portal.djb.swing.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Swing 알림 대상 직원(TSEAIRM02) 조회 전용 리포지토리.
*/
@Repository
@EMSDataSource
public interface SwingStaffRepository extends JpaRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRole(@Param("role") String role);
}
+5 -20
View File
@@ -23,6 +23,10 @@ server:
spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
data:
web:
pageable:
@@ -218,26 +222,7 @@ portal:
view-name: apps/apis/static/tokenApiSpec
bean: apiHandler
portal_security:
ROLE_USER:
- ROLE_INQUIRY
- ROLE_ACCOUNT
ROLE_CORP_USER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
ROLE_CORP_MANAGER:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# portal_security 는 roles.yml (portal-roles) 로 이동
page:
home:
name: "Home"
+55
View File
@@ -0,0 +1,55 @@
# ------------------------------------------------------------------------------
# 포탈 GNB 메뉴 정의 (menu.yml)
#
# - 부팅 시 PTL_MENU_ITEM 으로 자동 적재(upsert by id). 동일 id 의 내용이 바뀌면
# 기본값(DFLT_*)이 갱신되고, 관리자가 수정하지 않은 필드만 새 기본값을 따라간다.
# - 배치(위치) 정보는 PTL_MENU_PLACEMENT 가 비어있을 때만 최초 적재된다.
# 이후 배치 관리는 eapim-admin "포탈메뉴관리" 화면에서 수행.
# - id: kebab-case (^[a-z0-9-]+$). 변경 시 새 항목으로 인식되므로 변경 금지.
# - group: true → 상위 그룹(클릭 없음). path 를 주면 그룹도 링크 동작.
# - section: GNB(기본) | MYPAGE(마이페이지 드롭다운)
# - expose-roles / access-roles: 생략=전체 허용, AUTHENTICATED=로그인 사용자,
# 그 외 역할 코드 나열 시 하나라도 보유하면 허용(any-of)
# ------------------------------------------------------------------------------
portal-menu:
items:
- id: service
name: "서비스 소개"
group: true
children:
- { id: service-intro, name: "API 포탈 소개", path: /service/intro }
- { id: service-guide, name: "회원가입 안내", path: /service/guide }
- { id: service-oauth2-guide, name: "OAuth2 개발가이드", path: /service/oauth2-guide }
- { id: service-webhook-dev-guide, name: "웹훅 개발가이드", path: /service/webhook-dev-guide }
- { id: open-api, name: "오픈 API", path: /apis }
- id: support
name: "고객지원"
group: true
children:
- { id: support-notice, name: "공지사항", path: /portalnotice }
- { id: support-faq, name: "FAQ", path: /faq_list }
- { id: support-qna, name: "Q&A", path: /inquiry }
- { id: support-partnership, name: "피드백/개선요청", path: /partnership }
- { id: api-status, name: "API Status", path: /apistatus }
- id: my-page
name: "마이페이지"
group: true
section: MYPAGE
expose-roles: [AUTHENTICATED]
children:
- { id: my-page-users, name: "개발자 관리", path: /users, icon: fa-users,
expose-roles: [ROLE_CORP_MANAGER], access-roles: [ROLE_CORP_MANAGER] }
- { id: my-page-clients, name: "API 신청 관리", path: /clients, icon: fa-key,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
- { id: my-page-statistics, name: "이용 통계", path: /statistics/api, icon: fa-chart-bar,
expose-roles: [ROLE_APP], access-roles: [ROLE_APP] }
- { id: my-page-profile, name: "내 정보 관리", path: /mypage, icon: fa-user-circle,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
- { id: my-page-password, name: "비밀번호 변경", path: /password/change, icon: fa-lock,
expose-roles: [AUTHENTICATED], access-roles: [AUTHENTICATED] }
+47
View File
@@ -0,0 +1,47 @@
# ------------------------------------------------------------------------------
# 포탈 역할 정의 (roles.yml)
#
# - 기존 application.yml 의 portal.portal_security 를 이동한 파일.
# - 로그인 시 권한 확장(PortalUserAuthService)은 이 파일 바인딩을 직접 사용한다.
# - 부팅 시 PTL_ROLE / PTL_ROLE_AUTHORITY 로 미러 적재되며,
# 해당 테이블은 eapim-admin 메뉴 권한 선택 UI 소스로만 소비된다.
# ------------------------------------------------------------------------------
portal-roles:
roles:
- code: ROLE_USER
name: "개인사용자"
authorities:
- ROLE_INQUIRY
- ROLE_ACCOUNT
- code: ROLE_CORP_USER
name: "법인사용자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- code: ROLE_CORP_MANAGER
name: "법인관리자"
authorities:
- ROLE_API_KEY_REQUEST
- ROLE_API_KEY_REQUEST_VIEW
- ROLE_WEBHOOK
- ROLE_INQUIRY
- ROLE_APP
- ROLE_ACCOUNT
- ROLE_CORP_API
- ROLE_DASHBOARD
- ROLE_USER_MANAGER
# PTL_ROLE(AUTHORITY 유형) 한글 라벨 — admin 권한 선택 UI 표기용
authority-names:
ROLE_INQUIRY: "문의 작성"
ROLE_ACCOUNT: "계정 관리"
ROLE_API_KEY_REQUEST: "인증키 신청"
ROLE_API_KEY_REQUEST_VIEW: "인증키 신청 조회"
ROLE_APP: "앱/API 신청 관리"
ROLE_WEBHOOK: "웹훅 관리"
ROLE_CORP_API: "법인 API 조회"
ROLE_DASHBOARD: "대시보드"
ROLE_USER_MANAGER: "개발자 관리"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
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 base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
const PAGE_SIZE = 10;
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
const today = page.getAttribute('data-today') || '';
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
const minDate = page.getAttribute('data-min-date') || '';
// 진입 시 날짜 미지정 = 조회 기간 전체
@@ -39,6 +43,8 @@
const apiInput = document.getElementById('filterApiInput');
const apiClearBtn = document.getElementById('filterApiClear');
const apiListEl = document.getElementById('filterApiList');
const unlistedBanner = document.getElementById('unlistedApiBanner');
const unlistedName = document.getElementById('unlistedApiName');
let apiOptions = [];
@@ -124,15 +130,21 @@
indexBar.innerHTML = entries.map(function (entry) {
const hasIncident = entry.incCount > 0;
const hasDelay = entry.dlyCount > 0;
const hasMaintenance = entry.mntCount > 0;
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
let cls = '';
if (hasIncident && hasMaintenance) cls = ' has-both';
if (kindCount > 1) cls = ' has-mixed';
else if (hasIncident) cls = ' has-incident';
else if (hasDelay) cls = ' has-degraded';
else if (hasMaintenance) cls = ' has-maintenance';
if (state.date && state.date === entry.date) cls += ' is-selected';
const parts = [];
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
@@ -163,25 +175,65 @@
// ---------------- 이슈 카드 ----------------
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
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) {
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
let inner;
if (issue.kind === 'MAINTENANCE') {
@@ -211,8 +263,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ '</div>'
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis)
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, issue.hiddenApiCount)
+ '</article>';
}
@@ -271,13 +323,8 @@
.then(function (apis) {
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
const selected = findApiOption(state.apiId);
if (state.apiId && !selected) {
// 조회 가능 목록에 없는 API 가 URL 로 들어온 경우 필터 해제
state.apiId = '';
syncBrowserUrl();
}
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
renderApiInput();
})
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
@@ -299,8 +346,16 @@
function renderApiInput() {
const selected = findApiOption(state.apiId);
apiInput.value = optionLabel(selected);
apiCombo.classList.toggle('has-value', !!selected);
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
apiCombo.classList.toggle('has-value', !!state.apiId);
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
if (unlisted) {
unlistedName.textContent = urlApiName || state.apiId;
}
unlistedBanner.style.display = unlisted ? '' : 'none';
}
function closeApiList() {
@@ -375,7 +430,20 @@
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); });
+66 -19
View File
@@ -8,11 +8,13 @@
const authenticated = page.classList.contains('is-authenticated');
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
const noticeDetailBase = page.getAttribute('data-notice-detail-base') || '/portalnotice/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
const ACTIVE_INCIDENT_LIMIT = 1;
@@ -81,25 +83,65 @@
const query = [];
if (params.date) query.push('date=' + encodeURIComponent(params.date));
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
if (params.apiName) query.push('apiName=' + encodeURIComponent(params.apiName));
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
}
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
* 영향 API 태그.
*
* 서버가 이미 현재 사용자에게 게시된 API 만 내려주므로 여기 오는 항목은 전부 링크 대상이다.
* 게시되지 않은 GW 인터페이스는 개별 노출하지 않고 hiddenApiCount 로만 받아 건수로 묶는다.
*/
function apiPillsHtml(apis, label) {
if (!apis || !apis.length) return '';
const pills = apis.map(function (api) {
function apiPillsHtml(apis, label, hiddenCount) {
const hidden = hiddenCount || 0;
if ((!apis || !apis.length) && hidden === 0) return '';
const pills = (apis || []).map(function (api) {
const text = escapeHtml(api.apiName || api.apiId);
if (!linkableApiIds.has(api.apiId)) {
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
+ text + '</span>';
return '<span class="as-api-pill is-unlinked">' + text + '</span>';
}
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
+ text + '</a>';
}).join('');
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>'
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
+ apiPillsHtml(incident.apis, '영향 API:')
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
+ apiPillsHtml(incident.apis, '영향 API:', incident.hiddenApiCount)
+ splitHtml(timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '',
noticeHtml(incident))
+ '</section>';
}).join('');
@@ -206,7 +249,7 @@
document.getElementById('myApisCount').textContent = apis.length + '건';
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
const cls = badgeClass[api.currentStatus] || 'is-muted';
return '<tr data-api-id="' + escapeHtml(api.apiId) + '">'
return '<tr data-api-id="' + escapeHtml(api.apiId) + '" data-api-name="' + escapeHtml(api.apiName) + '">'
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
@@ -216,7 +259,10 @@
card.style.display = '';
card.querySelectorAll('tbody tr').forEach(function (row) {
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>'
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
+ '</div>'
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
+ apiPillsHtml(card.impactedApis, '영향 API:')
+ splitHtml(card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '',
noticeHtml(card))
+ apiPillsHtml(card.impactedApis, '영향 API:', card.hiddenApiCount)
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
+ '</div>'
@@ -258,8 +305,8 @@
// ---------------- ❺ 지난 이슈 사항 ----------------
function issueCardHtml(issue) {
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindBadge = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
const kindBadge = kindClass;
let inner;
if (issue.kind === 'MAINTENANCE') {
@@ -295,8 +342,8 @@
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis, '영향 API:')
+ splitHtml(inner, noticeHtml(issue))
+ apiPillsHtml(issue.impactedApis, '영향 API:', issue.hiddenApiCount)
+ '</article>'
+ '</div>';
}
@@ -142,7 +142,11 @@ const customPopups = {
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
// type 은 열려 있는 동안만 password 로 전환한다.
// 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
// 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
$('#passwordPopupInput').val('').removeClass('error')
.attr('type', 'password').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -219,8 +223,8 @@ const customPopups = {
// Body 스크롤 복원
$('body').css('overflow', '');
// 입력 필드 초기화
$('#passwordPopupInput').val('').removeClass('error');
// 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다)
$('#passwordPopupInput').val('').removeClass('error').attr('type', 'text');
$('#passwordPopupError').removeClass('show').text('');
// 이벤트 리스너 제거
@@ -285,20 +285,40 @@
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
transform-origin: 400px 110px;
transform-box: fill-box;
animation: pulseLine 2s ease-in-out infinite;
}
@keyframes pulseLine {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.1);
opacity: 0.5;
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background-color: #fcfcfc;
&::placeholder {
color: #7f8a95;
@@ -1010,6 +1010,7 @@ select.form-control {
resize: none;
transition: border-color 0.2s;
min-height: 250px;
background-color: #fcfcfc;
&::placeholder {
color: #94A3B8;
@@ -49,6 +49,7 @@
padding: 10px 20px;
border-radius: 8px;
transition: var(--transition-smooth);
white-space: nowrap;
&:hover {
background-color: var(--accent-light);
@@ -63,6 +64,7 @@
background-color: var(--primary-color);
padding: 10px 24px;
border-radius: 8px;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
transition: var(--transition-smooth);
@@ -199,7 +199,7 @@
p {
margin: 0;
& + p {
&+p {
margin-top: $spacing-md;
}
}
@@ -240,7 +240,6 @@
.btn,
.btn-modal-cancel,
.btn-modal-confirm {
flex: 1;
min-width: 0;
height: 46px;
font-size: 15px;
@@ -65,6 +65,19 @@
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
@@ -89,7 +89,7 @@
}
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
height: clamp(44px, 11.73vw, 60px);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
@@ -128,9 +128,31 @@
.header-left {
display: flex;
align-items: center;
// 헤더가 좁아져도 로고 영역은 줄이지 않는다 (줄임은 가운데 메뉴가 감당)
flex-shrink: 0;
.logo {
height: 32px;
// 로고 워드마크(DJ Bank) 밑선에 "API Portal" 글자 baseline 맞춤
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
img { flex-shrink: 0; }
.mobile-logo-link {
align-items: flex-end;
flex-shrink: 0;
}
.mobile-logo-text {
align-items: flex-end;
line-height: 1;
padding-bottom: 2px;
// "API / Portal" 로 접히지 않게
white-space: nowrap;
flex-shrink: 0;
}
}
}
@@ -158,7 +180,7 @@
// Logo with modern styling
.logo {
display: block;
display: flex;
height: 32px;
vertical-align: middle;
z-index: 10;
@@ -202,7 +224,6 @@
// Logo link styles
.logo-link,
.mobile-logo-link,
.drawer-logo-link {
display: inline-block;
text-decoration: none;
@@ -252,7 +273,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: none;
}
}
@@ -263,7 +284,7 @@
justify-content: space-between;
width: 100%;
@media (max-width: 768px) {
@media (max-width: 1024px) {
display: flex;
}
}
@@ -937,7 +958,7 @@
.sub-menu {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 17px);
left: 0;
min-width: 200px;
background: var(--white);
@@ -982,6 +1003,8 @@
padding: 12px;
position: relative;
transition: var(--transition-smooth);
// 폭이 좁아져도 "서비스 소 / 개" 처럼 메뉴명이 잘리지 않게 한다
white-space: nowrap;
&:hover {
color: var(--primary-blue);
@@ -1096,7 +1119,7 @@
// Mobile Responsive Design
// 375px 기준 반응형 - clamp() 사용하여 화면 크기에 비례
// ===========================
@media (max-width: 768px) {
@media (max-width: 1024px) {
.global-header {
// Figma: 44px 높이, 반투명 배경
// 375px 기준 44px → 44/375*100 ≈ 11.73vw, 범위: 44px ~ 60px
@@ -1247,7 +1270,7 @@
}
// Mobile responsive
@media (max-width: 768px) {
@media (max-width: 1024px) {
.header-user-info {
display: none; // Hide on mobile, use drawer instead
}
@@ -571,7 +571,7 @@
// Result Page Styles (아이디 찾기 결과 페이지)
.account-recovery-result {
width: 100%;
padding: 60px 40px;
padding: 35px 0px;
border-radius: 0 0 12px 12px;
text-align: center;
@@ -690,6 +690,44 @@
border-radius: 12px;
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 {
font-family: $font-family-primary;
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)
// -----------------------------------------------------------------------------
@@ -785,7 +844,7 @@
.auth-input-group {
.auth-timer {
font-size: 12px;
right: 100px;
right: 18px;
}
}
@@ -467,16 +467,27 @@
}
}
&-badge {
/* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다.
기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
&-badges {
position: absolute;
top: 39px;
left: 32px;
right: 32px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&-badge {
background-color: #f2f2f3;
border-radius: 10px;
padding: 2px 20px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
span {
font-size: 14px;
@@ -484,9 +495,34 @@
font-weight: 500;
color: #1b4ab7;
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 {
position: absolute;
top: 86px;
@@ -846,7 +882,7 @@
}
// API Overview Card (Flat Style)
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */
.api-basic-info-header {
display: flex;
align-items: center;
@@ -855,6 +891,41 @@
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-apply-footer {
display: flex;
@@ -3,6 +3,19 @@
// 설계: architecture/01-api-status/02-screen-design
// 타이틀 배너: 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 안에 놓이므로 컨테이너 폭을 따른다
.as-title-banner {
@@ -92,6 +105,9 @@
--as-pill: 999px;
display: grid;
// minmax(0,1fr): grid 자식의 암묵적 min-width(auto)를 해제한다.
// 90일 막대(90개 × 최소폭)가 카드의 최소 폭을 밀어올려 좁은 화면에서 카드가 잘리는 것 방지
grid-template-columns: minmax(0, 1fr);
gap: 20px;
padding: 0 0 70px;
@@ -237,6 +253,83 @@
color: var(--as-muted);
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-degraded { background: var(--as-warn); }
&.is-none { background: var(--as-none-bg); }
// 장애·지연·점검 중 2종 이상이 겹친 날
&.is-mixed,
&.is-both { background: var(--as-both); }
}
}
@@ -477,6 +572,7 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
&.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); }
.as-issue-meta-row {
@@ -498,6 +594,7 @@
}
&.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); }
}
@@ -585,7 +682,10 @@
transition: opacity 0.15s;
&.has-incident { background: var(--as-err); }
&.has-degraded { background: var(--as-warn); }
&.has-maintenance { background: var(--as-info); }
// 유형이 2종 이상 섞인 날
&.has-mixed,
&.has-both { background: var(--as-both); }
&.is-selected { outline: 2px solid var(--as-text-2); outline-offset: 1px; }
&: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 {
display: flex;
justify-content: center;
@@ -805,7 +927,14 @@
.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-title { font-size: 17px; }
@@ -168,16 +168,16 @@
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #dadada;
border: 1px solid #DFDFDF;
border-radius: $border-radius-md;
background-color: $white;
background-color: #fcfcfc;
font-size: 14px;
color: $text-dark;
outline: none;
transition: border-color 0.2s ease;
&::placeholder {
color: #dadada;
color: #94A3B8;
}
&:focus {
@@ -1130,7 +1130,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
}
&:disabled {
background-color: #E2E8F0 !important;
background-color: #ededed !important;
}
}
@@ -2929,7 +2929,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
.s1-title,
.s2-title,
.s3-title {
font-size: 30px;
font-size: 24px;
font-weight: 700;
color: #0d0e11;
line-height: 1;
@@ -3261,7 +3261,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
padding: 12px 16px;
}
.s1-upload-inner > img {
.s1-upload-inner>img {
width: 28px;
height: 28px;
}
+14 -760
View File
@@ -423,342 +423,6 @@
// 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 {
@@ -1288,14 +952,21 @@
.card-description {
font-family: $font-family-primary;
font-size: 13px;
font-size: 16px;
color: var(--text-gray);
line-height: 1.5;
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') {
// Figma 모바일: 11px Medium, 색상 #515151
font-size: 13px;
font-size: 14px;
font-weight: 500;
line-height: 1;
@@ -1425,7 +1096,7 @@
letter-spacing: 1px;
@include respond-to('sm') {
font-size: 13px;
font-size: 14px;
}
}
@@ -1447,7 +1118,7 @@
}
.info-description {
font-size: 14px;
font-size: 16px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 36px;
@@ -1459,7 +1130,7 @@
@include respond-to('sm') {
order: 3;
font-size: 13px;
font-size: 15px;
line-height: 1.6;
margin-bottom: 24px;
word-break: keep-all;
@@ -1637,424 +1308,6 @@
// -----------------------------------------------------------------------------
// 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 {
@@ -2130,9 +1383,10 @@
}
p {
font-size: 13px;
font-size: 14px;
color: var(--text-gray);
line-height: 1.5;
width: 80%;
}
.card-arrow {
@@ -120,7 +120,7 @@
}
.form-label-text {
font-size: 16px;
font-size: 14px;
font-weight: 700;
color: #000;
}
@@ -159,7 +159,7 @@
.form-input:disabled,
.input-readonly {
background: #e9ecef !important;
background: #ededed !important;
}
.compound-input {
@@ -289,6 +289,7 @@
width: 24px;
height: 24px;
flex-shrink: 0;
color: #4685ef
}
p {
@@ -136,9 +136,9 @@
}
&--completed {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
background: #d6fbd0;
color: #11af4c;
border: 1px solid #a0d4b2;
}
&--closed {
@@ -159,6 +159,12 @@
border: 1px solid #b3d4ff;
}
&--reviewing {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
}
&--test {
background: #e6fffa;
color: #0d9488;
@@ -352,6 +358,14 @@
font-weight: 700;
flex-shrink: 0;
}
// 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 ("GW 인터페이스 3건")
&.is-gw {
background-color: #ffffff;
border-style: dashed;
border-color: #cbd5e1;
color: #6b7280;
}
}
.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 {
display: flex;
@@ -34,6 +34,53 @@
@media (max-width: $breakpoint-sm) {
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
@@ -315,6 +362,16 @@
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 {
color: $text-gray;
font-weight: $font-weight-semibold;
@@ -336,29 +393,31 @@
// Select Dropdown
.org-form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
height: 48px;
padding: 0 16px;
border: 1px solid #E2E8F0;
border-radius: 10px;
font-size: 14px;
color: $text-dark;
color: #212529;
background: $white;
cursor: pointer;
transition: $transition-base;
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-repeat: no-repeat;
background-position: right $spacing-lg center;
background-position: right 16px center;
padding-right: $spacing-3xl;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1);
border-color: #3ba4ed;
box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
}
&:disabled {
background-color: $gray-bg;
color: $text-light;
background-color: #F8F9FA;
border-color: #E2E8F0;
color: #94A3B8;
cursor: not-allowed;
}
}
+402 -181
View File
@@ -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;
.signup-info-box {
background: #f5f5f5;
border-radius: 12px;
padding: 30px 40px;
background: #1d2a5e; // Deep slate dark card
border: 1px solid rgba(255, 255, 255, 0.08);
border-left: 5px solid #2a69de; // Bright tech blue accent line
border-radius: 16px;
padding: 28px 32px;
display: flex;
align-items: center;
gap: 30px;
gap: 28px;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
&__icon {
width: 76px;
height: 76px;
background: #fff;
border-radius: 50%;
width: 56px;
height: 56px;
background: #48507b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 40px;
height: 40px;
width: 28px;
height: 28px;
filter: brightness(0) invert(1); // Turns the green check into clean white icon
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 {
position: relative;
padding-left: 14px;
color: #535151;
font-size: 16px;
padding-left: 20px;
color: #94a3b8; // Sophisticated dim gray-blue
font-size: 15px;
font-weight: 500;
line-height: 1.8;
word-break: keep-all;
strong {
color: #ffffff;
font-weight: 700;
}
&::before {
content: '·';
content: '';
position: absolute;
left: 0;
color: #535151;
left: 4px;
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;
flex-direction: column;
gap: 40px;
position: relative;
padding-left: 80px;
justify-content: center;
min-height: 95px;
&::before {
content: '';
position: absolute;
left: 17px; // center of line
top: 47px;
bottom: 47px;
width: 2px;
background: #e6eefb; // Light blue line
z-index: 0;
&-header {
display: flex;
align-items: center;
margin-bottom: 6px;
}
&-num {
font-size: 14px;
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;
flex-direction: column;
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;
z-index: 1;
padding-left: 12px;
margin-bottom: 30px;
// 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;
left: 0;
top: 4px;
bottom: 4px;
width: 4px;
background: #0049B4;
border-radius: 2px;
}
}
.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 {
width: 95px;
height: 95px;
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);
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
svg {
width: 30px;
height: 30px;
.signup-roles-table {
width: 100%;
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 {
flex: 1;
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;
thead {
background: #f8fafc;
&-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-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) {
.signup-guide-v2 {
.signup-timeline {
padding-left: 0;
&::before {
display: none; // Hides the vertical timeline line
}
}
.signup-step {
gap: 15px;
&::before {
display: none; // Hides the glowing timeline dot
}
&__icon-box {
width: 60px;
height: 60px;
min-height: 60px;
svg {
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 {
height: auto;
min-height: 60px;
padding: 10px 15px;
padding: 12px 18px;
&-title {
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;
text-align: center;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.service_guide {
.title {
font-size: 28px;
font-weight: 700;
line-height: 36px;
letter-spacing: -0.02em;
color: #140064;
margin-bottom: 16px;
}
.service_guide {
.title {
font-size: 28px;
font-weight: 700;
line-height: 36px;
letter-spacing: -0.02em;
color: #140064;
margin-bottom: 16px;
}
.detail {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: #495057;
}
.detail {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: #495057;
}
}
@@ -1117,9 +1337,10 @@ $o2leg-err-fg: #a23b3b;
&__prereq-title {
margin: 4px 0 8px;
font-size: 15px;
font-size: 16px;
font-weight: 700;
color: $o2leg-text-dark;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
&__prereq-body p {
@@ -63,6 +63,7 @@ $wh-bg-soft: #f9f9f9;
border-radius: 50%;
margin-bottom: 16px;
color: #cbd5e1;
font-size: 25px;
}
h3 {
@@ -704,7 +705,7 @@ $wh-bg-soft: #f9f9f9;
.input-display-box {
width: 100%;
height: 48px;
background-color: #eeeeee;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
display: flex;
@@ -714,6 +715,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500;
word-break: break-all;
box-sizing: border-box;
border: 1px solid #DFDFDF;
}
.badges-row {
@@ -767,7 +769,7 @@ $wh-bg-soft: #f9f9f9;
flex: 1;
max-width: 250px;
height: 48px;
background-color: #eeeeee;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
display: flex;
@@ -778,6 +780,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
border: 1px solid #DFDFDF;
@media (max-width: 576px) {
max-width: 100%;
@@ -69,9 +69,18 @@
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card">
<!--/* 현재 상태는 게시된 장애·점검 공지 기준(/apistatus/current.json). 조회 실패해도 화면은 유지 */-->
<div class="org-section-header org-section-header--agreement api-basic-info-header">
<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 class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
@@ -475,6 +484,51 @@
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: 앱 인증 정보 자동 주입 =====
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
const appsSelect = document.getElementById('apps');
@@ -8,7 +8,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<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 class="service-hero__content">
<div class="service-hero__badge">
@@ -75,13 +75,20 @@
</div>
<!-- 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"
tabindex="0">
<!-- Group Badge -->
<div class="api-card-badge">
<span th:text="${api.apiGroupName}">그룹 이름</span>
<!-- Group Badge + 현재 상태 태그 -->
<div class="api-card-badges">
<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>
<!-- 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
const menuTitles = document.querySelectorAll('.js-menu-trigger');
menuTitles.forEach(function (title) {
@@ -1,61 +1,64 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" 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>
<section layout:fragment="contentFragment">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>본인 확인</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
<form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
<div class="register-form-container">
<div class="info-notice-box">
<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>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required autofocus>
<div class="form-row">
<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 class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${error}]]);
})
</script>
</section>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
})
</script>
</section>
</body>
</html>
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<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;" />
</div>
<div class="service-hero__content">
@@ -17,7 +17,7 @@
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<!-- 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;" />
</div>
<div class="service-hero__content">
@@ -76,9 +76,9 @@
<!-- Table Header -->
<div class="board-table-header">
<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="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: 120px;">등록일</div>
</div>
@@ -89,9 +89,19 @@
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
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>
<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})}"
class="notice-title-link"
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
@@ -99,7 +109,7 @@
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<!-- 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"
aria-label="비공개">
<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>
</a>
</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-org"
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
</div>
<div class="row-cell row-cell--status" data-label="처리상태">
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
<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="등록일"
<div class="row-cell row-cell--views" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<div class="row-cell row-cell--date" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<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;" />
</div>
<div class="service-hero__content">
@@ -69,7 +69,7 @@
<td
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
-</td>
<th></th>
<th>종료</th>
<td
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
-</td>
@@ -78,29 +78,27 @@
<th>상태</th>
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</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>
<td colspan="3">
<div class="affected-apis-wrapper">
<!-- First 3 APIs (Always visible) -->
<!-- 개발자포탈에 게시된 API 만 개별 노출한다 (인터페이스 ID 는 표시하지 않음) -->
<div class="affected-apis-list">
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
th:if="${iterStat.index < 3}">
<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:if="${iterStat.index < 3}" th:text="${api.apiName}">API 명</span>
<!-- Rest of APIs (Hidden by default) -->
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
<span class="affected-api-badge extra-api"
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
style="display: none;">
<strong th:text="${api.apiId}">API_ID</strong><span
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</span>
style="display: none;" th:text="${api.apiName}">API 명</span>
</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>
<!-- Toggle Button -->
@@ -141,6 +139,38 @@
</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 -->
<div class="notice-detail-actions">
<button type="button" class="btn-action-primary btn-notice-list"
@@ -16,7 +16,7 @@
<section class="service-hero">
<div class="service-hero__inner">
<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 class="service-hero__content">
<div class="service-hero__badge">
@@ -84,7 +84,13 @@
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number"
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"
th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance"
@@ -1,66 +1,73 @@
<!doctype html>
<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}">
<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>
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-check-circle result-icon"></i>
<h2 class="result-title">회원님의 아이디았습니다.</h2>
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
</p>
<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>
<!-- 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>
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-check-circle result-icon"></i>
<h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
</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>
<!-- 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>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text">
<i class="fas fa-info-circle"></i>
비밀번호가 기억나지 않는 경우에는
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>
이용해 주세요.
</p>
<!-- 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>
<!-- 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>
</th:block>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</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 class="hero-image-content">
<!-- Inline SVG Tech Illustration -->
@@ -47,7 +47,8 @@
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
</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 class="hero-image-content">
<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>
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
</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 class="hero-image-content">
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -146,40 +147,6 @@
<!-- API 검색 섹션 -->
<section class="api-search-section">
<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>*/-->
<!-- 피그마 시안 적용 카드 레이아웃 -->
@@ -328,7 +295,8 @@
</p>
<div class="action-buttons">
<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 class="info-image-box">
@@ -597,268 +565,284 @@
<!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript">
$(document).ready(function () {
LoginSuccessHandler.init({
successMsg: [[${ success }]],
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
dormantAccount: [[${ session.dormantAccount }]],
passwordExpired: [[${ session.passwordExpired }]],
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
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 th:inline="javascript">
$(document).ready(function () {
LoginSuccessHandler.init({
successMsg: [[${ success }]],
emailVerificationRequired: [[${ session.emailVerificationRequired }]],
dormantAccount: [[${ session.dormantAccount }]],
passwordExpired: [[${ session.passwordExpired }]],
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
});
</script>
// ========================================
// 숫자 카운트업 애니메이션
// ========================================
const observerOptions = {
threshold: 0.5,
rootMargin: '0px 0px -100px 0px'
};
<script>
document.addEventListener('DOMContentLoaded', function () {
// ========================================
// Hero Carousel
// ========================================
const heroCarousel = {
currentSlide: 0,
totalSlides: 3,
autoPlayInterval: null,
autoPlayDelay: 5000,
isPlaying: true,
const animateNumbers = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
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');
numbers.forEach(number => {
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
if (!this.slides.length) return;
const updateNumber = () => {
current += increment;
if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
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();
}
});
}
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) {
observer.observe(statsSection);
}
});
</script>
const animateNumbers = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
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>
</html>
@@ -14,7 +14,7 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<div class="service-main container" style="padding-top: 70px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
@@ -24,11 +24,12 @@
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
<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>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
@@ -1,80 +1,84 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" 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>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 변경</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content">
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
</div>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
<div class="register-form-container">
<div class="info-notice-box">
<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>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required>
<div class="form-row">
<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>
</div>
</div>
</div>
</div>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}"
th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
<script th:if="${error}" th:inline="javascript">
$(document).ready(function() {
customPopups.showAlert([[${error}]]);
})
</script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/actionLogout.do}]];
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
})
</script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/ actionLogout.do}]];
}
}
);
});
</script>
</section>
</script>
</section>
</body>
</html>
@@ -9,34 +9,49 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="org-register-container">
<div class="corp-manager-wrapper">
<div class="common-title-bar">
<h2 class="common-title">기본 정보</h2>
</div>
<div class="register-form-container">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="corp-manager-wrapper">
<h2 class="page-outer-title">정보 관리</h2>
<form id="updateForm" method="post" th:action="@{/mypage/update}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form">
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">소속기관</span>
</div>
<div class="form-field-wrapper">
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled">
</div>
<div class="register-form-container">
<!-- Section Title -->
<div class="inner-section-title">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<h3>기본 정보</h3>
</div>
<div class="register-form">
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">소속기관</span>
</div>
<div class="form-field-wrapper">
<input type="text" th:value="${user.portalOrg.orgName}" name="organization"
class="form-input input-readonly" placeholder="소속기관" disabled="disabled">
</div>
</div>
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div>
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div>
</form>
</div>
<!-- Action Buttons -->
<div class="form-actions form-actions--with-withdrawal">
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a>
<div class="form-actions-buttons">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button>
<!-- Action Buttons -->
<div class="form-actions">
<a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn-apply btn-primary submit-btn">수정</button>
</div>
</div>
</div>
</div>
@@ -1,524 +1,529 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<html lang="ko" xmlns:th="http://www.thymeleaf.org" 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}" alt="법인회원가입" class="title-image">
<h1>법인회원가입</h1>
</div>
</section>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
<h1>법인회원가입</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<section layout:fragment="contentFragment">
<div class="org-register-page corporate-register">
<!-- Page Title Banner -->
<div class="org-register-page corporate-register">
<!-- Page Title Banner -->
<div class="org-register-container">
<!-- Hidden Fields -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="registrationType" th:value="${registrationType}"/>
<div class="org-register-container">
<!-- Hidden Fields -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="registrationType" th:value="${registrationType}" />
<!-- Alert Container -->
<div id="alertContainer"></div>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<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>
</div>
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div>
<!-- Agreement Section -->
<th:block th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}"></th:block>
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}"
th:object="${portalOrg}" method="post">
<!-- Corporate Basic Information Section -->
<div class="org-section-header org-section-header--agreement">
<h3>법인 기본 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
<!-- Corporate Admin Information Section -->
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
<h3>법인 관리자 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<!-- Alert Container -->
<div id="alertContainer"></div>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<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">
<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>
</div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
<p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div>
<th:block th:if="${registrationType == 'corporate'}">
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
<!-- Agreement Section -->
<th:block
th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}">
</th:block>
<!-- Action Buttons -->
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
<button type="button" class="btn btn-primary btn_register">가입신청</button>
</div>
</form>
</div>
<form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}" th:object="${portalOrg}"
method="post">
<!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<!-- Corporate Basic Information Section -->
<div class="org-section-header org-section-header--agreement">
<h3>법인 기본 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<th:block th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoForm}"></th:block>
<!-- Corporate Admin Information Section -->
<div class="org-section-header org-section-header--agreement" style="margin-top: 48px;">
<h3>법인 관리자 정보</h3>
<span class="required-badge">필수 입력</span>
</div>
<div class="org-info-notice">
<div class="notice-icon-wrapper">
<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>
</div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p>
</div>
<th:block th:if="${registrationType == 'corporate'}">
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block>
</th:block>
<!-- Action Buttons -->
<div class="org-action-buttons">
<button type="button" class="btn btn-secondary" id="cancelButton">취소</button>
<button type="button" class="btn btn-primary btn_register">가입신청</button>
</div>
</form>
</div>
<!-- Loading Overlay -->
<div class="org-loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
</section>
</section>
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${error}]]);
});
</script>
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
let form = document.getElementById('registerForm');
let agreementForm = document.getElementById('agreementForm');
let btn_register = document.querySelector('.btn_register');
let isAuthVerified = false;
<th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript">
$(document).ready(function () {
customPopups.showAlert([[${ error }]]);
});
</script>
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></script>
<script th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoScript}"></script>
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
let form = document.getElementById('registerForm');
let agreementForm = document.getElementById('agreementForm');
let btn_register = document.querySelector('.btn_register');
let isAuthVerified = false;
// registrationScenario를 hidden input에서 가져오도록 수정
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
// registrationScenario를 hidden input에서 가져오도록 수정
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
scenarioInput.addEventListener('change', function () {
registrationScenario = this.value;
console.log('Registration Scenario Updated:', registrationScenario);
});
// MutationObserver를 사용하여 값 변경 감지
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
registrationScenario = scenarioInput.value;
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
}
});
});
observer.observe(scenarioInput, {attributes: true});
}
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
user: ['loginId', 'passwordConfirmIndividual'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
change: {
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
}
};
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
const submitOrgFields = [
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
'orgSectors', 'orgIndustryType'
];
// 이메일 유효성 검사 함수
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function collectFormData() {
const form = document.createElement('form');
form.enctype = 'multipart/form-data';
form.method = 'POST';
form.action = '/signup/portalOrg';
form.style.display = 'none';
const fields = requiredFieldsByScenario[registrationScenario];
if (!fields) {
console.error('Unknown scenario');
return form;
}
// 시나리오 값 추가
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = scenarioInput.value;
form.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = registrationScenario;
form.appendChild(input);
scenarioInput.addEventListener('change', function () {
registrationScenario = this.value;
console.log('Registration Scenario Updated:', registrationScenario);
});
// MutationObserver를 사용하여 값 변경 감지
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
registrationScenario = scenarioInput.value;
console.log('Registration Scenario Updated (from mutation):', registrationScenario);
}
});
});
observer.observe(scenarioInput, { attributes: true });
}
// user와 org 필드를 따로 처리
fields.user.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
user: ['loginId', 'passwordConfirmIndividual'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
change: {
user: ['loginId', 'passwordConfirmEmailChange', 'newLoginId'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
}
};
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
const submitOrgFields = [
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
'orgSectors', 'orgIndustryType'
];
// 이메일 유효성 검사 함수
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function collectFormData() {
const form = document.createElement('form');
form.enctype = 'multipart/form-data';
form.method = 'POST';
form.action = '/signup/portalOrg';
form.style.display = 'none';
const fields = requiredFieldsByScenario[registrationScenario];
if (!fields) {
console.error('Unknown scenario');
return form;
}
// 시나리오 값 추가
const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
input.name = 'registrationScenario';
input.value = scenarioInput.value;
form.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationScenario';
input.value = registrationScenario;
form.appendChild(input);
}
});
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
const files = element.files;
if (files.length > 0) {
const input = document.createElement('input');
input.type = 'file';
input.name = fieldId;
input.files = files;
form.appendChild(input);
}
} else {
// user와 org 필드를 따로 처리
fields.user.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
form.appendChild(input);
}
}
});
});
// Add confirmPassword manually based on the scenario
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
const files = element.files;
if (files.length > 0) {
const input = document.createElement('input');
input.type = 'file';
input.name = fieldId;
input.files = files;
form.appendChild(input);
}
} else {
const input = document.createElement('input');
input.type = 'hidden';
input.name = fieldId;
input.value = element.value;
form.appendChild(input);
}
}
});
// Add confirmPassword manually based on the scenario
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmIndividual.value;
form.appendChild(input);
}
} else if (registrationScenario === 'change') {
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
if (passwordConfirmEmailChange) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmEmailChange.value;
form.appendChild(input);
}
}
// 공통 필드 추가
if (document.getElementById('termsOfUse')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmIndividual.value;
input.name = 'termsOfUse';
input.value = document.getElementById('termsOfUse').checked;
form.appendChild(input);
}
} else if (registrationScenario === 'change') {
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange');
if (passwordConfirmEmailChange) {
if (document.getElementById('privacyCollect')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'confirmPassword';
input.value = passwordConfirmEmailChange.value;
input.name = 'privacyCollect';
input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input);
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = csrfToken.value;
form.appendChild(input);
}
const registrationType = document.querySelector('input[name="registrationType"]');
if (registrationType) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationType';
input.value = registrationType.value;
form.appendChild(input);
}
console.log('Collected Form Data for scenario:',
document.getElementById('registrationScenario')?.value || registrationScenario);
return form;
}
// 시나리오별 유효성 검사
function validateFormByScenario() {
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
console.log('Registration Scenario:', currentScenario);
if (!currentScenario) {
console.log('Unknown scenario');
return false;
}
switch (registrationScenario) {
case 'new':
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
console.log('New User Validation:', {
email: loginIdElement?.value,
isEmailValid,
isPasswordValid,
isPasswordMatch,
userName: userNameElement?.value,
hasUserName,
isAuthVerified,
requiredFields: currentScenario
});
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
case 'retain':
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
const retainLoginIdElement = document.getElementById('loginId');
console.log('Retain User Validation:', {
email: retainLoginIdElement?.value,
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
passwordLength: passwordConfirmElement?.value.length,
hasPassword,
requiredFields: currentScenario
});
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
case 'change':
const changeLoginIdElement = document.getElementById('loginId');
const newLoginIdElement = document.getElementById('newLoginId');
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
console.log('Change Email Validation:', {
currentEmail: changeLoginIdElement?.value,
newEmail: newLoginIdElement?.value,
hasPassword: hasChangePassword,
requiredFields: currentScenario
});
return changeLoginIdElement && newLoginIdElement &&
isValidEmail(changeLoginIdElement.value) &&
hasChangePassword &&
isValidEmail(newLoginIdElement.value);
default:
console.log('Unknown scenario');
return false;
}
}
// 공통 필드 추가
if (document.getElementById('termsOfUse')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'termsOfUse';
input.value = document.getElementById('termsOfUse').checked;
form.appendChild(input);
// 공통 필드 유효성 검사
function validateCommonFields() {
if (!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
compRegFile: $('#compRegFile').val().trim() !== ''
};
console.log('Common Fields Validation:', validations);
return Object.values(validations).every(value => value === true);
}
if (document.getElementById('privacyCollect')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'privacyCollect';
input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input);
// 전체 폼 유효성 검사
function validateForm() {
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
console.log('Form validation result:', {
scenarioValidation,
commonValidation,
registrationScenario
});
return scenarioValidation && commonValidation;
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = csrfToken.value;
form.appendChild(input);
}
const registrationType = document.querySelector('input[name="registrationType"]');
if (registrationType) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'registrationType';
input.value = registrationType.value;
form.appendChild(input);
}
console.log('Collected Form Data for scenario:',
document.getElementById('registrationScenario')?.value || registrationScenario);
return form;
}
// 시나리오별 유효성 검사
function validateFormByScenario() {
const currentScenario = document.getElementById('registrationScenario')?.value || 'new';
console.log('Registration Scenario:', currentScenario);
if (!currentScenario) {
console.log('Unknown scenario');
return false;
}
switch (registrationScenario) {
case 'new':
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false;
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false;
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true';
console.log('New User Validation:', {
email: loginIdElement?.value,
isEmailValid,
isPasswordValid,
isPasswordMatch,
userName: userNameElement?.value,
hasUserName,
isAuthVerified,
requiredFields: currentScenario
});
return isEmailValid && isPasswordValid && isPasswordMatch && hasUserName && isAuthVerified;
case 'retain':
const passwordConfirmElement = document.getElementById('passwordConfirmIndividual');
const hasPassword = passwordConfirmElement ? passwordConfirmElement.value.length >= 8 : false;
const retainLoginIdElement = document.getElementById('loginId');
console.log('Retain User Validation:', {
email: retainLoginIdElement?.value,
isEmailValid: retainLoginIdElement ? isValidEmail(retainLoginIdElement.value) : false,
passwordLength: passwordConfirmElement?.value.length,
hasPassword,
requiredFields: currentScenario
});
return retainLoginIdElement && isValidEmail(retainLoginIdElement.value) && hasPassword;
case 'change':
const changeLoginIdElement = document.getElementById('loginId');
const newLoginIdElement = document.getElementById('newLoginId');
const changePasswordElement = document.getElementById('passwordConfirmEmailChange');
const hasChangePassword = changePasswordElement ? changePasswordElement.value.length >= 8 : false;
console.log('Change Email Validation:', {
currentEmail: changeLoginIdElement?.value,
newEmail: newLoginIdElement?.value,
hasPassword: hasChangePassword,
requiredFields: currentScenario
});
return changeLoginIdElement && newLoginIdElement &&
isValidEmail(changeLoginIdElement.value) &&
hasChangePassword &&
isValidEmail(newLoginIdElement.value);
default:
console.log('Unknown scenario');
return false;
}
}
// 공통 필드 유효성 검사
function validateCommonFields() {
if(!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
compRegFile: $('#compRegFile').val().trim() !== ''
};
console.log('Common Fields Validation:', validations);
return Object.values(validations).every(value => value === true);
}
// 전체 폼 유효성 검사
function validateForm() {
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
console.log('Form validation result:', {
scenarioValidation,
commonValidation,
registrationScenario
});
return scenarioValidation && commonValidation;
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
}
// 공통 항목
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
}
let isSubmitting = false;
// 등록 버튼 클릭 이벤트
btn_register.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
// Prevent resubmission
if (isSubmitting) {
console.log('Form is already being submitted...');
return;
}
isSubmitting = true;
btn_register.disabled = true;
// Show loading overlay
document.getElementById('loadingOverlay').classList.add('active');
try {
form.classList.add('was-validated');
if (agreementForm) {
agreementForm.classList.add('was-validated');
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
}
console.log('Validating form...');
// 공통 항목
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
if (validateForm()) {
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
} else {
console.log('Form validation failed');
return errors;
}
// 현재 상태 출력
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
let isSubmitting = false;
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
// 등록 버튼 클릭 이벤트
btn_register.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
console.log('Validation details:', {
scenarioValidation,
commonValidation,
registrationScenario,
isAuthVerified,
isPasswordValid,
isPasswordMatch
});
// Prevent resubmission
if (isSubmitting) {
console.log('Form is already being submitted...');
return;
}
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
isSubmitting = true;
btn_register.disabled = true;
// Reset submission state since validation failed
// Show loading overlay
document.getElementById('loadingOverlay').classList.add('active');
try {
form.classList.add('was-validated');
if (agreementForm) {
agreementForm.classList.add('was-validated');
}
console.log('Validating form...');
if (validateForm()) {
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
} else {
console.log('Form validation failed');
// 현재 상태 출력
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
const isPasswordValid = document.getElementById('isPasswordValid')?.value.trim() === 'true';
const isPasswordMatch = document.getElementById('isPasswordMatch')?.value.trim() === 'true';
console.log('Validation details:', {
scenarioValidation,
commonValidation,
registrationScenario,
isAuthVerified,
isPasswordValid,
isPasswordMatch
});
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
// Reset submission state since validation failed
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
}
} catch (error) {
console.error('Form submission error:', error);
// Reset submission state on error
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
}
} catch (error) {
console.error('Form submission error:', error);
// Reset submission state on error
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
});
// 신규 이메일 중복 체크 응답 처리 함수
function handleNewEmailValidationResponse(response) {
if (response.valid) {
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
}
});
// 신규 이메일 중복 체크 응답 처리 함수
function handleNewEmailValidationResponse(response) {
if (response.valid) {
customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
}
// 인증 완료 이벤트 리스너
document.addEventListener('authVerified', function () {
isAuthVerified = true;
});
// 인증 완료 이벤트 리스너
document.addEventListener('authVerified', function () {
isAuthVerified = true;
// 취소 버튼 클릭 시
document.getElementById('cancelButton').addEventListener('click', function () {
location.href = '/login';
});
});
// 취소 버튼 클릭 시
document.getElementById('cancelButton').addEventListener('click', function () {
location.href = '/login';
});
});
</script>
</th:block>
</script>
</th:block>
</body>
</html>
@@ -24,25 +24,24 @@
<h3>초대 정보</h3>
</div>
<div class="account-recovery-card" style="margin-bottom: 32px;">
<div class="account-recovery-result">
<div class="result-header">
<i class="fas fa-envelope-open-text result-icon" style="color: #1a73e8;"></i>
<h2 class="result-title" style="font-size: 18px;">
<strong th:text="${userName}"></strong>님, 법인회원 초대가 도착했습니다.
</h2>
<div class="invitation_alert_banner">
<div class="banner-content-wrapper">
<div class="banner-icon-area">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#1976D2" stroke-width="2"/>
<path d="M12 8V13" stroke="#1976D2" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="16" r="1.25" fill="#1976D2"/>
</svg>
</div>
<div class="result-info-box">
<p class="info-text">
<strong th:text="${orgName}"></strong>에서
DJBank API Portal 법인회원으로 초대하였습니다.
</p>
<p class="info-text" style="margin-top: 8px;">
<div class="banner-text-area">
<strong>
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
</strong>
<p>
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
초대를 수락하시면 법인회원으로 전환됩니다.
</p>
</div>
</div>
</div>
@@ -1,50 +1,62 @@
<!doctype html>
<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">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="64" height="64" fill="url(#pattern0_1130_6799)"/>
<defs>
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)"/>
</pattern>
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg=="/>
</defs>
</svg>
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
<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">
<!-- Result Content Area -->
<div class="account-recovery-result">
<div class="result-header">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="64" height="64" fill="url(#pattern0_1130_6799)" />
<defs>
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)" />
</pattern>
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg==" />
</defs>
</svg>
<h2 class="result-title"
th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2>
</div>
<!-- Info Box -->
<div class="result-info-box info-box-highlight" th:if="${registrationType == 'corporate'}">
<div class="highlight-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#0049B4" stroke-width="2" />
<path d="M12 8V13" stroke="#0049B4" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="16" r="1.25" fill="#0049B4" />
</svg>
</div>
<p class="info-text">
<strong>관리자의 확인 및 승인</strong> 이후
이용하실 수 있습니다.
</p>
</div>
</div>
<!-- Info Box -->
<div class="result-info-box" th:if="${registrationType == 'corporate'}">
<p class="info-text">
관리자의 확인 및 승인 이후<br>
이용하실 수 있습니다.
</p>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/}" class="submit-button">홈으로</a>
</div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/}" class="submit-button">홈으로</a>
</div>
</div>
</div>
</div>
</th:block>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
@@ -37,181 +37,244 @@
<img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" />
</div>
<ul class="signup-info-list">
<li>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 운영 담당자의 승인 후 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</li>
<li>회원은 <strong>법인, 개인(개발자)</strong>분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 <strong>운영 담당자의 승인 후</strong> 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 <strong>직원(개발자)을 추가 등록</strong>해 주시기 바랍니다.</li>
</ul>
</div>
<!-- Vertical Timeline Steps -->
<div class="signup-timeline">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 01</div>
<div class="signup-step__card-title">회원가입</div>
<!-- Member Type Tabs -->
<div class="signup-tabs">
<button type="button" class="signup-tab-btn active" data-tab="personal">개인 회원</button>
<button type="button" class="signup-tab-btn" data-tab="corporate">법인 회원</button>
</div>
<!-- Timeline Container for fixed height spacing -->
<div class="signup-timeline-container">
<!-- Personal Member Timeline -->
<div class="signup-timeline active" id="timeline-personal">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 01</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">회원가입</span>
</div>
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="signup-step">
<div class="signup-step__icon-box two">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 13C11 11.3431 12.3431 10 14 10C15.6569 10 17 11.3431 17 13C17 14.6569 15.6569 16 14 16C12.3431 16 11 14.6569 11 13Z"
stroke="#0049B4" stroke -width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
<!-- Corporate Member Timeline -->
<div class="signup-timeline" id="timeline-corporate">
<!-- Step 1 -->
<div class="signup-step">
<div class="signup-step__icon-box one">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-add" viewBox="0 0 16 16">
<path
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0m-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0M8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4" />
<path
d="M8.256 14a4.5 4.5 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10q.39 0 .74.025c.226-.341.496-.65.804-.918Q8.844 9.002 8 9c-5 0-6 3-6 4s1 1 1 1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 01</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">회원가입</span>
</div>
<p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 02</div>
<div class="signup-step__card-title">승인</div>
</div>
</div>
<!-- Step 3 -->
<div class="signup-step">
<div class="signup-step__icon-box three">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16">
<path
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
</svg>
<!-- Step 2 -->
<div class="signup-step">
<div class="signup-step__icon-box two">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-journal-check" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M10.854 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 8.793l2.646-2.647a.5.5 0 0 1 .708 0" />
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2" />
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 02</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">승인</span>
</div>
<p class="signup-step__card-desc">신청하신 계정 정보를 관리자가 확인 후 승인합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 03</div>
<div class="signup-step__card-title">API/APP 사용 신청</div>
</div>
</div>
<!-- Step 4 -->
<div class="signup-step">
<div class="signup-step__icon-box four">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
</svg>
<!-- Step 3 -->
<div class="signup-step">
<div class="signup-step__icon-box three">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-code" viewBox="0 0 16 16">
<path
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 03</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">API / APP 사용 신청</span>
</div>
<p class="signup-step__card-desc">사용할 API를 선택하고 테스트용 키를 발급받습니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 04</div>
<div class="signup-step__card-title">개발/테스트</div>
</div>
</div>
<!-- Step 5 -->
<div class="signup-step">
<div class="signup-step__icon-box five">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
<!-- xmlns="http://www.w3.org/2000/svg">-->
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
<!--/* </svg>*/-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
</svg>
<!-- Step 4 -->
<div class="signup-step">
<div class="signup-step__icon-box four">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-display" viewBox="0 0 16 16">
<path
d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4q0 1 .25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75Q6 13 6 12H2s-2 0-2-2zm1.398-.855a.76.76 0 0 0-.254.302A1.5 1.5 0 0 0 1 4.01V10c0 .325.078.502.145.602q.105.156.302.254a1.5 1.5 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.76.76 0 0 0 .254-.302 1.5 1.5 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.76.76 0 0 0-.302-.254A1.5 1.5 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 04</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">개발 / 테스트</span>
</div>
<p class="signup-step__card-desc">제공된 개발 도구로 자유롭게 연동을 테스트합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 05</div>
<div class="signup-step__card-title">운영신청 및 심사</div>
</div>
</div>
<!-- Step 6 -->
<div class="signup-step">
<div class="signup-step__icon-box six">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
</svg> */-->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
</svg>
<!-- Step 5 -->
<div class="signup-step">
<div class="signup-step__icon-box five">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<path
d="M2 10h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m9-9h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1m0 9a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm0-10a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM2 9a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2zm7 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2zM0 2a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.354.854a.5.5 0 1 0-.708-.708L3 3.793l-.646-.647a.5.5 0 1 0-.708.708l1 1a.5.5 0 0 0 .708 0z" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 05</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">운영신청 및 심사</span>
</div>
<p class="signup-step__card-desc">실운영 환경 적용을 위해 최종 심사를 요청합니다.</p>
</div>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 06</div>
<div class="signup-step__card-title">서비스 개시(예약)</div>
<!-- Step 6 -->
<div class="signup-step">
<div class="signup-step__icon-box six">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-airplane" viewBox="0 0 16 16">
<path
d="M6.428 1.151C6.708.591 7.213 0 8 0s1.292.592 1.572 1.151C9.861 1.73 10 2.431 10 3v3.691l5.17 2.585a1.5 1.5 0 0 1 .83 1.342V12a.5.5 0 0 1-.582.493l-5.507-.918-.375 2.253 1.318 1.318A.5.5 0 0 1 10.5 16h-5a.5.5 0 0 1-.354-.854l1.319-1.318-.376-2.253-5.507.918A.5.5 0 0 1 0 12v-1.382a1.5 1.5 0 0 1 .83-1.342L6 6.691V3c0-.568.14-1.271.428-1.849m.894.448C7.111 2.02 7 2.569 7 3v4a.5.5 0 0 1-.276.447l-5.448 2.724a.5.5 0 0 0-.276.447v.792l5.418-.903a.5.5 0 0 1 .575.41l.5 3a.5.5 0 0 1-.14.437L6.708 15h2.586l-.647-.646a.5.5 0 0 1-.14-.436l.5-3a.5.5 0 0 1 .576-.411L15 11.41v-.792a.5.5 0 0 0-.276-.447L9.276 7.447A.5.5 0 0 1 9 7V3c0-.432-.11-.979-.322-1.401C8.458 1.159 8.213 1 8 1s-.458.158-.678.599" />
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-header">
<span class="signup-step__card-num">STEP 06</span>
<span class="signup-step__card-dot"></span>
<span class="signup-step__card-title">서비스 개시(예약)</span>
</div>
<p class="signup-step__card-desc">실운영 키를 적용하여 서비스를 정식 오픈합니다.</p>
</div>
</div>
</div>
</div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
<section class="signup-roles-tbd">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder">
<span class="signup-roles-tbd__badge">TBD</span>
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
<!-- 회원 구분별 이용 가능 서비스 -->
<section class="signup-roles-table-section">
<h2 class="signup-roles-table-section__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-table-wrapper">
<table class="signup-roles-table">
<thead>
<tr>
<th rowspan="2" class="col-feature">기능</th>
<th rowspan="2" class="col-role">비회원</th>
<th rowspan="2" class="col-role">개인</th>
<th colspan="2" class="col-role-group">법인이용자</th>
</tr>
<tr>
<th class="col-sub-role">법인개발자</th>
<th class="col-sub-role">법인관리자</th>
</tr>
</thead>
<tbody>
<tr>
<td class="cell-feature">API 조회</td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">API 테스트</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">1:1 문의</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">피드백/개선요청</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">클라이언트 - 신규, 수정, 삭제</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">Webhook 관리</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">이용 통계</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
<tr>
<td class="cell-feature">개발자 관리</td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--denied">X</span></td>
<td><span class="status-badge status-badge--allowed">O</span></td>
</tr>
</tbody>
</table>
</div>
</section>
@@ -225,6 +288,31 @@
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
const tabs = document.querySelectorAll('.signup-tab-btn');
const timelines = document.querySelectorAll('.signup-timeline');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
const targetTab = this.getAttribute('data-tab');
// Active Tab toggle
tabs.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
// Timeline display toggle
timelines.forEach(timeline => {
if (timeline.id === `timeline-${targetTab}`) {
timeline.classList.add('active');
} else {
timeline.classList.remove('active');
}
});
});
});
});
</script>
</th:block>
</html>

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