2 Commits

Author SHA1 Message Date
hong a0685c8689 merge 충돌 해결 2026-08-04 14:53:33 +09:00
hong c0ae60f738 fix : 부분적으로 수정 2026-08-04 14:50:27 +09:00
81 changed files with 2453 additions and 8632 deletions
-15
View File
@@ -80,21 +80,6 @@ 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,20 +101,5 @@ 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 -3
View File
@@ -203,6 +203,4 @@ task printSourceSets {
println " Output dir : ${srcSet.output.classesDirs.asPath}" println " Output dir : ${srcSet.output.classesDirs.asPath}"
} }
} }
} }
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
-326
View File
@@ -1,326 +0,0 @@
/*
* 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))
}
}
@@ -1,36 +0,0 @@
package com.eactive.apim.gateway.data.statistics.entity;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
*
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
*/
@Entity
@Table(name = "API_STATUS")
@Data
public class GwApiStatus {
/** EAI 서비스명 */
@Id
@Column(name = "EAISVCNAME", length = 30)
private String eaisvcname;
/** N 정상 / C 점검 / D 지연 / E 장애 */
@Column(name = "STATUS_CODE", length = 1)
private String statusCode;
@Column(name = "MODIFIED_BY", length = 20)
private String modifiedBy;
@Column(name = "MODIFIED_DATE")
private LocalDateTime modifiedDate;
}
@@ -1,17 +0,0 @@
package com.eactive.apim.gateway.data.statistics.repository;
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
/**
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
*/
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
Optional<LocalDateTime> findLastModifiedDate();
}
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.NotFoundException; import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -32,7 +31,6 @@ public class ApiController {
private final ApiService apiService; private final ApiService apiService;
private final ApiServiceService apiServiceService; private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade; private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec"; private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증"; private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -88,8 +86,6 @@ public class ApiController {
model.addAttribute("totalApiCount", searchResult.get("totalApiCount")); model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount")); model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1"); model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
return "apps/apis/mainApiList"; return "apps/apis/mainApiList";
} }
@@ -78,7 +78,7 @@ public class AppServiceFacade {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types, List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState())); Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능) // 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types)); appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순 // 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
@@ -7,7 +7,6 @@ import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -36,7 +35,7 @@ public class UserRoleHistoryService {
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록 * 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만). * 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
*/ */
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) { public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
try { try {
String actor = SecurityUtil.getCurrentLoginId(); String actor = SecurityUtil.getCurrentLoginId();
@@ -1,211 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.controller;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusSupport;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusUptimeService;
import com.eactive.apim.portal.djb.apistatus.service.MyApiStatusQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 개발자포탈 API Status 화면.
*
* <p>장애/점검 데이터는 관리자 공지사항(PTL_NOTICE + DJB_APISTATUS_INCIDENT)과
* 자동 탐지(eapim-admin ApiStatusDetectionService)가 채운다. 본 컨트롤러는 읽기 전용이다.</p>
*
* <p>비로그인 사용자도 모두 조회 가능하며, "내 API 현황"만 로그인을 요구한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("/apistatus")
@RequiredArgsConstructor
public class ApiStatusController {
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
private static final int MAX_PAGE_SIZE = 50;
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
private static final int MAX_STATUS_BATCH = 100;
private final ApiStatusQueryService apiStatusQueryService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusIssueHistoryService issueHistoryService;
private final MyApiStatusQueryService myApiStatusQueryService;
private final ApiStatusCatalogService catalogService;
private final ApiCurrentStatusService apiCurrentStatusService;
/** P1 - API Status 메인 */
@GetMapping
public ModelAndView index() {
ModelAndView mav = new ModelAndView("djb/apistatus/index");
mav.addObject("windowDays", catalogService.getWindowDays());
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
mav.addObject("lastFireAt", lastFireAt);
mav.addObject("lastFireRelative",
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
return mav;
}
/**
* P8 - 전체 이슈 이력. 날짜를 지정하지 않으면 조회 기간(90일) 전체를 본다.
* 날짜 선택 가능 범위는 서버 기준 일자로 내려 클라이언트 timezone 차이를 없앤다.
*/
@GetMapping("/issues")
public ModelAndView issues(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
LocalDate today = ApiStatusSupport.now().toLocalDate();
int windowDays = catalogService.getWindowDays();
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
mav.addObject("windowDays", windowDays);
mav.addObject("today", today);
mav.addObject("minDate", today.minusDays(windowDays - 1L));
mav.addObject("selectedDate", date);
mav.addObject("selectedApiId", apiId);
mav.addObject("selectedKind", kind);
return mav;
}
/** 이슈 이력 API 필터 - 현재 사용자가 조회 가능한 API 목록 */
@GetMapping("/apis.json")
@ResponseBody
public List<ApiOptionDTO> selectableApis() {
return catalogService.getSelectableApis();
}
/** P2 - 90일 가동률 */
@GetMapping("/uptime.json")
@ResponseBody
public List<DailyStatDTO> uptime(
@RequestParam(value = "days", defaultValue = "0") int days) {
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
}
/** P3 - 진행 중 장애 */
@GetMapping("/active.json")
@ResponseBody
public List<ActiveIncidentDTO> active() {
return apiStatusQueryService.getActiveIncidents();
}
/** P4 - 내 API 현황 (로그인 필수) */
@GetMapping("/my-apis.json")
@ResponseBody
public ResponseEntity<List<MyApiStatusDTO>> myApis() {
if (!SecurityUtil.isAuthenticated()) {
return new ResponseEntity<>(Collections.emptyList(), HttpStatus.UNAUTHORIZED);
}
return ResponseEntity.ok(myApiStatusQueryService.getMyApiStatuses());
}
/** P5 - 예정/진행 중 점검 */
@GetMapping("/maintenance.json")
@ResponseBody
public List<MaintenanceCardDTO> maintenance() {
return apiStatusQueryService.getOngoingMaintenance();
}
/** P6 - 지난 이슈 (종결) */
@GetMapping("/recent-issues.json")
@ResponseBody
public List<PastIssueCardDTO> recentIssues(
@RequestParam(value = "size", defaultValue = "" + DEFAULT_RECENT_ISSUE_SIZE) int size) {
return apiStatusQueryService.getRecentClosedIssues(size);
}
/** P7 - 이슈 상세 */
@GetMapping("/incident/{incidentId}")
@ResponseBody
public ResponseEntity<PastIssueCardDTO> incidentDetail(@PathVariable Long incidentId) {
return apiStatusQueryService.getIssueDetail(incidentId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** P9 - 90일 이슈 일자 인덱스 */
@GetMapping("/issues/dates.json")
@ResponseBody
public List<IssueDateEntryDTO> issueDates(
@RequestParam(value = "days", defaultValue = "0") int days,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind) {
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
}
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
@GetMapping("/issues/list.json")
@ResponseBody
public Page<PastIssueCardDTO> issueList(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(value = "apiId", required = false) String apiId,
@RequestParam(value = "kind", required = false) String kind,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
int safePage = Math.max(page, 0);
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
}
/**
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
*
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
*/
@GetMapping("/current.json")
@ResponseBody
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
return apiCurrentStatusService.getStatus(apiId)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.badRequest().build());
}
/**
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
*/
@GetMapping("/current-list.json")
@ResponseBody
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
}
}
@@ -1,28 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 진행 중 장애 카드 (P3)
*/
@Data
public class ActiveIncidentDTO {
private Long incidentId;
private String noticeId;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
private long elapsedMinutes;
private List<AffectedApiDTO> apis = new ArrayList<>();
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
}
@@ -1,22 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 이슈 영향 API
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AffectedApiDTO {
private String apiId;
private String apiName;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime recoveredAt;
}
@@ -1,53 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 이슈 이력 API 필터 옵션. 화면에는 API 명만 노출하고 apiId 는 조회 파라미터로만 쓴다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiOptionDTO {
private String apiId;
private String apiName;
/** 소속 API 그룹명 (오픈 API 목록과 동일 기준) */
private String groupName;
}
@@ -1,50 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 90일 가동률 일별 집계 (P2)
*/
@Data
public class DailyStatDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate statDate;
/** 0.0000 ~ 1.0000 */
private double uptimeRatio;
/** 장애 + 지연 합계 (가동률 차감분). 지연도 서비스 저하이므로 계속 차감한다 */
private long incidentMinutes;
/** 그 중 지연(자동 탐지) 분 */
private long delayMinutes;
private long maintenanceMinutes;
/** 막대 색상 구분: NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String status;
private List<IssueRefDTO> issues = new ArrayList<>();
@Data
public static class IssueRefDTO {
private Long incidentId;
private String kind;
private String title;
public IssueRefDTO() {
}
public IssueRefDTO(Long incidentId, String kind, String title) {
this.incidentId = incidentId;
this.kind = kind;
this.title = title;
}
}
}
@@ -1,29 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 이슈 이력 페이지의 90일 인덱스바 1칸 (P9)
*/
@Data
public class IssueDateEntryDTO {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate date;
/** INCIDENT / DELAY / MAINTENANCE - 유형 필터와 같은 값 */
private List<String> kinds = new ArrayList<>();
/** 장애 건수 (자동 탐지 지연 제외) */
private int incCount;
/** 지연 건수 (자동 탐지 지연 - INCIDENT 중 INTERFACE_ID 가 DELAY_START: 인 건) */
private int dlyCount;
private int mntCount;
}
@@ -1,30 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 점검 사항 카드 (P5). 상태 관리를 하지 않으므로(ADR-F15) 진행 단계는 화면이 현재 시각과 비교해 부여한다.
*/
@Data
public class MaintenanceCardDTO {
private Long incidentId;
private String noticeId;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime registeredAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastModifiedAt;
}
@@ -1,25 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 내가 이용 중인 API 의 현재 상태 (P4)
*/
@Data
public class MyApiStatusDTO {
private String apiId;
private String apiName;
/** NORMAL / DEGRADED / OUTAGE / MAINTENANCE */
private String currentStatus;
private String currentStatusLabel;
private Long activeIncidentId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastIncidentAt;
private double uptime90d;
}
@@ -1,33 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 지난 이슈 카드 (P6, P10). 장애는 타임라인 전체, 점검은 요약만 담는다.
*/
@Data
public class PastIssueCardDTO {
private Long incidentId;
private String noticeId;
private String kind;
private String state;
private String stateLabel;
private String title;
private String summary;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startedAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate dateGroup;
private Long durationMinutes;
private List<AffectedApiDTO> impactedApis = new ArrayList<>();
private List<TimelineEntryDTO> timeline = new ArrayList<>();
}
@@ -1,21 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 장애 처리 타임라인 1건
*/
@Data
public class TimelineEntryDTO {
private Long timelineId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime eventAt;
private String stateAfter;
private String labelKo;
private String body;
private String authorType;
}
@@ -1,118 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.repository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
*
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
* <ul>
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
* </ul>
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY";
String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE";
/** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */
String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = " + KIND_MAINTENANCE
+ " AND i.endAt IS NOT NULL AND i.endAt < :now)) ";
/**
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind IN :kinds"
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now);
/**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
*/
@Query(value = "SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION
+ " ORDER BY i.startedAt DESC",
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
Pageable pageable);
/**
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
*/
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
+ " WHERE " + VISIBLE
+ " AND a.incidentId = i.incidentId"
+ " AND a.apiId = :apiId"
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("apiId") String apiId);
/**
* 공개 상세 (P7)
*/
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
}
@@ -1,274 +0,0 @@
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;
}
}
}
@@ -1,179 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.AffectedApiDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 장애/점검 엔티티 → 화면 DTO 변환. 영향 API·타임라인은 한 번에 모아 읽어 1+N 조회를 피한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusAssembler {
private static final int RECENT_TIMELINE_SIZE = 3;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository timelineRepository;
public Map<Long, List<AffectedApiDTO>> loadApis(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<AffectedApiDTO>> result = new HashMap<>();
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds)) {
result.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
.add(new AffectedApiDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getRecoveredAt()));
}
return result;
}
/**
* 공개 타임라인. 최신순으로 담는다.
*/
public Map<Long, List<TimelineEntryDTO>> loadTimelines(Collection<Long> incidentIds) {
if (incidentIds == null || incidentIds.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, List<TimelineEntryDTO>> result = new HashMap<>();
for (DjbApistatusIncidentTimeline timeline :
timelineRepository.findByIncidentIdInAndVisibleYnOrderByEventAtDesc(incidentIds, "Y")) {
result.computeIfAbsent(timeline.getIncidentId(), key -> new ArrayList<>())
.add(toTimelineEntry(timeline));
}
return result;
}
public List<ActiveIncidentDTO> toActiveIncidents(List<DjbApistatusIncident> incidents, LocalDateTime now) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(ids);
List<ActiveIncidentDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
ActiveIncidentDTO dto = new ActiveIncidentDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setElapsedMinutes(ApiStatusSupport.minutesBetween(incident.getStartedAt(), now));
dto.setApis(apis.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
List<TimelineEntryDTO> all = timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList());
dto.setRecentTimeline(all.size() > RECENT_TIMELINE_SIZE
? new ArrayList<>(all.subList(0, RECENT_TIMELINE_SIZE)) : all);
result.add(dto);
}
return result;
}
public List<MaintenanceCardDTO> toMaintenanceCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
Map<Long, List<AffectedApiDTO>> apis = loadApis(incidentIds(incidents));
List<MaintenanceCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
MaintenanceCardDTO dto = new MaintenanceCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
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()));
dto.setRegisteredAt(incident.getCreatedDate());
dto.setLastModifiedAt(incident.getLastModifiedDate());
result.add(dto);
}
return result;
}
/**
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
*/
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> ids = incidentIds(incidents);
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
List<PastIssueCardDTO> result = new ArrayList<>();
for (DjbApistatusIncident incident : incidents) {
PastIssueCardDTO dto = new PastIssueCardDTO();
dto.setIncidentId(incident.getIncidentId());
dto.setNoticeId(incident.getNoticeId());
dto.setKind(incident.getKind() == null ? null : incident.getKind().name());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setStateLabel(ApiStatusSupport.stateLabel(incident.getState()));
dto.setTitle(incident.getTitle());
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
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()));
dto.setTimeline(timelines.getOrDefault(incident.getIncidentId(), Collections.emptyList()));
result.add(dto);
}
return result;
}
private TimelineEntryDTO toTimelineEntry(DjbApistatusIncidentTimeline timeline) {
TimelineEntryDTO dto = new TimelineEntryDTO();
dto.setTimelineId(timeline.getTimelineId());
dto.setEventAt(timeline.getEventAt());
dto.setStateAfter(timeline.getStateAfter() == null ? null : timeline.getStateAfter().name());
dto.setLabelKo(ApiStatusSupport.stateLabel(timeline.getStateAfter()));
dto.setBody(timeline.getBody());
dto.setAuthorType(timeline.getAuthorType());
return dto;
}
private List<Long> incidentIds(List<DjbApistatusIncident> incidents) {
return incidents.stream().map(DjbApistatusIncident::getIncidentId).collect(Collectors.toList());
}
}
@@ -1,179 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.gateway.data.statistics.repository.GwApiStatusRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* API Status 화면의 부가 조회 - 필터용 API 목록, 상태 모니터링 최근 실행 시각.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusCatalogService {
public static final String PROPERTY_GROUP = "Portal";
/** API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME) */
public static final String KEY_MONITOR_JOB_NAME = "djb.apistatus.monitor.job-name";
/** 실서버(DAPM) QRTZ_JOB_DETAILS 실사값 (2026-07-31) */
private static final String DEFAULT_MONITOR_JOB_NAME = "ApiStatusMonitorJob";
/** 상태/이력 조회 기간(일). 가동률 바·이슈 인덱스·date picker 범위가 모두 이 값을 따른다 */
public static final String KEY_WINDOW_DAYS = "djb.apistatus.window-days";
/** OPEN API 목록 카드의 현재 상태 태그 노출 여부 (true/false) */
public static final String KEY_API_LIST_STATUS_BADGE = "djb.apistatus.api-list-status-badge";
private static final boolean DEFAULT_API_LIST_STATUS_BADGE = true;
private static final int MIN_WINDOW_DAYS = 1;
private static final int MAX_WINDOW_DAYS = 365;
private final ApiSearchFacade apiSearchFacade;
private final PortalPropertyService portalPropertyService;
private final GwApiStatusRepository gwApiStatusRepository;
@PersistenceContext
private EntityManager entityManager;
/**
* 이슈 이력 필터용 API 목록.
*
* <p>"오픈 API" 목록 화면과 <b>같은 조회 경로</b>({@link ApiSearchFacade#searchApis})를 쓴다.
* 즉 API 그룹(AGWAPP.API_GROUP + API_GROUP_API)에 편성된 API 중
* PTL_API_SPEC_INFO 에 스펙이 있고 현재 사용자에게 공개된 것만 나온다.</p>
*/
@SuppressWarnings("unchecked")
public List<ApiOptionDTO> getSelectableApis() {
Object apis = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
if (!(apis instanceof List)) {
return Collections.emptyList();
}
return ((List<ApiSpecInfoDto>) apis).stream()
.filter(api -> StringUtils.isNotBlank(api.getApiId()))
.map(api -> new ApiOptionDTO(api.getApiId(),
StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()),
api.getApiGroupName()))
.sorted(Comparator.comparing(ApiOptionDTO::getApiName,
Comparator.nullsLast(Comparator.naturalOrder())))
.collect(Collectors.toList());
}
/**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
*
* <p>Job 이름은 PTL_PROPERTY({@code Portal} / {@code djb.apistatus.monitor.job-name})로
* 바꿀 수 있으며, 최초 접근 시 기본값으로 row 가 자동 생성된다.
* Quartz 메타(QRTZ_TRIGGERS)는 EMSAPP 스키마라 EMS EntityManager 로 직접 읽는다.</p>
*
* <p>readOnly 를 풀어야 최초 접근 시 프로퍼티 row 자동 생성(INSERT)이 가능하다.</p>
*/
@Transactional
public LocalDateTime getLastMonitorFireAt() {
try {
String jobName = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_MONITOR_JOB_NAME, DEFAULT_MONITOR_JOB_NAME,
"API 상태 모니터링 Quartz Job 이름 (eapim-admin QRTZ_JOB_DETAILS.JOB_NAME)");
if (StringUtils.isBlank(jobName)) {
jobName = DEFAULT_MONITOR_JOB_NAME;
}
Object fireTime = entityManager.createNativeQuery(
"SELECT MAX(PREV_FIRE_TIME) FROM QRTZ_TRIGGERS WHERE JOB_NAME = :jobName")
.setParameter("jobName", jobName)
.getSingleResult();
if (!(fireTime instanceof Number)) {
return null;
}
long epochMillis = ((Number) fireTime).longValue();
if (epochMillis <= 0) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ApiStatusSupport.ZONE);
} catch (Exception e) {
// 스케줄 메타 조회 실패가 화면 전체를 막지 않도록 한다
log.warn("API 상태 모니터링 실행 시각 조회 실패", e);
return null;
}
}
/**
* 상태/이력 조회 기간(일). PTL_PROPERTY({@code Portal} / {@code djb.apistatus.window-days})로
* 조절하며 1~365 로 clamp 한다. 값이 이상하면 기본 90일.
*/
@Transactional
public int getWindowDays() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_WINDOW_DAYS,
String.valueOf(ApiStatusSupport.DEFAULT_WINDOW_DAYS),
"API Status 조회 기간(일). 가동률 바·이슈 인덱스·날짜 선택 범위에 적용 (1~365)");
int days = Integer.parseInt(StringUtils.trimToEmpty(value));
return Math.max(MIN_WINDOW_DAYS, Math.min(MAX_WINDOW_DAYS, days));
} catch (Exception e) {
log.warn("조회 기간 프로퍼티 해석 실패 - 기본값 {}일 사용", ApiStatusSupport.DEFAULT_WINDOW_DAYS, e);
return ApiStatusSupport.DEFAULT_WINDOW_DAYS;
}
}
/**
* OPEN API 목록(/apis) 카드에 현재 상태 태그를 노출할지 여부.
* PTL_PROPERTY({@code Portal} / {@code djb.apistatus.api-list-status-badge}) 로 켜고 끈다.
*
* <p>{@code true/Y/1} 이면 노출, {@code false/N/0} 이면 숨김. 값이 비었거나 이상하면 기본 노출.</p>
*/
@Transactional
public boolean isApiListStatusBadgeEnabled() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_API_LIST_STATUS_BADGE,
String.valueOf(DEFAULT_API_LIST_STATUS_BADGE),
"OPEN API 목록 카드에 현재 상태(정상/점검/지연/장애) 태그 노출 여부 (true/false)");
String normalized = StringUtils.trimToEmpty(value);
if (StringUtils.equalsAnyIgnoreCase(normalized, "false", "n", "0")) {
return false;
}
if (StringUtils.equalsAnyIgnoreCase(normalized, "true", "y", "1")) {
return true;
}
return DEFAULT_API_LIST_STATUS_BADGE;
} catch (Exception e) {
log.warn("API 목록 상태 태그 노출 프로퍼티 조회 실패 - 기본값 {} 사용", DEFAULT_API_LIST_STATUS_BADGE, e);
return DEFAULT_API_LIST_STATUS_BADGE;
}
}
/**
* API 상태가 마지막으로 <b>변경</b>된 시각 (AGWAPP.API_STATUS 최근 upsert).
* 실행 시각(fire)과 달리 상태 변화가 있을 때만 갱신된다.
*/
public LocalDateTime getLastStatusChangedAt() {
try {
return gwApiStatusRepository.findLastModifiedDate().orElse(null);
} catch (Exception e) {
log.warn("API 상태 변경 시각 조회 실패", e);
return null;
}
}
}
@@ -1,159 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 전체 이슈 이력 페이지 조회 (ADR-F16/F17). 필터는 날짜와 API 두 축만 지원한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusIssueHistoryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/**
* P9 - 90일 인덱스바용 일자별 이슈 집계. 이슈가 여러 날에 걸치면 걸친 날짜 모두에 집계한다.
*/
public List<IssueDateEntryDTO> getIssueDates(int days, String apiId, String kind) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
LocalDateTime windowStart = from.atStartOfDay();
LocalDateTime windowEnd = today.plusDays(1).atStartOfDay();
List<DjbApistatusIncident> incidents = findOverlapping(windowStart, windowEnd, apiId, kind);
Map<LocalDate, IssueDateEntryDTO> byDate = new LinkedHashMap<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
IssueDateEntryDTO entry = new IssueDateEntryDTO();
entry.setDate(date);
byDate.put(date, entry);
}
for (DjbApistatusIncident incident : incidents) {
if (incident.getStartedAt() == null) {
continue;
}
LocalDate start = incident.getStartedAt().toLocalDate();
LocalDateTime effectiveEnd = incident.getEndAt() == null ? now : incident.getEndAt();
LocalDate end = effectiveEnd.toLocalDate();
LocalDate cursor = start.isBefore(from) ? from : start;
LocalDate last = end.isAfter(today) ? today : end;
while (!cursor.isAfter(last)) {
IssueDateEntryDTO entry = byDate.get(cursor);
if (entry != null) {
tally(entry, incident);
}
cursor = cursor.plusDays(1);
}
}
return new ArrayList<>(byDate.values());
}
/**
* 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
*/
private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
IncidentKind kind = incident.getKind();
if (kind == null) {
return;
}
switch (kind) {
case MAINTENANCE: entry.setMntCount(entry.getMntCount() + 1); break;
case DELAY: entry.setDlyCount(entry.getDlyCount() + 1); break;
default: entry.setIncCount(entry.getIncCount() + 1); break;
}
if (!entry.getKinds().contains(kind.name())) {
entry.getKinds().add(kind.name());
}
}
/**
* P10 - 날짜/API/유형 필터 이슈 목록. 날짜 미지정 시 90일 전체.
*/
public Page<PastIssueCardDTO> getIssues(LocalDate date, String apiId, String kind, Pageable pageable) {
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime from;
LocalDateTime to;
if (date == null) {
LocalDate today = now.toLocalDate();
from = today.minusDays(catalogService.getWindowDays() - 1L).atStartOfDay();
to = today.plusDays(1).atStartOfDay();
} else {
from = date.atStartOfDay();
to = date.plusDays(1).atStartOfDay();
}
List<DjbApistatusIncident> incidents = findOverlapping(from, to, apiId, kind);
if (incidents.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int offset = (int) pageable.getOffset();
if (offset >= incidents.size()) {
return new PageImpl<>(Collections.emptyList(), pageable, incidents.size());
}
int end = Math.min(offset + pageable.getPageSize(), incidents.size());
List<PastIssueCardDTO> content = assembler.toPastIssueCards(incidents.subList(offset, end));
return new PageImpl<>(content, pageable, incidents.size());
}
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
return incidents;
}
return incidents.stream().filter(filter).collect(Collectors.toList());
}
/**
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
*/
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null;
}
try {
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -1,59 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* API Status 메인 화면 조회 (진행 중 장애 / 점검 사항 / 지난 이슈 / 이슈 상세).
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
return assembler.toActiveIncidents(incidents, now);
}
/** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
return assembler.toMaintenanceCards(incidents);
}
/** P6 - 종결된 이슈 최근 N건 */
public List<PastIssueCardDTO> getRecentClosedIssues(int size) {
int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit))
.getContent();
return assembler.toPastIssueCards(incidents);
}
/** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId)
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
}
}
@@ -1,132 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* API Status 조회 서비스 공통 상수/헬퍼.
*/
public final class ApiStatusSupport {
/** 일자 경계 기준 timezone (OQ-10) */
public static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
/** 90일 가동률/이력 인덱스 기본 조회 일수 */
public static final int DEFAULT_WINDOW_DAYS = 90;
/** 장애 종결 상태 */
public static final List<IncidentState> CLOSED_STATES =
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
public static final String STATUS_NORMAL = "NORMAL";
public static final String STATUS_DEGRADED = "DEGRADED";
public static final String STATUS_OUTAGE = "OUTAGE";
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
private ApiStatusSupport() {
}
public static LocalDateTime now() {
return LocalDateTime.now(ZONE);
}
/** 지연 건인지 여부 (장애와 구분) */
public static boolean isDelay(DjbApistatusIncident incident) {
return incident != null && incident.getKind() == IncidentKind.DELAY;
}
/** 이슈 종류에 대응하는 현재 상태 코드 */
public static String statusOf(IncidentKind kind) {
if (kind == null) {
return STATUS_NORMAL;
}
switch (kind) {
case INCIDENT: return STATUS_OUTAGE;
case DELAY: return STATUS_DEGRADED;
case MAINTENANCE: return STATUS_MAINTENANCE;
default: return STATUS_NORMAL;
}
}
/**
* 상태 심각도 순위 (낮을수록 심각). 정렬·상태 병합에 함께 쓴다.
*/
public static int statusRank(String status) {
if (status == null) {
return 9;
}
switch (status) {
case STATUS_OUTAGE: return 0;
case STATUS_MAINTENANCE: return 1;
case STATUS_DEGRADED: return 2;
default: return 3;
}
}
public static String stateLabel(IncidentState state) {
if (state == null) {
return null;
}
switch (state) {
case INVESTIGATING: return "발생";
case IDENTIFIED: return "원인 확인";
case MONITORING: return "모니터링";
case RESOLVED: return "해소";
case CANCELED: return "취소";
default: return state.name();
}
}
public static String statusLabel(String status) {
if (status == null) {
return null;
}
switch (status) {
case STATUS_NORMAL: return "정상";
case STATUS_DEGRADED: return "지연";
case STATUS_OUTAGE: return "장애";
case STATUS_MAINTENANCE: return "점검";
default: return status;
}
}
/**
* 두 시각 사이의 분. 종료가 없거나 역순이면 0.
*/
public static long minutesBetween(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)) {
return 0L;
}
return Duration.between(from, to).toMinutes();
}
/**
* "N분 전" 형태의 상대 시간 표기.
*/
public static String relativeTime(LocalDateTime past, LocalDateTime now) {
if (past == null || now == null) {
return null;
}
long minutes = Duration.between(past, now).toMinutes();
if (minutes < 1) {
return "방금 전";
}
if (minutes < 60) {
return minutes + "분 전";
}
long hours = minutes / 60;
if (hours < 24) {
long restMinutes = minutes % 60;
return restMinutes == 0 ? hours + "시간 전" : hours + "시간 " + restMinutes + "분 전";
}
return (hours / 24) + "일 전";
}
}
@@ -1,236 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 90일 가동률 집계. 일별 캐시 테이블(DJB_APISTATUS_DAILY_STAT) 없이 이슈 구간을 그때그때 합산한다.
*
* <p>이슈 건수가 연 1천 건 미만이므로 90일 구간을 메모리에서 합산해도 부담이 없다.
* 같은 시간대에 겹치는 이슈는 구간 합집합으로 계산해 중복 차감을 막는다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
/** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) {
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDate today = now.toLocalDate();
LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) {
LocalDate date = from.plusDays(offset);
result.add(buildDailyStat(date, incidents, now));
}
return result;
}
/**
* API 별 90일 가동률. 장애(INCIDENT) 구간만 차감한다.
*/
public Map<String, Double> getApiUptimeRatios(Set<String> apiIds, int days) {
if (apiIds == null || apiIds.isEmpty()) {
return Collections.emptyMap();
}
int windowDays = days <= 0 ? ApiStatusSupport.DEFAULT_WINDOW_DAYS : Math.min(days, 365);
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
if (!incidents.isEmpty()) {
Map<Long, DjbApistatusIncident> byId = incidents.stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
for (DjbApistatusIncidentApi api :
incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(byId.keySet())) {
if (!apiIds.contains(api.getApiId())) {
continue;
}
DjbApistatusIncident incident = byId.get(api.getIncidentId());
LocalDateTime start = max(incident.getStartedAt(), windowStart);
// 개별 API 복구 시각이 있으면 그 시점까지만 장애로 본다
LocalDateTime end = min(firstNonNull(api.getRecoveredAt(), incident.getEndAt(), now), now);
long minutes = ApiStatusSupport.minutesBetween(start, end);
if (minutes <= 0) {
continue;
}
intervalsByApi.computeIfAbsent(api.getApiId(), key -> new ArrayList<>())
.add(new long[]{toEpochMinute(start), toEpochMinute(end)});
}
}
long totalMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(windowStart, now));
Map<String, Double> ratios = new HashMap<>();
for (String apiId : apiIds) {
long down = unionMinutes(intervalsByApi.get(apiId));
double ratio = (double) (totalMinutes - Math.min(down, totalMinutes)) / totalMinutes;
ratios.put(apiId, round4(ratio));
}
return ratios;
}
private DailyStatDTO buildDailyStat(LocalDate date, List<DjbApistatusIncident> incidents, LocalDateTime now) {
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = min(date.plusDays(1).atStartOfDay(), now);
DailyStatDTO dto = new DailyStatDTO();
dto.setStatDate(date);
if (!dayEnd.isAfter(dayStart)) {
// 아직 시작되지 않은 날짜 (오늘 자정 직후 등)
dto.setUptimeRatio(1.0d);
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
return dto;
}
// 가동률은 장애+지연을 합쳐 차감하므로 합집합 계산용 리스트를 따로 둔다.
// (장애와 지연을 나눠 union 한 뒤 더하면 겹치는 구간이 이중 차감된다)
List<long[]> incidentIntervals = new ArrayList<>();
List<long[]> outageIntervals = new ArrayList<>();
List<long[]> delayIntervals = new ArrayList<>();
List<long[]> maintenanceIntervals = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DjbApistatusIncident incident : incidents) {
LocalDateTime start = max(incident.getStartedAt(), dayStart);
LocalDateTime end = min(firstNonNull(incident.getEndAt(), now), dayEnd);
if (!end.isAfter(start)) {
continue;
}
long[] interval = new long[]{toEpochMinute(start), toEpochMinute(end)};
if (incident.getKind() == IncidentKind.MAINTENANCE) {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
if (incident.getKind() == IncidentKind.DELAY) {
delayIntervals.add(interval);
} else {
outageIntervals.add(interval);
}
}
if (seen.add(incident.getIncidentId())) {
dto.getIssues().add(new DailyStatDTO.IssueRefDTO(incident.getIncidentId(),
incident.getKind() == null ? null : incident.getKind().name(),
incident.getTitle()));
}
}
long dayMinutes = Math.max(1L, ApiStatusSupport.minutesBetween(dayStart, dayEnd));
long incidentMinutes = unionMinutes(incidentIntervals);
long outageMinutes = unionMinutes(outageIntervals);
long delayMinutes = unionMinutes(delayIntervals);
long maintenanceMinutes = unionMinutes(maintenanceIntervals);
dto.setIncidentMinutes(incidentMinutes);
dto.setDelayMinutes(delayMinutes);
dto.setMaintenanceMinutes(maintenanceMinutes);
long downMinutes = Math.min(dayMinutes, incidentMinutes + maintenanceMinutes);
dto.setUptimeRatio(round4((double) (dayMinutes - downMinutes) / dayMinutes));
// 색상은 심각한 순으로 하나만 고른다 (장애 > 점검 > 지연)
if (outageMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_OUTAGE);
} else if (maintenanceMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_MAINTENANCE);
} else if (delayMinutes > 0) {
dto.setStatus(ApiStatusSupport.STATUS_DEGRADED);
} else {
dto.setStatus(ApiStatusSupport.STATUS_NORMAL);
}
return dto;
}
/**
* 겹치는 구간을 합쳐 총 분을 구한다.
*/
private long unionMinutes(List<long[]> intervals) {
if (intervals == null || intervals.isEmpty()) {
return 0L;
}
intervals.sort((left, right) -> Long.compare(left[0], right[0]));
long total = 0L;
long currentStart = intervals.get(0)[0];
long currentEnd = intervals.get(0)[1];
for (int index = 1; index < intervals.size(); index++) {
long[] interval = intervals.get(index);
if (interval[0] > currentEnd) {
total += currentEnd - currentStart;
currentStart = interval[0];
currentEnd = interval[1];
} else if (interval[1] > currentEnd) {
currentEnd = interval[1];
}
}
total += currentEnd - currentStart;
return total;
}
private long toEpochMinute(LocalDateTime dateTime) {
return dateTime.atZone(ApiStatusSupport.ZONE).toEpochSecond() / 60L;
}
private LocalDateTime max(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isAfter(right) ? left : right;
}
private LocalDateTime min(LocalDateTime left, LocalDateTime right) {
if (left == null) {
return right;
}
return left.isBefore(right) ? left : right;
}
private LocalDateTime firstNonNull(LocalDateTime... candidates) {
for (LocalDateTime candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
}
private double round4(double value) {
double clamped = Math.max(0.0d, Math.min(1.0d, value));
return Math.round(clamped * 10000.0d) / 10000.0d;
}
}
@@ -1,94 +0,0 @@
package com.eactive.apim.portal.djb.apistatus.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 로그인 사용자가 이용 중인 API 의 현재 상태 (P4).
*
* <p>대상 API 는 소속 기관이 발급받은 앱(Credential)의 API 목록(ptl_credential_api)이다.
* 상태 판정은 {@link ApiCurrentStatusService} 에 위임해 API 상세 화면과 기준을 맞춘다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MyApiStatusQueryService {
private final CredentialRepository credentialRepository;
private final ApiCurrentStatusService apiCurrentStatusService;
private final ApiStatusUptimeService uptimeService;
private final ApiStatusCatalogService catalogService;
public List<MyApiStatusDTO> getMyApiStatuses() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null || StringUtils.isBlank(user.getPortalOrg().getId())) {
return Collections.emptyList();
}
Map<String, String> myApis = collectMyApis(user.getPortalOrg().getId());
if (myApis.isEmpty()) {
return Collections.emptyList();
}
Map<String, ApiCurrentStatusDTO> statuses = apiCurrentStatusService.resolveStatuses(myApis.keySet());
Map<String, Double> uptimes =
uptimeService.getApiUptimeRatios(myApis.keySet(), catalogService.getWindowDays());
List<MyApiStatusDTO> result = new ArrayList<>();
for (Map.Entry<String, String> entry : myApis.entrySet()) {
String apiId = entry.getKey();
ApiCurrentStatusDTO status = statuses.get(apiId);
MyApiStatusDTO dto = new MyApiStatusDTO();
dto.setApiId(apiId);
// 기관이 발급받은 앱 기준 목록이므로 API 명은 credential 쪽 값을 그대로 쓴다
dto.setApiName(entry.getValue());
dto.setCurrentStatus(status == null ? ApiStatusSupport.STATUS_NORMAL : status.getCurrentStatus());
dto.setCurrentStatusLabel(ApiStatusSupport.statusLabel(dto.getCurrentStatus()));
dto.setActiveIncidentId(status == null ? null : status.getActiveIncidentId());
dto.setLastIncidentAt(status == null ? null : status.getLastIncidentAt());
dto.setUptime90d(uptimes.getOrDefault(apiId, 1.0d));
result.add(dto);
}
result.sort(Comparator
.comparingInt((MyApiStatusDTO dto) -> ApiStatusSupport.statusRank(dto.getCurrentStatus()))
.thenComparing(MyApiStatusDTO::getApiName, Comparator.nullsLast(Comparator.naturalOrder())));
return result;
}
/**
* 기관이 보유한 앱들의 API 목록 (중복 제거, API 명 기준 정렬)
*/
private Map<String, String> collectMyApis(String orgId) {
Map<String, String> myApis = new TreeMap<>();
for (Credential credential : credentialRepository.findAllByOrgid(orgId)) {
if (credential.getApiList() == null) {
continue;
}
for (ApiSpecInfo api : credential.getApiList()) {
if (StringUtils.isBlank(api.getApiId())) {
continue;
}
myApis.put(api.getApiId(), StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()));
}
}
return myApis;
}
}
+1 -8
View File
@@ -2,7 +2,7 @@ server:
servlet: servlet:
context-path: / context-path: /
session: session:
# 세션 타임아웃 10분 고정 # 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다. # WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다. # CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
timeout: 10m timeout: 10m
@@ -318,13 +318,6 @@ page:
api_testbed: api_testbed:
name: "테스트베드" name: "테스트베드"
path: "/apis/detail/testbed" path: "/apis/detail/testbed"
apistatus:
name: "API Status"
path: "/apistatus"
children:
issues:
name: "전체 이슈 이력"
path: "/apistatus/issues"
community: community:
name: 고객지원 name: 고객지원
path: "#" path: "#"
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

@@ -1,457 +0,0 @@
(function () {
'use strict';
const page = document.getElementById('issueHistoryPage');
if (!page) return;
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
const PAGE_SIZE = 10;
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
const today = page.getAttribute('data-today') || '';
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
const minDate = page.getAttribute('data-min-date') || '';
// 진입 시 날짜 미지정 = 조회 기간 전체
const state = {
date: page.getAttribute('data-selected-date') || '',
apiId: page.getAttribute('data-selected-api') || '',
kind: page.getAttribute('data-selected-kind') || '',
page: 0
};
const indexBar = document.getElementById('issueIndexBar');
const listEl = document.getElementById('issueList');
const pagerEl = document.getElementById('issuePager');
const totalCountEl = document.getElementById('issueTotalCount');
const selectedDateLabel = document.getElementById('selectedDateLabel');
const dateField = document.getElementById('dateField');
const dateInput = document.getElementById('filterDateInput');
const dateClearBtn = document.getElementById('filterDateClear');
const kindSelect = document.getElementById('filterKindSelect');
const apiCombo = document.getElementById('apiCombo');
const apiInput = document.getElementById('filterApiInput');
const apiClearBtn = document.getElementById('filterApiClear');
const apiListEl = document.getElementById('filterApiList');
const unlistedBanner = document.getElementById('unlistedApiBanner');
const unlistedName = document.getElementById('unlistedApiName');
let apiOptions = [];
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
});
}
function parseDateTime(value) {
if (!value) return null;
const parsed = new Date(String(value).replace(' ', 'T'));
return isNaN(parsed.getTime()) ? null : parsed;
}
function pad(value) {
return value < 10 ? '0' + value : String(value);
}
function formatDate(value) {
const date = parseDateTime(value);
if (!date) return '-';
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function formatDateTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatDuration(minutes) {
if (minutes == null) return '';
if (minutes < 60) return minutes + '분';
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
}
function buildQuery(params) {
const query = [];
Object.keys(params).forEach(function (key) {
if (params[key] !== '' && params[key] != null) {
query.push(key + '=' + encodeURIComponent(params[key]));
}
});
return query.length ? '?' + query.join('&') : '';
}
function syncBrowserUrl() {
if (!window.history || !window.history.replaceState) return;
window.history.replaceState(null, '',
base + '/issues' + buildQuery({ date: state.date, apiId: state.apiId, kind: state.kind }));
}
/** 필터 변경 후 목록/인덱스 재조회 */
function applyFilters(reloadIndex) {
state.page = 0;
syncBrowserUrl();
renderIndexBarSelection();
if (reloadIndex) loadIndexBar();
loadIssues();
}
// ---------------- 90일 인덱스바 ----------------
function renderIndexBar(entries) {
if (!entries || !entries.length) {
indexBar.innerHTML = '';
return;
}
indexBar.innerHTML = entries.map(function (entry) {
const hasIncident = entry.incCount > 0;
const hasDelay = entry.dlyCount > 0;
const hasMaintenance = entry.mntCount > 0;
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
let cls = '';
if (kindCount > 1) cls = ' has-mixed';
else if (hasIncident) cls = ' has-incident';
else if (hasDelay) cls = ' has-degraded';
else if (hasMaintenance) cls = ' has-maintenance';
if (state.date && state.date === entry.date) cls += ' is-selected';
const parts = [];
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
return '<button type="button" class="as-index-cell' + cls + '"'
+ ' data-date="' + escapeHtml(entry.date) + '"'
+ ' title="' + escapeHtml(tooltip) + '"></button>';
}).join('');
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
cell.addEventListener('click', function () {
const clicked = cell.getAttribute('data-date');
state.date = state.date === clicked ? '' : clicked;
applyFilters(false);
});
});
}
function renderIndexBarSelection() {
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
cell.classList.toggle('is-selected', state.date === cell.getAttribute('data-date'));
});
selectedDateLabel.textContent = state.date || '전체 기간';
if (dateInput.value !== state.date) {
dateInput.value = state.date;
}
dateField.classList.toggle('has-value', !!state.date);
}
// ---------------- 이슈 카드 ----------------
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
*/
function apiPillsHtml(apis) {
if (!apis || !apis.length) 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 '<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>';
}
function issueCardHtml(issue) {
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
let inner;
if (issue.kind === 'MAINTENANCE') {
inner = '<p class="as-tl-body">' + escapeHtml(issue.summary || '점검 안내') + '</p>';
} else {
const items = (issue.timeline || []).map(function (entry) {
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
+ '</div>';
}).join('');
inner = items
? '<div class="as-tl-block">' + items + '</div>'
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
}
const period = formatDateTime(issue.startedAt)
+ (issue.endAt ? ' ~ ' + formatDateTime(issue.endAt) : ' ~ 진행 중')
+ (issue.durationMinutes != null ? ' (' + formatDuration(issue.durationMinutes) + ')' : '');
return '<article class="as-issue-card ' + kindClass + '">'
+ '<div class="as-issue-meta-row">'
+ ' <span class="as-badge ' + kindClass + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
+ ' <span>' + escapeHtml(period) + '</span>'
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ '</div>'
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis)
+ '</article>';
}
function renderPager(pageData) {
const totalPages = pageData.totalPages || 0;
if (totalPages <= 1) {
pagerEl.innerHTML = '';
return;
}
let html = '';
for (let index = 0; index < totalPages; index++) {
const current = index === pageData.number;
html += '<button type="button" class="as-btn' + (current ? ' is-current' : '') + '"'
+ ' data-page="' + index + '"' + (current ? ' disabled' : '') + '>' + (index + 1) + '</button>';
}
pagerEl.innerHTML = html;
pagerEl.querySelectorAll('button[data-page]').forEach(function (button) {
button.addEventListener('click', function () {
state.page = parseInt(button.getAttribute('data-page'), 10) || 0;
loadIssues();
window.scrollTo({ top: listEl.offsetTop - 80, behavior: 'smooth' });
});
});
}
function loadIssues() {
const query = buildQuery({
date: state.date,
apiId: state.apiId,
kind: state.kind,
page: state.page,
size: PAGE_SIZE
});
fetchJson(base + '/issues/list.json' + query)
.then(function (pageData) {
const content = pageData.content || [];
totalCountEl.textContent = pageData.totalElements != null ? pageData.totalElements : content.length;
listEl.innerHTML = content.length
? content.map(issueCardHtml).join('')
: '<div class="as-empty">조건에 해당하는 이슈가 없습니다.</div>';
renderPager(pageData);
})
.catch(function () {
listEl.innerHTML = '<div class="as-empty">이슈 목록을 불러올 수 없습니다.</div>';
pagerEl.innerHTML = '';
});
}
// ---------------- API 필터 (라이브서치 콤보박스) ----------------
/** 옵션 = 오픈 API 목록과 동일 기준(그룹 편성 + 사용자 공개). 표시는 API 명만. */
function loadApiOptions() {
return fetchJson(base + '/apis.json')
.then(function (apis) {
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
renderApiInput();
})
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
}
function findApiOption(apiId) {
if (!apiId) return null;
for (let index = 0; index < apiOptions.length; index++) {
if (apiOptions[index].apiId === apiId) return apiOptions[index];
}
return null;
}
/** 표시 형태: "[그룹명] API명" (그룹 없으면 API명만) */
function optionLabel(api) {
if (!api) return '';
return api.groupName ? '[' + api.groupName + '] ' + api.apiName : api.apiName;
}
function renderApiInput() {
const selected = findApiOption(state.apiId);
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
apiCombo.classList.toggle('has-value', !!state.apiId);
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
if (unlisted) {
unlistedName.textContent = urlApiName || state.apiId;
}
unlistedBanner.style.display = unlisted ? '' : 'none';
}
function closeApiList() {
apiListEl.hidden = true;
apiInput.setAttribute('aria-expanded', 'false');
}
function openApiList(keyword) {
const query = (keyword || '').trim().toLowerCase();
const matched = apiOptions.filter(function (api) {
if (!query) return true;
return optionLabel(api).toLowerCase().indexOf(query) >= 0;
});
if (!matched.length) {
apiListEl.innerHTML = '<li class="as-combo-empty">검색 결과가 없습니다</li>';
} else {
const items = matched.slice(0, API_LIST_LIMIT).map(function (api) {
return '<li role="option" class="as-combo-item" data-api-id="' + escapeHtml(api.apiId) + '">'
+ escapeHtml(optionLabel(api)) + '</li>';
});
if (matched.length > API_LIST_LIMIT) {
items.push('<li class="as-combo-empty">이하 '
+ (matched.length - API_LIST_LIMIT) + '건은 검색어를 입력해 좁혀주세요</li>');
}
apiListEl.innerHTML = '<li role="option" class="as-combo-item" data-api-id="">전체 API</li>'
+ items.join('');
}
apiListEl.hidden = false;
apiInput.setAttribute('aria-expanded', 'true');
}
function selectApi(apiId) {
state.apiId = apiId || '';
renderApiInput();
closeApiList();
applyFilters(true);
}
function loadIndexBar() {
fetchJson(base + '/issues/dates.json'
+ buildQuery({ days: windowDays, apiId: state.apiId, kind: state.kind }))
.then(function (entries) {
renderIndexBar(entries);
renderIndexBarSelection();
})
.catch(function () { indexBar.innerHTML = ''; });
}
// ---------------- 필터 이벤트 ----------------
dateInput.addEventListener('change', function () {
const value = dateInput.value;
// 조회 기간(90일) 밖 날짜는 되돌린다
if (value && ((minDate && value < minDate) || (today && value > today))) {
dateInput.value = state.date;
return;
}
state.date = value;
applyFilters(false);
});
dateClearBtn.addEventListener('click', function () {
state.date = '';
dateInput.value = '';
applyFilters(false);
});
kindSelect.addEventListener('change', function () {
state.kind = kindSelect.value;
applyFilters(true);
});
// readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
// 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
function unlockApiInput() {
if (apiInput.hasAttribute('readonly')) {
apiInput.removeAttribute('readonly');
}
}
apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
apiInput.addEventListener('mousedown', unlockApiInput);
apiInput.addEventListener('focus', function () {
unlockApiInput();
openApiList('');
});
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
apiInput.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
renderApiInput();
closeApiList();
} else if (event.key === 'Enter') {
event.preventDefault();
const first = apiListEl.querySelector('.as-combo-item');
if (first) selectApi(first.getAttribute('data-api-id'));
}
});
apiListEl.addEventListener('mousedown', function (event) {
const item = event.target.closest('.as-combo-item');
if (!item) return;
event.preventDefault();
selectApi(item.getAttribute('data-api-id'));
});
apiClearBtn.addEventListener('click', function () {
apiInput.value = '';
selectApi('');
});
document.addEventListener('click', function (event) {
if (!apiCombo.contains(event.target)) {
renderApiInput();
closeApiList();
}
});
document.getElementById('filterResetBtn').addEventListener('click', function () {
state.date = '';
state.apiId = '';
state.kind = '';
dateInput.value = '';
kindSelect.value = '';
renderApiInput();
closeApiList();
applyFilters(true);
});
// ---------------- 초기 로딩 ----------------
dateInput.value = state.date;
kindSelect.value = state.kind;
renderIndexBarSelection();
loadIndexBar();
// 영향 API 태그의 링크 여부 판단에 오픈 API 목록이 필요하므로 옵션을 먼저 받는다
loadApiOptions().then(loadIssues);
})();
@@ -1,357 +0,0 @@
(function () {
'use strict';
const page = document.getElementById('apiStatusPage');
if (!page) return;
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
const authenticated = page.classList.contains('is-authenticated');
const base = page.getAttribute('data-base') || '/apistatus';
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
const ACTIVE_INCIDENT_LIMIT = 1;
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
});
}
/** "2026-07-30T09:15:00" → Date. 서버는 서버 timezone(KST) 기준 LocalDateTime 을 보낸다. */
function parseDateTime(value) {
if (!value) return null;
const parsed = new Date(String(value).replace(' ', 'T'));
return isNaN(parsed.getTime()) ? null : parsed;
}
function pad(value) {
return value < 10 ? '0' + value : String(value);
}
function formatDate(value) {
const date = parseDateTime(value);
if (!date) return '-';
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}
function formatDateTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatTime(value) {
const date = parseDateTime(value);
if (!date) return '-';
return pad(date.getHours()) + ':' + pad(date.getMinutes());
}
function formatDuration(minutes) {
if (minutes == null) return '';
if (minutes < 60) return minutes + '분';
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
}
function formatPercent(ratio) {
if (ratio == null) return '-';
return (ratio * 100).toFixed(2);
}
function issuesHref(params) {
const query = [];
if (params.date) query.push('date=' + encodeURIComponent(params.date));
if (params.apiId) query.push('apiId=' + encodeURIComponent(params.apiId));
if (params.apiName) query.push('apiName=' + encodeURIComponent(params.apiName));
return base + '/issues' + (query.length ? '?' + query.join('&') : '');
}
/**
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
*/
function apiPillsHtml(apis, label) {
if (!apis || !apis.length) 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 '<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>';
}
// ---------------- ❶ 진행 중 장애 ----------------
function renderActiveIncidents(incidents) {
const container = document.getElementById('activeIncidents');
if (!incidents || !incidents.length) {
container.innerHTML = '<section class="as-card">'
+ '<div class="as-card-head"><div><h2>현재 진행 중인 장애가 없습니다</h2>'
+ '<p class="as-desc">모든 API 가 정상 동작 중입니다.</p></div>'
+ '<span class="as-badge is-normal">정상</span></div></section>';
return;
}
const visible = incidents.slice(0, ACTIVE_INCIDENT_LIMIT);
const hidden = incidents.length - visible.length;
const cards = visible.map(function (incident) {
const timeline = (incident.recentTimeline || []).map(function (entry) {
const who = entry.authorType === 'SYSTEM' ? 'SYSTEM' : '운영자';
const whoClass = entry.authorType === 'SYSTEM' ? ' is-system' : '';
return '<div class="as-tl-row">'
+ '<span class="as-tl-time">' + escapeHtml(formatTime(entry.eventAt)) + '</span>'
+ '<span class="as-tl-who' + whoClass + '">' + escapeHtml(who) + '</span>'
+ '<span>' + escapeHtml(entry.body) + '</span>'
+ '</div>';
}).join('');
return '<section class="as-card as-alert-card">'
+ '<div class="as-alert-head">'
+ ' <div><span class="as-badge is-incident">장애</span>'
+ (incident.stateLabel ? ' <span class="as-badge is-muted">' + escapeHtml(incident.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <div class="as-meta">시작 <strong>' + escapeHtml(formatDateTime(incident.startedAt)) + '</strong>'
+ ' · 경과 <strong>' + escapeHtml(formatDuration(incident.elapsedMinutes)) + '</strong></div>'
+ '</div>'
+ '<h2 class="as-alert-title">' + escapeHtml(incident.title) + '</h2>'
+ (incident.summary ? '<p class="as-meta">' + escapeHtml(incident.summary) + '</p>' : '')
+ apiPillsHtml(incident.apis, '영향 API:')
+ (timeline ? '<div class="as-alert-timeline">' + timeline + '</div>' : '')
+ '</section>';
}).join('');
const overflow = hidden > 0
? '<div class="as-alert-overflow">'
+ '<span>진행 중인 장애가 ' + escapeHtml(String(hidden)) + '건 더 있습니다.</span>'
+ '<a href="' + issuesHref({}) + '">전체 이슈 이력에서 보기 →</a>'
+ '</div>'
: '';
container.innerHTML = cards + overflow;
}
// ---------------- ❷ 90일 서비스 상태 ----------------
function renderUptime(stats) {
const chart = document.getElementById('uptimeChart');
const fromLabel = document.getElementById('uptimeFromLabel');
if (!stats || !stats.length) {
chart.innerHTML = '';
return;
}
const STATUS_LABEL = { OUTAGE: '장애', MAINTENANCE: '점검', DEGRADED: '지연' };
chart.innerHTML = stats.map(function (stat) {
const statusClass = stat.status === 'OUTAGE' ? ' is-outage'
: stat.status === 'MAINTENANCE' ? ' is-maintenance'
: stat.status === 'DEGRADED' ? ' is-degraded' : '';
const titles = (stat.issues || []).map(function (issue) {
return (KIND_LABEL[issue.kind] || '') + ' ' + issue.title;
});
const tooltip = stat.statDate + ' · ' + (STATUS_LABEL[stat.status] || '정상')
+ (titles.length ? '\n' + titles.join('\n') : '');
return '<button type="button" class="as-bar' + statusClass + '"'
+ ' data-date="' + escapeHtml(stat.statDate) + '"'
+ ' title="' + escapeHtml(tooltip) + '"></button>';
}).join('');
if (fromLabel) fromLabel.textContent = stats[0].statDate;
chart.querySelectorAll('.as-bar').forEach(function (bar) {
bar.addEventListener('click', function () {
window.location.href = issuesHref({ date: bar.getAttribute('data-date') });
});
});
}
// ---------------- ❸ My APIs ----------------
function renderMyApis(apis) {
const card = document.getElementById('myApisCard');
if (!card) return;
if (!apis || !apis.length) {
card.style.display = 'none';
return;
}
const badgeClass = {
OUTAGE: 'is-outage',
DEGRADED: 'is-degraded',
MAINTENANCE: 'is-maintenance',
NORMAL: 'is-normal'
};
document.getElementById('myApisCount').textContent = apis.length + '건';
document.getElementById('myApisBody').innerHTML = apis.map(function (api) {
const cls = badgeClass[api.currentStatus] || 'is-muted';
return '<tr data-api-id="' + escapeHtml(api.apiId) + '" data-api-name="' + escapeHtml(api.apiName) + '">'
+ '<td><span class="as-api-name">' + escapeHtml(api.apiName) + '</span></td>'
+ '<td><span class="as-badge ' + cls + '">' + escapeHtml(api.currentStatusLabel) + '</span></td>'
+ '<td class="as-num">' + formatPercent(api.uptime90d) + '%</td>'
+ '</tr>';
}).join('');
card.style.display = '';
card.querySelectorAll('tbody tr').forEach(function (row) {
row.addEventListener('click', function () {
window.location.href = issuesHref({
apiId: row.getAttribute('data-api-id'),
apiName: row.getAttribute('data-api-name')
});
});
});
}
// ---------------- ❹ 점검 사항 ----------------
function renderMaintenance(cards) {
const list = document.getElementById('maintenanceList');
const count = document.getElementById('maintenanceCount');
if (!cards || !cards.length) {
count.textContent = '0건';
list.innerHTML = '<div class="as-empty">예정된 점검이 없습니다.</div>';
return;
}
const now = new Date();
count.textContent = cards.length + '건';
list.innerHTML = cards.map(function (card) {
const start = parseDateTime(card.startedAt);
const ongoing = start && start <= now;
const phase = ongoing ? '진행중' : '예정';
const range = formatDateTime(card.startedAt)
+ (card.endAt ? ' ~ ' + formatDateTime(card.endAt) : '')
+ (card.durationMinutes != null ? ' (' + formatDuration(card.durationMinutes) + ')' : '');
return '<article class="as-maint-card' + (ongoing ? ' is-ongoing' : '') + '">'
+ '<div class="as-maint-head">'
+ ' <h3 class="as-maint-title">' + escapeHtml(card.title) + '</h3>'
+ ' <span class="as-schedule">' + escapeHtml(phase + ' · ' + range) + '</span>'
+ '</div>'
+ (card.summary ? '<div class="as-maint-body">' + escapeHtml(card.summary) + '</div>' : '')
+ apiPillsHtml(card.impactedApis, '영향 API:')
+ '<div class="as-maint-meta">등록 ' + escapeHtml(formatDateTime(card.registeredAt))
+ (card.lastModifiedAt ? ' (최종 수정 ' + escapeHtml(formatDateTime(card.lastModifiedAt)) + ')' : '')
+ '</div>'
+ '</article>';
}).join('');
}
// ---------------- ❺ 지난 이슈 사항 ----------------
function issueCardHtml(issue) {
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
const kindBadge = kindClass;
let inner;
if (issue.kind === 'MAINTENANCE') {
inner = '<div class="as-tl-block"><div class="as-tl-item state-RESOLVED">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">점검 완료</p>'
+ (issue.summary ? '<p class="as-tl-body">' + escapeHtml(issue.summary) + '</p>' : '')
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(issue.startedAt))
+ ' ~ ' + escapeHtml(formatDateTime(issue.endAt)) + '</p>'
+ '</div></div>';
} else {
const items = (issue.timeline || []).map(function (entry) {
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
+ '<span class="as-dot"></span>'
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
+ '</div>';
}).join('');
inner = items
? '<div class="as-tl-block">' + items + '</div>'
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
}
const duration = issue.durationMinutes != null ? ' · ' + formatDuration(issue.durationMinutes) : '';
return '<div class="as-issue-group">'
+ '<div class="as-date-head">' + escapeHtml(formatDate(issue.startedAt)) + '</div>'
+ '<article class="as-issue-card ' + kindClass + '">'
+ ' <div class="as-issue-meta-row">'
+ ' <span class="as-badge ' + kindBadge + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
+ ' <span>' + escapeHtml(formatDateTime(issue.startedAt) + duration) + '</span>'
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
+ ' </div>'
+ ' <h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
+ inner
+ apiPillsHtml(issue.impactedApis, '영향 API:')
+ '</article>'
+ '</div>';
}
function renderRecentIssues(issues) {
const list = document.getElementById('recentIssueList');
if (!issues || !issues.length) {
list.innerHTML = '<div class="as-empty">종결된 이슈가 없습니다.</div>';
return;
}
list.innerHTML = issues.map(issueCardHtml).join('');
}
function handleError(target, message) {
const element = document.getElementById(target);
if (element) element.innerHTML = '<div class="as-empty">' + escapeHtml(message) + '</div>';
}
// ---------------- 초기 로딩 ----------------
// 영향 API 태그의 링크 여부를 판단해야 하므로 오픈 API 목록을 먼저 받는다
fetchJson(base + '/apis.json')
.then(function (apis) {
(apis || []).forEach(function (api) {
if (api.apiId) linkableApiIds.add(api.apiId);
});
})
.catch(function () { /* 실패 시 전부 링크 없이 표시된다 */ })
.then(loadSections);
function loadSections() {
fetchJson(base + '/active.json')
.then(renderActiveIncidents)
.catch(function () { handleError('activeIncidents', '진행 중 장애 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/uptime.json?days=' + windowDays)
.then(renderUptime)
.catch(function () { handleError('uptimeChart', '서비스 상태 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/maintenance.json')
.then(renderMaintenance)
.catch(function () { handleError('maintenanceList', '점검 정보를 불러올 수 없습니다.'); });
fetchJson(base + '/recent-issues.json?size=5')
.then(renderRecentIssues)
.catch(function () { handleError('recentIssueList', '지난 이슈를 불러올 수 없습니다.'); });
if (authenticated) {
fetchJson(base + '/my-apis.json')
.then(renderMyApis)
.catch(function () { /* 비인증/권한 없음은 조용히 숨긴다 */ });
}
}
})();
@@ -142,11 +142,7 @@ const customPopups = {
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message)); $('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용) // 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
// type 은 열려 있는 동안만 password 로 전환한다. $('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
// 상주 DOM 에 password 입력이 있으면 브라우저가 페이지의 다른 텍스트 입력을
// 아이디 칸으로 오인해 계정 자동완성을 띄우기 때문 (닫을 때 text 로 복원).
$('#passwordPopupInput').val('').removeClass('error')
.attr('type', 'password').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text(''); $('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용) // 팝업 표시 (modal 구조 사용)
@@ -223,8 +219,8 @@ const customPopups = {
// Body 스크롤 복원 // Body 스크롤 복원
$('body').css('overflow', ''); $('body').css('overflow', '');
// 입력 필드 초기화 (type 도 text 로 복원 — 상주 password 로 남기지 않는다) // 입력 필드 초기화
$('#passwordPopupInput').val('').removeClass('error').attr('type', 'text'); $('#passwordPopupInput').val('').removeClass('error');
$('#passwordPopupError').removeClass('show').text(''); $('#passwordPopupError').removeClass('show').text('');
// 이벤트 리스너 제거 // 이벤트 리스너 제거
@@ -285,20 +285,40 @@
} }
.pulsing { .pulsing {
animation: pulse 2s ease-in-out infinite; transform-origin: 400px 110px;
transform-box: fill-box;
animation: pulseLine 2s ease-in-out infinite;
} }
@keyframes pulseLine {
0%,
100% {
transform: translate(0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translateX(75px) scale(1.1);
/* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
}
}
@keyframes pulse { @keyframes pulse {
0%, 0%,
100% { 100% {
transform: scale(1); transform: translate(0, 0) scale(1);
opacity: 0.9; opacity: 0.9;
} }
50% { 50% {
transform: scale(1.1); transform: translateX(75px) scale(1.1);
opacity: 0.5; /* 하나의 transform에 한 줄로 작성 */
opacity: 0.7;
} }
} }
@@ -183,6 +183,7 @@ textarea.djb-comment-input {
font-family: inherit; font-family: inherit;
outline: none; outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
background-color: #fcfcfc;
&::placeholder { &::placeholder {
color: #7f8a95; color: #7f8a95;
@@ -1010,6 +1010,7 @@ select.form-control {
resize: none; resize: none;
transition: border-color 0.2s; transition: border-color 0.2s;
min-height: 250px; min-height: 250px;
background-color: #fcfcfc;
&::placeholder { &::placeholder {
color: #94A3B8; color: #94A3B8;
@@ -199,7 +199,7 @@
p { p {
margin: 0; margin: 0;
& + p { &+p {
margin-top: $spacing-md; margin-top: $spacing-md;
} }
} }
@@ -240,7 +240,6 @@
.btn, .btn,
.btn-modal-cancel, .btn-modal-cancel,
.btn-modal-confirm { .btn-modal-confirm {
flex: 1;
min-width: 0; min-width: 0;
height: 46px; height: 46px;
font-size: 15px; font-size: 15px;
@@ -330,4 +329,4 @@
font-size: $font-size-base; font-size: $font-size-base;
} }
} }
} }
@@ -158,7 +158,7 @@
// Logo with modern styling // Logo with modern styling
.logo { .logo {
display: block; display: flex;
height: 32px; height: 32px;
vertical-align: middle; vertical-align: middle;
z-index: 10; z-index: 10;
@@ -202,7 +202,6 @@
// Logo link styles // Logo link styles
.logo-link, .logo-link,
.mobile-logo-link,
.drawer-logo-link { .drawer-logo-link {
display: inline-block; display: inline-block;
text-decoration: none; text-decoration: none;
@@ -937,7 +936,7 @@
.sub-menu { .sub-menu {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 17px);
left: 0; left: 0;
min-width: 200px; min-width: 200px;
background: var(--white); background: var(--white);
-1
View File
@@ -72,7 +72,6 @@
@use 'pages/service' as *; @use 'pages/service' as *;
@use 'pages/api-statistics' as *; @use 'pages/api-statistics' as *;
@use 'pages/webhook' as *; @use 'pages/webhook' as *;
@use 'pages/api-status' as *;
// 6. Themes // 6. Themes
@use 'themes/dark' as *; @use 'themes/dark' as *;
@@ -571,7 +571,7 @@
// Result Page Styles (아이디 찾기 결과 페이지) // Result Page Styles (아이디 찾기 결과 페이지)
.account-recovery-result { .account-recovery-result {
width: 100%; width: 100%;
padding: 60px 40px; padding: 35px 0px;
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
text-align: center; text-align: center;
@@ -690,6 +690,44 @@
border-radius: 12px; border-radius: 12px;
padding: 16px 24px; padding: 16px 24px;
&.info-box-highlight {
background: linear-gradient(135deg, rgba(0, 73, 180, 0.06) 0%, rgba(0, 73, 180, 0.02) 100%);
border: 1.5px solid rgba(0, 73, 180, 0.25);
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.08);
display: flex;
align-items: center;
gap: 12px;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
transform: translateY(15px);
opacity: 0;
.highlight-icon {
display: flex;
justify-content: center;
align-items: center;
animation: pulseIcon 2s infinite ease-in-out;
}
.info-text {
font-size: 16px;
line-height: 1.6;
color: #1e293b;
text-align: center;
strong {
color: #0049B4;
font-weight: 700;
font-size: 19px;
}
p {
color: #4685ef;
}
}
}
.info-text { .info-text {
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 15px; font-size: 15px;
@@ -725,6 +763,27 @@
} }
} }
@keyframes fadeInUp {
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulseIcon {
0%,
100% {
transform: scale(1);
filter: drop-shadow(0 0 0px rgba(0, 73, 180, 0));
}
50% {
transform: scale(1.06);
filter: drop-shadow(0 0 6px rgba(0, 73, 180, 0.2));
}
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Mobile Design (sm: 768px breakpoint) // Mobile Design (sm: 768px breakpoint)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -785,7 +844,7 @@
.auth-input-group { .auth-input-group {
.auth-timer { .auth-timer {
font-size: 12px; font-size: 12px;
right: 100px; right: 18px;
} }
} }
@@ -467,27 +467,16 @@
} }
} }
/* 그룹 배지 + 현재 상태 태그를 한 줄에 둔다. &-badge {
기존 그룹 배지가 갖고 있던 절대 위치(top 39 / left 32)를 이 래퍼가 이어받는다 */
&-badges {
position: absolute; position: absolute;
top: 39px; top: 39px;
left: 32px; left: 32px;
right: 32px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&-badge {
background-color: #f2f2f3; background-color: #f2f2f3;
border-radius: 10px; border-radius: 10px;
padding: 2px 20px; padding: 2px 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 0;
span { span {
font-size: 14px; font-size: 14px;
@@ -495,34 +484,9 @@
font-weight: 500; font-weight: 500;
color: #1b4ab7; color: #1b4ab7;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
} }
/* 현재 상태 태그. 색 규격은 API Status 화면(.as-badge)과 동일.
상태를 못 받았을 때(빈 값)는 자리만 차지하지 않도록 숨긴다 */
&-status {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 3px 12px;
border-radius: 10px;
font-size: 12px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 600;
white-space: nowrap;
background: #eef1f5;
color: #4b5563;
&:empty { display: none; }
&.is-outage { background: #feeae7; color: #c8362f; }
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
&.is-degraded { background: #fef3c7; color: #c77800; }
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
}
&-title { &-title {
position: absolute; position: absolute;
top: 86px; top: 86px;
@@ -882,7 +846,7 @@
} }
// API Overview Card (Flat Style) // API Overview Card (Flat Style)
/* "기본 정보" 헤더: 좌측 제목 + 우측 현재 상태/상태 이력 */ /* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
.api-basic-info-header { .api-basic-info-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -891,41 +855,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* 현재 상태 배지 + "상태 이력 확인" 버튼.
색 규격은 API Status 화면(.as-badge)과 동일하게 맞춘다 */
.api-status-inline {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
.api-status-inline__label {
font-size: 13px;
font-weight: 500;
color: #4b5563;
}
.api-status-inline__badge {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 600;
padding: 5px 12px;
border-radius: 999px;
letter-spacing: 0.02em;
white-space: nowrap;
background: #eef1f5;
color: #4b5563;
&.is-outage { background: #feeae7; color: #c8362f; }
&.is-maintenance { background: #e7f0fb; color: #0a66c2; }
&.is-degraded { background: #fef3c7; color: #c77800; }
&.is-normal { background: #e7f6ec; color: #1b8c4a; }
&.is-loading { color: #6b7280; }
&.is-muted { background: #eef1f5; color: #6b7280; }
}
}
/* 하단 중앙 "API 사용 신청" 버튼 */ /* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer { .api-apply-footer {
display: flex; display: flex;
@@ -1,882 +0,0 @@
// ============================================================
// API Status 페이지 (/apistatus, /apistatus/issues)
// 설계: 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 {
// 상단 여백은 fixed 헤더(80px) 아래로 배너를 내리기 위한 값이다
margin: 44px 0 28px;
border-radius: 12px;
background: linear-gradient(110deg, #003080 0%, #0049b4 60%, #066ae5 100%);
color: #fff;
overflow: hidden;
.as-title-banner-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 32px;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
color: #fff;
}
.as-title-meta {
text-align: right;
p { margin: 0; font-weight: 400; }
}
.as-title-meta-main {
font-size: 13px;
color: #fff;
strong { font-weight: 500; }
}
.as-title-meta-sub {
margin-top: 2px;
font-size: 11px;
color: #c9d1dc;
}
.as-title-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: #56e3a6;
box-shadow: 0 0 0 4px rgba(86, 227, 166, 0.25);
}
@media (max-width: 720px) {
.as-title-banner-inner { padding: 24px 20px; }
h1 { font-size: 20px; }
.as-title-meta { text-align: left; }
}
}
.api-status {
// ---- 토큰 (설계서 색상 규격) ----
--as-ok: #1b8c4a;
--as-ok-bg: #e7f6ec;
--as-warn: #c77800;
--as-warn-bg: #fef3c7;
--as-err: #c8362f;
--as-err-bg: #feeae7;
--as-info: #0a66c2;
--as-info-bg: #e7f0fb;
--as-both: #6b21a8;
--as-gray-bg: #eef1f5;
// 이슈 없음 = 정상. API Status 메인 가동률 바와 같은 색을 쓴다
--as-none-bg: var(--as-ok);
--as-border: #e0e6f1;
--as-border-strong: #d1d5db;
--as-text: #020616;
--as-text-2: #252b37;
--as-text-3: #4b5563;
--as-muted: #6b7280;
--as-radius: 12px;
--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;
// 상단 헤더가 fixed 라 브레드크럼 위를 덮는다. 첫 카드가 헤더에 붙지 않도록 띄운다.
&.is-standalone { padding-top: 44px; }
// 제목(h1~h3) 외 본문은 기본 굵기를 쓴다.
// 포털 전역 타이포가 본문을 600 으로 잡고 있어 페이지 범위에서 되돌린다.
font-weight: 400;
p,
span,
li,
a,
td,
th,
label,
button,
input,
select { font-weight: 400; }
b,
strong { font-weight: 500; }
// ---------------- 공통 카드/배지 ----------------
.as-card {
background: #fff;
border: 1px solid var(--as-border);
border-radius: var(--as-radius);
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.as-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 18px;
letter-spacing: -0.01em;
color: var(--as-text);
}
.as-desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--as-muted);
}
}
.as-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 4px 10px;
border-radius: var(--as-pill);
letter-spacing: 0.02em;
white-space: nowrap;
&.is-incident,
&.is-outage { background: var(--as-err-bg); color: var(--as-err); }
&.is-maintenance { background: var(--as-info-bg); color: var(--as-info); }
&.is-degraded { background: var(--as-warn-bg); color: var(--as-warn); }
&.is-normal { background: var(--as-ok-bg); color: var(--as-ok); }
&.is-muted { background: var(--as-gray-bg); color: var(--as-text-3); }
}
.as-section-title {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
margin: 6px 4px 14px;
h2 {
margin: 0;
font-size: 20px;
letter-spacing: -0.01em;
}
.as-desc { font-size: 13px; color: var(--as-muted); }
.as-count {
font-size: 12px;
font-weight: 500;
color: var(--as-muted);
background: #f6f9fb;
padding: 2px 8px;
border-radius: var(--as-pill);
}
}
.as-empty {
color: var(--as-muted);
font-size: 14px;
padding: 26px;
background: #fff;
border: 1px dashed var(--as-border);
border-radius: var(--as-radius);
text-align: center;
}
.as-api-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
font-size: 13px;
color: var(--as-text-3);
.as-api-pill {
background: var(--as-gray-bg);
color: var(--as-text-2);
font-size: 12px;
font-weight: 500;
padding: 4px 10px;
border-radius: var(--as-pill);
}
// 오픈 API 목록에 있는 API — 상세로 이동
a.as-api-pill {
text-decoration: none;
transition: background 0.15s, color 0.15s;
&:hover,
&:focus-visible {
background: var(--as-info-bg);
color: var(--as-info);
}
}
// 목록에 없는 API — 링크 없음, 채도를 낮춰 구분
.as-api-pill.is-unlinked {
background: #f3f5f8;
color: var(--as-muted);
cursor: default;
}
}
// ---------------- 진행 중 장애 카드 ----------------
.as-alert-card {
border-left: 4px solid var(--as-err);
.as-alert-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.as-alert-title {
margin: 8px 0 6px;
font-size: 20px;
color: var(--as-err);
}
.as-meta { font-size: 13px; color: var(--as-text-3); }
.as-alert-timeline {
margin-top: 16px;
border-top: 1px dashed var(--as-border);
padding-top: 14px;
display: grid;
gap: 10px;
}
.as-tl-row {
display: grid;
grid-template-columns: 60px 76px 1fr;
gap: 12px;
font-size: 13px;
.as-tl-time {
color: var(--as-muted);
font-variant-numeric: tabular-nums;
}
.as-tl-who {
font-size: 11px;
font-weight: 500;
text-align: center;
height: -webkit-fit-content;
height: fit-content;
padding: 2px 8px;
border-radius: var(--as-pill);
background: var(--as-gray-bg);
color: var(--as-text-2);
&.is-system { background: var(--as-info-bg); color: var(--as-info); }
}
}
}
// 진행 중 장애가 노출 상한을 넘을 때 안내
.as-alert-overflow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 20px;
border: 1px dashed var(--as-err);
border-radius: var(--as-radius);
background: var(--as-err-bg);
font-size: 13px;
color: var(--as-err);
a { color: var(--as-err); font-weight: 500; text-decoration: none; }
}
// ---------------- 90일 서비스 상태 ----------------
.as-uptime {
.as-bar-chart {
display: flex;
gap: 2px;
align-items: flex-end;
height: 56px;
overflow-x: auto;
}
.as-axis {
display: flex;
justify-content: space-between;
margin-top: 14px;
font-size: 11px;
color: var(--as-muted);
}
}
.as-bar {
flex: 1 0 4px;
height: 100%;
border: 0;
padding: 0;
border-radius: 2px;
background: var(--as-ok);
cursor: pointer;
transition: opacity 0.15s;
&.is-outage { background: var(--as-err); }
&.is-maintenance { background: var(--as-info); }
&.is-degraded { background: var(--as-warn); }
&:hover { opacity: 0.75; }
}
.as-legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-top: 18px;
font-size: 12px;
color: var(--as-muted);
.as-sw {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
&.is-ok { background: var(--as-ok); }
&.is-outage { background: var(--as-err); }
&.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); }
}
}
// ---------------- My APIs ----------------
.as-my-apis {
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td { text-align: left; padding: 12px 14px; }
th {
background: #f6f9fb;
color: var(--as-text-3);
font-size: 12px;
font-weight: 500;
border-bottom: 1px solid var(--as-border);
}
tbody tr {
border-bottom: 1px solid var(--as-border);
cursor: pointer;
&:last-child { border-bottom: 0; }
&:hover { background: #f6f9fb; }
}
.as-api-name { font-weight: 500; color: var(--as-text); }
.as-num { font-variant-numeric: tabular-nums; }
}
// ---------------- 점검 사항 ----------------
.as-maint-list { display: grid; gap: 14px; }
.as-maint-card {
background: #fff;
border: 1px solid var(--as-border);
border-left: 4px solid var(--as-info);
border-radius: var(--as-radius);
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
&.is-ongoing { border-left-color: var(--as-warn); }
.as-maint-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-text); }
.as-schedule {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--as-info);
background: var(--as-info-bg);
padding: 4px 12px;
border-radius: var(--as-pill);
font-variant-numeric: tabular-nums;
}
&.is-ongoing .as-schedule { color: var(--as-warn); background: var(--as-warn-bg); }
.as-maint-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
.as-maint-meta {
text-align: right;
font-size: 12px;
color: var(--as-muted);
font-variant-numeric: tabular-nums;
}
}
// ---------------- 지난 이슈 / 이슈 이력 카드 ----------------
.as-issue-list { display: grid; gap: 24px; }
.as-issue-group {
.as-date-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin: 0 4px 10px;
padding-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--as-text-2);
border-bottom: 1px solid var(--as-border);
}
}
.as-issue-card {
background: #fff;
border: 1px solid var(--as-border);
border-radius: var(--as-radius);
padding: 22px 26px 24px;
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 {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: var(--as-muted);
margin-bottom: 10px;
}
.as-issue-title {
margin: 0 0 18px;
font-size: 19px;
font-weight: 800;
line-height: 1.3;
letter-spacing: -0.01em;
}
&.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); }
}
// 세로 타임라인
.as-tl-block {
position: relative;
padding-left: 24px;
&::before {
content: '';
position: absolute;
left: 6px;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--as-border);
}
}
.as-tl-item {
position: relative;
padding-bottom: 22px;
&:last-child { padding-bottom: 0; }
+ .as-tl-item {
margin-top: 4px;
padding-top: 22px;
border-top: 1px dashed var(--as-border);
}
.as-dot {
position: absolute;
left: -24px;
top: 6px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
background: var(--as-muted);
box-shadow: 0 0 0 1px var(--as-border-strong);
}
&.state-RESOLVED .as-dot { background: var(--as-ok); box-shadow: 0 0 0 1px var(--as-ok); }
&.state-MONITORING .as-dot { background: var(--as-warn); box-shadow: 0 0 0 1px var(--as-warn); }
&.state-IDENTIFIED .as-dot { background: #c95a0f; box-shadow: 0 0 0 1px #c95a0f; }
&.state-INVESTIGATING .as-dot { background: var(--as-warn); box-shadow: 0 0 0 1px var(--as-warn); }
&.state-CANCELED .as-dot { background: var(--as-muted); box-shadow: 0 0 0 1px var(--as-muted); }
.as-tl-label { margin: 0 0 8px; font-size: 14px; font-weight: 500; color: var(--as-text); }
.as-tl-body { margin: 0 0 8px; font-size: 14px; line-height: 1.6; color: var(--as-text); white-space: pre-line; }
.as-tl-ts {
margin: 0;
font-size: 13px;
color: var(--as-muted);
font-variant-numeric: tabular-nums;
}
}
.as-more-link {
display: block;
margin-top: 14px;
text-align: right;
font-size: 13px;
text-decoration: none;
}
// ---------------- 이슈 이력 페이지 ----------------
.as-index-bar {
display: flex;
gap: 2px;
align-items: stretch;
height: 44px;
overflow-x: auto;
}
.as-index-cell {
flex: 1 0 4px;
border: 0;
padding: 0;
border-radius: 2px;
background: var(--as-none-bg); // 이슈 없음 = 정상(메인 가동률 바와 동일)
cursor: pointer;
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; }
}
.as-filter-bar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: #fff;
border: 1px solid var(--as-border);
border-radius: var(--as-radius);
padding: 14px 18px;
font-size: 13px;
.as-filter-field {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.as-filter-label {
font-weight: 500;
color: var(--as-text-2);
white-space: nowrap;
}
.as-filter-right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
color: var(--as-muted);
white-space: nowrap;
}
.as-filter-count b { color: var(--as-text); }
}
.as-input {
height: 34px;
padding: 0 10px;
border: 1px solid var(--as-border-strong);
border-radius: 6px;
font-size: 13px;
color: var(--as-text);
background: #fff;
max-width: 100%;
&:focus {
outline: none;
border-color: var(--as-info);
box-shadow: 0 0 0 2px rgba(10, 102, 194, 0.15);
}
}
// 값 지우기 버튼이 붙는 입력 컨트롤 (날짜 / API)
.as-field-control {
position: relative;
display: inline-flex;
align-items: center;
input { padding-right: 30px; }
// type=search 의 네이티브 지우기 버튼은 자체 버튼과 중복이라 감춘다
input[type='search'] {
-webkit-appearance: none;
appearance: none;
&::-webkit-search-cancel-button,
&::-webkit-search-decoration { -webkit-appearance: none; appearance: none; display: none; }
}
}
.as-field-clear {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
display: none;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--as-gray-bg);
color: var(--as-text-3);
font-size: 14px;
line-height: 1;
cursor: pointer;
&:hover { background: var(--as-border-strong); color: var(--as-text); }
}
.as-filter-field.has-value .as-field-clear { display: block; }
// 날짜 입력은 네이티브 달력 아이콘과 겹치지 않게 여유를 둔다
#filterDateInput { padding-right: 52px; }
// API 라이브서치 콤보박스
.as-combo {
.as-field-control {
min-width: 240px;
flex: 1 1 240px;
}
input { width: 100%; }
.as-combo-list {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
margin: 0;
padding: 4px;
overflow-y: auto;
list-style: none;
background: #fff;
border: 1px solid var(--as-border-strong);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.as-combo-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover { background: var(--as-info-bg); }
}
.as-combo-empty {
padding: 10px;
color: var(--as-muted);
font-size: 12px;
text-align: center;
}
}
// 포탈에 게시되지 않은 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;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
// 필터/페이저 버튼 (전역 버튼 클래스에 의존하지 않는다)
.as-btn {
min-width: 32px;
height: 32px;
padding: 0 12px;
border: 1px solid var(--as-border-strong);
border-radius: 6px;
background: #fff;
color: var(--as-text-2);
font-size: 13px;
line-height: 1;
cursor: pointer;
&:hover { background: #f6f9fb; }
&.is-current {
border-color: var(--as-info);
background: var(--as-info);
color: #fff;
cursor: default;
}
}
// ---------------- 반응형 ----------------
@media (max-width: 960px) {
.as-filter-bar {
gap: 10px;
.as-filter-right { margin-left: 0; width: 100%; justify-content: flex-end; }
}
.as-my-apis {
table { display: block; overflow-x: auto; white-space: nowrap; }
}
}
@media (max-width: 720px) {
gap: 16px;
padding-bottom: 48px;
&.is-standalone { padding-top: 32px; }
.as-card { padding: 18px 16px; }
.as-card-head { margin-bottom: 12px; }
.as-alert-card .as-tl-row { grid-template-columns: 52px 1fr; }
.as-alert-card .as-tl-who { display: none; }
.as-alert-card .as-alert-title { font-size: 18px; }
// 필터: 라벨 + 입력을 한 줄씩 쌓는다
.as-filter-bar {
flex-direction: column;
align-items: stretch;
padding: 14px;
.as-filter-field { flex-direction: column; align-items: flex-start; gap: 6px; }
// 세로 스택에서는 flex-basis 가 높이로 해석되므로 반드시 해제한다
.as-input,
.as-field-control { flex: 0 0 auto; width: 100%; min-width: 0; }
.as-filter-right { justify-content: space-between; }
}
// 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; }
.as-maint-card { padding: 18px 16px; }
.as-uptime .as-bar-chart { height: 44px; }
.as-issue-list { gap: 16px; }
.as-pager button { min-width: 30px; padding: 0 8px; }
}
@media (max-width: 480px) {
.as-section-title h2 { font-size: 18px; }
.as-legend { gap: 8px; font-size: 11px; }
.as-tl-block { padding-left: 18px; }
.as-tl-item .as-dot { left: -18px; }
}
}
@@ -168,16 +168,16 @@
.form-input { .form-input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #dadada; border: 1px solid #DFDFDF;
border-radius: $border-radius-md; border-radius: $border-radius-md;
background-color: $white; background-color: #fcfcfc;
font-size: 14px; font-size: 14px;
color: $text-dark; color: $text-dark;
outline: none; outline: none;
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
&::placeholder { &::placeholder {
color: #dadada; color: #94A3B8;
} }
&:focus { &:focus {
@@ -1130,7 +1130,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
} }
&:disabled { &:disabled {
background-color: #E2E8F0 !important; background-color: #ededed !important;
} }
} }
@@ -2929,7 +2929,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
.s1-title, .s1-title,
.s2-title, .s2-title,
.s3-title { .s3-title {
font-size: 30px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #0d0e11; color: #0d0e11;
line-height: 1; line-height: 1;
@@ -3261,7 +3261,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
padding: 12px 16px; padding: 12px 16px;
} }
.s1-upload-inner > img { .s1-upload-inner>img {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
+14 -760
View File
@@ -423,342 +423,6 @@
// API Search section styles - 검색 섹션 // API Search section styles - 검색 섹션
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/* 원래 있던 검색 영역 스타일 (주석 처리)
.api-search-section {
position: relative;
padding: 0;
//margin-top: -72px;
overflow: hidden;
background: #e9f9ff;
height: 283px;
.search-background {
position: absolute;
inset: 0;
background-image: url('/img/bg_main_intersect.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
pointer-events: none;
}
.search-content-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
gap: 22px;
padding-top: 36px;
}
@include respond-to('sm') {
// Figma 모바일: 캐릭터와 텍스트 가로 배치
flex-direction: row;
flex-wrap: wrap;
gap: 7px;
padding-top: 18px;
justify-content: center;
}
}
.search-character {
width: 139px;
height: 133px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('md') {
width: 108px;
height: 104px;
}
@include respond-to('sm') {
// Figma 모바일: 68x65px
width: 61px;
height: 59px;
}
}
.search-text-content {
text-align: left;
@include respond-to('md') {
text-align: center;
}
@include respond-to('sm') {
// Figma 모바일: 텍스트 왼쪽 정렬
text-align: left;
}
}
.search-title {
font-size: 32px;
line-height: 1.3;
color: #000000;
margin: 0;
@include respond-to('md') {
font-size: 25px;
}
@include respond-to('sm') {
// Figma 모바일: 14px, Bold
font-size: 14px;
line-height: 1.4;
color: #212529;
}
}
.search-input-wrapper {
position: relative;
display: flex;
justify-content: center;
padding: 0px 0 27px;
z-index: 1;
@include respond-to('md') {
padding: 27px 18px 18px;
}
@include respond-to('sm') {
// Figma 모바일: 좌측 24px, 우측 25px 패딩으로 좌측 정렬
justify-content: flex-start;
padding: 0px 25px 11px 24px;
}
}
.search-form {
width: 100%;
max-width: 816px;
@include respond-to('sm') {
// Figma 모바일: 288px 고정 너비
//width: 288px;
//max-width: 320px;
}
}
.search-box {
position: relative;
width: 100%;
height: 72px;
background: #FFFFFF;
border: 5px solid #0049B4;
border-radius: 45px;
display: flex;
align-items: center;
padding: 0 22px;
@include respond-to('md') {
height: 54px;
border: 4px solid #0049B4;
padding: 0 14px;
}
@include respond-to('sm') {
// Figma 모바일: 320x36px, 1px 파란색 테두리
height: 32px;
border: 3px solid #0049B4;
border-radius: 16px;
padding: 0 11px;
}
}
.search-input {
flex: 1;
width: 100%;
height: 100%;
border: none;
outline: none;
background: transparent;
font-family: $font-family-primary;
font-size: 15px;
font-weight: 500;
color: #000000;
padding: 0 18px;
&::placeholder {
color: #B3B3B3;
font-weight: 400;
}
@include respond-to('md') {
font-size: 14px;
padding: 0 11px;
}
@include respond-to('sm') {
// Figma 모바일: 10px, Medium, placeholder 색상 #8C959F
font-size: 10px;
font-weight: 500;
padding: 0 7px;
&::placeholder {
color: #8C959F;
font-weight: 500;
}
}
}
.search-icon-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
svg {
width: 30px;
height: 31px;
@include respond-to('md') {
width: 25px;
height: 26px;
}
@include respond-to('sm') {
// Figma 모바일: 16x16px 아이콘
width: 14px;
height: 14px;
}
}
@include respond-to('sm') {
width: 18px;
height: 18px;
}
}
.hashtag-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 11px;
padding: 0 18px 0px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 18px 27px;
}
@include respond-to('sm') {
// Figma 모바일: 가로 배치, 작은 간격
flex-direction: row;
flex-wrap: wrap;
gap: 11px;
padding: 0 18px 18px;
justify-content: center;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 14px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
// Figma 모바일: 11px, Bold
font-size: 11px;
color: #212529;
}
}
.hashtag-list {
display: flex;
align-items: center;
gap: 13px;
flex-wrap: wrap;
@include respond-to('md') {
justify-content: center;
gap: 9px;
}
@include respond-to('sm') {
// Figma 모바일: 7px 간격, wrap
gap: 7px;
justify-content: left;
}
}
.hashtag-link {
font-family: $font-family-primary;
font-size: 14px;
font-weight: 400;
color: #000000;
text-decoration: none;
white-space: nowrap;
transition: color 0.3s ease;
&:hover {
color: #0049B4;
text-decoration: underline;
}
@include respond-to('md') {
font-size: 13px;
}
@include respond-to('sm') {
// Figma 모바일: 11px
font-size: 11px;
color: #212529;
}
}
.hashtag-separator {
font-family: $font-family-primary;
font-size: 14px;
color: #000000;
user-select: none;
@include respond-to('md') {
font-size: 13px;
}
@include respond-to('sm') {
// Figma 모바일: 세로 구분선 (|)
font-size: 9px;
color: #212529;
display: flex;
align-items: center;
height: 9px;
}
}
}
*/
/* 피그마 시안 적용 카드 레이아웃 스타일 */ /* 피그마 시안 적용 카드 레이아웃 스타일 */
.search-container { .search-container {
@@ -1288,14 +952,21 @@
.card-description { .card-description {
font-family: $font-family-primary; font-family: $font-family-primary;
font-size: 13px; font-size: 16px;
color: var(--text-gray); color: var(--text-gray);
line-height: 1.5; line-height: 1.5;
text-align: left; text-align: left;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
max-height: 3em;
@include respond-to('sm') { @include respond-to('sm') {
// Figma 모바일: 11px Medium, 색상 #515151 // Figma 모바일: 11px Medium, 색상 #515151
font-size: 13px; font-size: 14px;
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
@@ -1425,7 +1096,7 @@
letter-spacing: 1px; letter-spacing: 1px;
@include respond-to('sm') { @include respond-to('sm') {
font-size: 13px; font-size: 14px;
} }
} }
@@ -1447,7 +1118,7 @@
} }
.info-description { .info-description {
font-size: 14px; font-size: 16px;
line-height: 1.7; line-height: 1.7;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
margin-bottom: 36px; margin-bottom: 36px;
@@ -1459,7 +1130,7 @@
@include respond-to('sm') { @include respond-to('sm') {
order: 3; order: 3;
font-size: 13px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
margin-bottom: 24px; margin-bottom: 24px;
word-break: keep-all; word-break: keep-all;
@@ -1637,424 +1308,6 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Support Center Section - 비지니스의 시작 (Figma Design) // Support Center Section - 비지니스의 시작 (Figma Design)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/* 원래 있던 support-center 스타일 (주석 처리)
.support-center {
position: relative;
padding: 0;
padding-top: 92px;
padding-bottom: 113px;
background: #eef7fd;
overflow: hidden;
height: 648px;
@include respond-to('md') {
height: auto;
padding-bottom: 60px;
}
@include respond-to('sm') {
// Figma 모바일: 353px 높이, 패딩 54px 18px
min-height: unset;
height: auto;
padding: 54px 18px;
}
.support-background {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
max-width: 1920px;
pointer-events: none;
&::before {
content: '';
position: absolute;
top: 15px; // Figma: top-[2911px] relative to page, section starts at ~2895px
left: 46%; // Approximate positioning
width: 624px;
height: 537px;
background-image: url('/img/bg_support_center.png'); // Assuming this image exists or use placeholder
background-size: cover;
background-repeat: no-repeat;
opacity: 0.5; // Adjust based on visual
}
@include respond-to('sm') {
// Figma 모바일: 포인트 이미지 상단 우측
&::before {
top: 0;
left: auto;
right: 0;
width: 168px;
height: 159px;
opacity: 1;
}
}
}
.container {
position: relative;
z-index: 1;
max-width: 1280px;
margin: 0 auto;
padding: 0 23px;
height: 443px;
@include respond-to('md') {
height: auto;
}
@include respond-to('sm') {
padding: 0;
max-width: 100%;
width: 100%;
height: auto;
}
}
.section-header {
margin-bottom: 36px;
text-align: center;
@include respond-to('sm') {
// Figma 모바일: 타이틀 영역
margin-bottom: 22px;
text-align: left;
}
.section-title {
font-size: 25px;
line-height: 1.4;
color: #000000;
margin: 0 0 $spacing-lg 0;
text-align: left;
@include respond-to('md') {
font-size: 32px;
line-height: 1.4;
}
@include respond-to('sm') {
// Figma 모바일: 타이틀 스타일
font-size: 14px;
line-height: 1;
letter-spacing: -0.32px;
}
.title-regular {
@include respond-to('sm') {
font-weight: 500;
}
}
.title-bold {
font-size: 34px;
font-weight: 700;
@include respond-to('sm') {
// Figma 모바일: 18px Bold
font-size: 18px;
letter-spacing: -0.4px;
}
}
}
}
.support-grid {
display: flex;
gap: 29px;
justify-content: center;
align-items: stretch;
max-width: 1228px; // 598 + 283 + 283 + (32 * 2) = 1228px
height: auto;
min-height: 288px;
margin: 0 auto;
flex-wrap: wrap;
@include respond-to('mobile') {
flex-direction: column;
align-items: center;
gap: 18px;
height: auto;
}
@include respond-to('sm') {
// 가로 배치는 카드가 너무 좁아지므로 1열 세로 배치로 변경
flex-direction: column;
align-items: stretch;
gap: 14px;
max-width: 100%;
height: auto;
}
}
// 통합 Support Card
.support-card {
background: #FFFFFF;
border-radius: 18px;
text-decoration: none;
transition: all 0.3s ease;
display: flex;
box-sizing: border-box;
position: relative;
overflow: hidden;
height: 288px; // 모든 카드 동일 높이
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
height: auto;
min-height: 234px;
}
@include respond-to('sm') {
// Figma 모바일: 101x168px 카드
width: calc(33.333% - 7px);
min-width: 0;
height: 151px;
min-height: 151px;
max-width: none;
border-radius: 11px;
padding: 23px 0 23px;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 14px;
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 9px 27px rgba(0, 73, 180, 0.15);
@include respond-to('sm') {
transform: translateY(-2px);
}
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('sm') {
// Figma 모바일: 43px 아이콘
width: 43px !important;
height: 43px !important;
margin-bottom: 0 !important;
}
}
.card-content {
display: flex;
flex-direction: column;
justify-content: center;
@include respond-to('sm') {
align-items: center;
text-align: center;
gap: 4px;
}
h3 {
font-family: $font-family-primary;
font-size: 29px;
font-weight: 700;
color: #212529;
margin: 0 0 9px 0;
line-height: 1.3;
@include respond-to('md') {
font-size: 25px;
}
@include respond-to('sm') {
// Figma 모바일: 15-14px Bold
font-size: 14px;
margin: 0;
line-height: 1;
}
}
p {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #515961;
margin: 0;
line-height: 1.4;
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 9px Regular
font-size: 9px;
line-height: 1;
}
}
}
// 공지사항 카드
&-notice {
width: 349px;
flex-shrink: 0;
flex-direction: column;
align-items: center;
padding: 45px 36px;
background: #0049B4;
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
padding: 45px 36px;
}
@include respond-to('sm') {
// Figma 모바일: 공지사항 카드 스타일
width: 100%;
padding: 23px 20px 23px;
gap: 17px;
height: auto;
}
.card-icon {
width: 108px;
height: 108px;
margin-bottom: 32px;
}
.card-content {
align-items: center;
text-align: center;
h3 {
color: #FFFFFF;
font-size: 27px;
@include respond-to('md') {
font-size: 29px;
}
@include respond-to('sm') {
font-size: 14px;
color: #FFFFFF;
}
}
p {
color: rgba(255, 255, 255, 0.9);
font-size: 20px;
@include respond-to('md') {
font-size: 16px;
}
@include respond-to('sm') {
font-size: 9px;
color: rgba(255, 255, 255, 0.9);
}
}
}
&:hover {
background: darken(#0049B4, 5%);
}
}
// FAQ & Q&A Cards
&-faq,
&-qna {
width: 349px;
flex-shrink: 0;
flex-direction: column;
align-items: center;
padding: 45px 36px;
@include respond-to('mobile') {
width: 100%;
max-width: 598px;
padding: 36px;
}
@include respond-to('sm') {
// Figma 모바일: FAQ/Q&A 카드 스타일
width: 100%;
padding: 23px 20px 23px;
height: auto;
}
.card-icon {
width: 108px;
height: 108px;
margin-bottom: 32px;
}
.card-content {
align-items: center;
text-align: center;
flex: none;
h3 {
font-size: 27px;
@include respond-to('md') {
font-size: 29px;
}
@include respond-to('sm') {
// Figma 모바일: 14px Bold
font-size: 14px;
}
}
p {
font-size: 20px;
@include respond-to('md') {
font-size: 16px;
}
@include respond-to('sm') {
// Figma 모바일: 9px Regular
font-size: 9px;
}
}
}
}
// FAQ Card Specifics
&--faq {
background: #DAF0FF;
.card-content h3 {
color: #212529;
}
}
// Q&A Card Specifics
&--qna {
background: #D9F6F8;
.card-content h3 {
color: #212529;
}
}
}
}
*/
/* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */ /* 피그마 시안 적용 Support Center 그리드 레이아웃 스타일 */
.support-center { .support-center {
@@ -2130,9 +1383,10 @@
} }
p { p {
font-size: 13px; font-size: 14px;
color: var(--text-gray); color: var(--text-gray);
line-height: 1.5; line-height: 1.5;
width: 80%;
} }
.card-arrow { .card-arrow {
@@ -120,7 +120,7 @@
} }
.form-label-text { .form-label-text {
font-size: 16px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #000; color: #000;
} }
@@ -159,7 +159,7 @@
.form-input:disabled, .form-input:disabled,
.input-readonly { .input-readonly {
background: #e9ecef !important; background: #ededed !important;
} }
.compound-input { .compound-input {
@@ -289,6 +289,7 @@
width: 24px; width: 24px;
height: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
color: #4685ef
} }
p { p {
@@ -136,9 +136,9 @@
} }
&--completed { &--completed {
background: #e5f0ff; background: #d6fbd0;
color: #0a4ea3; color: #11af4c;
border: 1px solid #b3d4ff; border: 1px solid #a0d4b2;
} }
&--closed { &--closed {
@@ -159,6 +159,12 @@
border: 1px solid #b3d4ff; border: 1px solid #b3d4ff;
} }
&--reviewing {
background: #e5f0ff;
color: #0a4ea3;
border: 1px solid #b3d4ff;
}
&--test { &--test {
background: #e6fffa; background: #e6fffa;
color: #0d9488; color: #0d9488;
@@ -34,6 +34,53 @@
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
padding: $spacing-lg $spacing-md; padding: $spacing-lg $spacing-md;
} }
.invitation_alert_banner {
background-color: #f0f8ff;
border: 1.5px solid #2196F3;
border-radius: 12px;
padding: 24px;
margin: 20px 0;
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.08);
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
transform: translateY(15px);
opacity: 0;
margin-bottom: 60px;
.banner-content-wrapper {
display: flex;
align-items: center;
gap: 16px;
}
.banner-icon-area {
display: flex;
align-items: center;
justify-content: center;
svg {
animation: pulseIcon 2s infinite ease-in-out;
}
}
.banner-text-area {
strong {
color: #1976D2;
font-size: 17px;
display: block;
margin-bottom: 4px;
}
p {
margin: 0;
color: #475569;
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
}
}
}
} }
// Header // Header
@@ -315,6 +362,16 @@
background-position: right 8px center; background-position: right 8px center;
} }
.org-form-select.phone-prefix {
flex: 1;
min-width: 90px;
max-width: 130px;
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.separator { .separator {
color: $text-gray; color: $text-gray;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
@@ -336,29 +393,31 @@
// Select Dropdown // Select Dropdown
.org-form-select { .org-form-select {
width: 100%; width: 100%;
padding: 8px 12px; height: 48px;
border: 1px solid $border-gray; padding: 0 16px;
border-radius: $border-radius-md; border: 1px solid #E2E8F0;
border-radius: 10px;
font-size: 14px; font-size: 14px;
color: $text-dark; color: #212529;
background: $white; background: $white;
cursor: pointer; cursor: pointer;
transition: $transition-base; transition: $transition-base;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748B' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right $spacing-lg center; background-position: right 16px center;
padding-right: $spacing-3xl; padding-right: $spacing-3xl;
&:focus { &:focus {
outline: none; outline: none;
border-color: $primary-blue; border-color: #3ba4ed;
box-shadow: 0 0 0 3px rgba(75, 155, 255, 0.1); box-shadow: 0 0 0 3px rgba(59, 164, 237, 0.12);
} }
&:disabled { &:disabled {
background-color: $gray-bg; background-color: #F8F9FA;
color: $text-light; border-color: #E2E8F0;
color: #94A3B8;
cursor: not-allowed; cursor: not-allowed;
} }
} }
+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; padding-bottom: 100px;
.signup-info-box { .signup-info-box {
background: #f5f5f5; background: #1d2a5e; // Deep slate dark card
border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.08);
padding: 30px 40px; border-left: 5px solid #2a69de; // Bright tech blue accent line
border-radius: 16px;
padding: 28px 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 30px; gap: 28px;
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
&__icon { &__icon {
width: 76px; width: 56px;
height: 76px; height: 56px;
background: #fff; background: #48507b;
border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
img { img {
width: 40px; width: 28px;
height: 40px; height: 28px;
filter: brightness(0) invert(1); // Turns the green check into clean white icon
object-fit: contain; object-fit: contain;
} }
} }
@@ -417,183 +422,397 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
li { li {
position: relative; position: relative;
padding-left: 14px; padding-left: 20px;
color: #535151; color: #94a3b8; // Sophisticated dim gray-blue
font-size: 16px; font-size: 15px;
font-weight: 500; font-weight: 500;
line-height: 1.8; line-height: 1.8;
word-break: keep-all; word-break: keep-all;
strong {
color: #ffffff;
font-weight: 700;
}
&::before { &::before {
content: '·'; content: '';
position: absolute; position: absolute;
left: 0; left: 4px;
color: #535151; top: 10px;
width: 5px;
height: 5px;
background: #38bdf8; // Neon cyan dot
border-radius: 50%;
}
&+li {
margin-top: 6px;
} }
} }
} }
} }
}
.signup-timeline { // Member Type Tabs
.signup-tabs {
display: flex;
justify-content: flex-start;
border-bottom: 1px solid #e3e8f0;
margin-top: 40px;
margin-bottom: 50px;
width: 100%;
.signup-tab-btn {
flex: 1;
max-width: 50%;
padding: 16px 20px;
font-size: 16px;
font-weight: 500;
color: #94a3b8;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
text-align: center;
transition: all 0.2s ease;
outline: none;
&:hover {
color: #1e293b;
}
&.active {
color: #2a69de;
font-weight: 700;
border-bottom: 2px solid #2a69de;
background: #ffffff;
}
}
}
.signup-timeline-container {
margin-bottom: 50px;
min-height: 300px;
}
.signup-timeline {
display: none; // Hidden by default, toggled via JS
flex-direction: column;
gap: 40px;
position: relative;
padding-left: 80px;
&.active {
display: flex;
}
&::before {
content: '';
position: absolute;
left: 17px; // center of line
top: 47px;
bottom: 47px;
width: 2px;
background: #e6eefb; // Light blue line
z-index: 0;
}
}
.signup-step {
display: flex;
align-items: center;
gap: 40px;
position: relative;
z-index: 1;
// Glowing Dot
&::before {
content: '';
position: absolute;
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background: #2a69de;
border-radius: 50%;
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1);
z-index: 2;
}
&__icon-box {
width: 95px;
min-height: 95px;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
svg {
width: 30px;
height: 30px;
}
}
&__card {
flex: 1;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
padding: 20px 30px;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 40px; justify-content: center;
position: relative; min-height: 95px;
padding-left: 80px;
&::before { &-header {
content: ''; display: flex;
position: absolute; align-items: center;
left: 17px; // center of line margin-bottom: 6px;
top: 47px; }
bottom: 47px;
width: 2px; &-num {
background: #e6eefb; // Light blue line font-size: 14px;
z-index: 0; font-weight: 700;
color: #1b4ab7;
}
&-dot {
width: 4px;
height: 4px;
background: #1b4ab7;
border-radius: 50%;
margin: 0 8px;
}
&-title {
font-size: 18px;
font-weight: 700;
color: #000;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
&-desc {
margin: 0;
font-size: 14px;
color: #64748b;
line-height: 1.5;
} }
} }
.signup-step { .one {
color: #2C4A8C;
}
.two {
color: #1F7A5C;
}
.three {
color: #F98E24;
}
.four {
color: #2480F9;
}
.five {
color: #FF5C5C;
}
.six {
color: #5B4AEE;
}
}
.signup-action {
display: flex;
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 40px; justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
// 권한별 이용 가능 서비스 테이블 디자인 추가
.signup-roles-table-section {
margin-top: 50px;
margin-bottom: 20px;
&__title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0 0 20px;
position: relative; position: relative;
z-index: 1; padding-left: 12px;
margin-bottom: 30px;
// Glowing Dot
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
left: -71px; // (80 - 17) = 63 distance to line center. 63 + 8 (half of 16px) = 71px left: 0;
top: 50%; top: 4px;
transform: translateY(-50%); bottom: 4px;
width: 16px; width: 4px;
height: 16px; background: #0049B4;
background: #2a69de; border-radius: 2px;
border-radius: 50%; }
box-shadow: 0 0 0 6px rgba(42, 105, 222, 0.15), 0 0 10px 6px rgba(42, 105, 222, 0.1); }
z-index: 2;
.signup-roles-table-wrapper {
width: 100%;
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
&::-webkit-scrollbar {
height: 6px;
} }
&__icon-box { &::-webkit-scrollbar-thumb {
width: 95px; background: #cbd5e1;
height: 95px; border-radius: 3px;
background: #fff; }
border: 1px solid #e3e8f0; }
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
svg { .signup-roles-table {
width: 30px; width: 100%;
height: 30px; border-collapse: collapse;
font-family: $font-family-primary;
text-align: center;
th,
td {
padding: 14px 16px;
font-size: 14px;
border-bottom: 1px solid #e2e8f0;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
} }
} }
&__card { thead {
flex: 1; background: #f8fafc;
background: #fff;
border: 1px solid #e3e8f0;
border-radius: 20px;
padding: 0 30px;
box-shadow: 0 4px 4px rgba(173, 173, 173, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
height: 95px;
&-num { th {
font-weight: 700;
color: #334155;
font-size: 13px;
&.col-feature {
width: 35%;
font-size: 14px;
color: #1e293b;
}
&.col-role {
width: 15%;
}
&.col-role-group {
background: #f1f5f9;
color: #0f172a;
}
&.col-sub-role {
width: 17.5%;
background: #f8fafc;
}
}
}
tbody {
tr {
transition: background 0.2s ease;
&:hover {
background: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
.cell-feature {
font-weight: 600;
color: #475569;
text-align: left;
padding-left: 20px;
}
// O / X 배지 디자인
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #1b4ab7;
margin-bottom: 4px; &--allowed {
background: #e0f2fe;
color: #0369a1;
}
&--denied {
background: #f1f5f9;
color: #94a3b8;
}
} }
&-title {
font-size: 18px;
font-weight: 500;
color: #000;
margin: 0;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
}
.one {
color: #2C4A8C;
}
.two {
color: #1F7A5C;
}
.three {
color: #F98E24;
}
.four {
color: #2480F9;
}
.five {
color: #FF5C5C;
}
.six {
color: #5B4AEE;
}
}
.signup-action {
display: flex;
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
} }
} }
} }
@@ -630,12 +849,25 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
@media (max-width: 768px) { @media (max-width: 768px) {
.signup-guide-v2 { .signup-guide-v2 {
.signup-timeline {
padding-left: 0;
&::before {
display: none; // Hides the vertical timeline line
}
}
.signup-step { .signup-step {
gap: 15px; gap: 15px;
&::before {
display: none; // Hides the glowing timeline dot
}
&__icon-box { &__icon-box {
width: 60px; width: 60px;
height: 60px; height: 60px;
min-height: 60px;
svg { svg {
width: 24px; width: 24px;
@@ -646,25 +878,13 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
&__card { &__card {
height: auto; height: auto;
min-height: 60px; min-height: 60px;
padding: 10px 15px; padding: 12px 18px;
&-title { &-title {
font-size: 15px; font-size: 15px;
} }
} }
} }
.signup-timeline {
padding-left: 50px;
&::before {
left: 17px;
}
}
.signup-step::before {
left: -41px; // adjusted for new padding
}
} }
} }
@@ -676,23 +896,23 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
padding: 56px 16px 32px; padding: 56px 16px 32px;
text-align: center; text-align: center;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.service_guide { .service_guide {
.title { .title {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
line-height: 36px; line-height: 36px;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #140064; color: #140064;
margin-bottom: 16px; margin-bottom: 16px;
} }
.detail { .detail {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
line-height: 22px; line-height: 22px;
color: #495057; color: #495057;
}
} }
} }
@@ -1117,9 +1337,10 @@ $o2leg-err-fg: #a23b3b;
&__prereq-title { &__prereq-title {
margin: 4px 0 8px; margin: 4px 0 8px;
font-size: 15px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: $o2leg-text-dark; color: $o2leg-text-dark;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
&__prereq-body p { &__prereq-body p {
@@ -63,6 +63,7 @@ $wh-bg-soft: #f9f9f9;
border-radius: 50%; border-radius: 50%;
margin-bottom: 16px; margin-bottom: 16px;
color: #cbd5e1; color: #cbd5e1;
font-size: 25px;
} }
h3 { h3 {
@@ -704,7 +705,7 @@ $wh-bg-soft: #f9f9f9;
.input-display-box { .input-display-box {
width: 100%; width: 100%;
height: 48px; height: 48px;
background-color: #eeeeee; background-color: #efefef;
border-radius: 10px; border-radius: 10px;
padding: 0 20px; padding: 0 20px;
display: flex; display: flex;
@@ -714,6 +715,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500; font-weight: 500;
word-break: break-all; word-break: break-all;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #DFDFDF;
} }
.badges-row { .badges-row {
@@ -767,7 +769,7 @@ $wh-bg-soft: #f9f9f9;
flex: 1; flex: 1;
max-width: 250px; max-width: 250px;
height: 48px; height: 48px;
background-color: #eeeeee; background-color: #efefef;
border-radius: 10px; border-radius: 10px;
padding: 0 20px; padding: 0 20px;
display: flex; display: flex;
@@ -778,6 +780,7 @@ $wh-bg-soft: #f9f9f9;
font-weight: 500; font-weight: 500;
box-sizing: border-box; box-sizing: border-box;
letter-spacing: 1px; letter-spacing: 1px;
border: 1px solid #DFDFDF;
@media (max-width: 576px) { @media (max-width: 576px) {
max-width: 100%; max-width: 100%;
@@ -69,18 +69,9 @@
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab"> <div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) --> <!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card"> <div class="api-overview-card">
<!--/* 현재 상태는 게시된 장애·점검 공지 기준(/apistatus/current.json). 조회 실패해도 화면은 유지 */-->
<div class="org-section-header org-section-header--agreement api-basic-info-header"> <div class="org-section-header org-section-header--agreement api-basic-info-header">
<h3>기본 정보</h3> <h3>기본 정보</h3>
<div class="api-status-inline" id="apiStatusInline" <button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
th:attr="data-api-id=${apiSpecInfo.apiId},
data-status-url=@{/apistatus/current.json}">
<span class="api-status-inline__label">현재 상태</span>
<span class="api-status-inline__badge is-loading" id="apiStatusBadge">조회 중</span>
<!--/* apiName 은 표시용 - 이슈 이력 화면 필터는 apiId 로 동작한다 */-->
<a class="btn-action-primary md"
th:href="@{/apistatus/issues(apiId=${apiSpecInfo.apiId},apiName=${apiSpecInfo.apiName})}">상태 이력 확인</a>
</div>
</div> </div>
<div class="api-overview-header"> <div class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div> <div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
@@ -484,51 +475,6 @@
btn.addEventListener('click', requestApiUse); btn.addEventListener('click', requestApiUse);
}); });
// ===== 기본 정보: 현재 상태 =====
// 게시된 장애·점검 공지 기준(/apistatus/current.json). 상태 이력은 /apistatus/issues?apiId= 로 이어진다.
(function loadApiCurrentStatus() {
const box = document.getElementById('apiStatusInline');
const badge = document.getElementById('apiStatusBadge');
if (!box || !badge) return;
const apiId = box.getAttribute('data-api-id');
const statusUrl = box.getAttribute('data-status-url');
if (!apiId || !statusUrl) return;
const BADGE_CLASS = {
OUTAGE: 'is-outage',
DEGRADED: 'is-degraded',
MAINTENANCE: 'is-maintenance',
NORMAL: 'is-normal'
};
function paint(cls, text, tooltip) {
badge.className = 'api-status-inline__badge ' + cls;
badge.textContent = text;
if (tooltip) badge.setAttribute('title', tooltip);
else badge.removeAttribute('title');
}
fetch(statusUrl + '?apiId=' + encodeURIComponent(apiId), { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (status) {
const tooltip = [
status.activeIncidentTitle,
status.statusSince ? '시작 ' + status.statusSince : null,
status.expectedEndAt ? '종료 예정 ' + status.expectedEndAt : null
].filter(Boolean).join('\n');
paint(BADGE_CLASS[status.currentStatus] || 'is-muted',
status.currentStatusLabel || '정상', tooltip);
})
.catch(function (e) {
console.error('API 현재 상태 조회 실패', e);
paint('is-muted', '상태 확인 불가');
});
})();
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 ===== // ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec'; const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
const appsSelect = document.getElementById('apps'); const appsSelect = document.getElementById('apps');
@@ -8,7 +8,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" /> <img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
<div class="service-hero__badge"> <div class="service-hero__badge">
@@ -75,20 +75,13 @@
</div> </div>
<!-- API Cards Grid --> <!-- API Cards Grid -->
<div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}" <div class="api-card-grid" th:if="${apis != null and !apis.isEmpty()}">
th:attr="data-status-url=@{/apistatus/current-list.json}">
<div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button" <div class="api-card" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}" role="button"
tabindex="0"> tabindex="0">
<!-- Group Badge + 현재 상태 태그 --> <!-- Group Badge -->
<div class="api-card-badges"> <div class="api-card-badge">
<div class="api-card-badge"> <span th:text="${api.apiGroupName}">그룹 이름</span>
<span th:text="${api.apiGroupName}">그룹 이름</span>
</div>
<!--/* 상태 태그 노출은 PTL_PROPERTY(djb.apistatus.api-list-status-badge) 로 제어.
값은 로드 후 JS가 채우며, 비어 있는 동안은 CSS(:empty)로 숨긴다 */-->
<span th:if="${apiStatusBadgeEnabled}" class="api-card-status"
th:attr="data-api-id=${api.apiId}"></span>
</div> </div>
<!-- API Name --> <!-- API Name -->
@@ -159,62 +152,6 @@
}); });
}); });
// ===== 카드 현재 상태 태그 =====
// 게시된 장애·점검 공지 기준(/apistatus/current-list.json). 태그 자체는 PTL_PROPERTY 로 노출 제어된다.
(function loadApiCardStatuses() {
const grid = document.querySelector('.api-card-grid');
if (!grid) return;
const statusUrl = grid.getAttribute('data-status-url');
const slots = Array.prototype.slice.call(grid.querySelectorAll('.api-card-status[data-api-id]'));
if (!statusUrl || !slots.length) return;
const BADGE_CLASS = {
OUTAGE: 'is-outage',
DEGRADED: 'is-degraded',
MAINTENANCE: 'is-maintenance',
NORMAL: 'is-normal'
};
const CHUNK = 50; // 서버 일괄 조회 상한(100) 이내로 나눠 요청
// 같은 API 가 여러 카드에 나올 수 있어 apiId → 슬롯 목록으로 묶는다
const slotsByApiId = {};
slots.forEach(function (el) {
const apiId = el.getAttribute('data-api-id');
if (!apiId) return;
(slotsByApiId[apiId] = slotsByApiId[apiId] || []).push(el);
});
const apiIds = Object.keys(slotsByApiId);
function paint(list) {
(list || []).forEach(function (status) {
const targets = slotsByApiId[status.apiId];
const cls = BADGE_CLASS[status.currentStatus];
if (!targets || !cls) return;
targets.forEach(function (el) {
el.className = 'api-card-status ' + cls;
el.textContent = status.currentStatusLabel || '';
if (status.activeIncidentTitle) el.setAttribute('title', status.activeIncidentTitle);
});
});
}
for (let index = 0; index < apiIds.length; index += CHUNK) {
const query = apiIds.slice(index, index + CHUNK).map(function (apiId) {
return 'apiId=' + encodeURIComponent(apiId);
}).join('&');
fetch(statusUrl + '?' + query, { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(paint)
// 상태 조회 실패는 목록 자체를 막지 않는다 (태그는 비어 있는 채로 숨겨진다)
.catch(function (e) { console.error('API 현재 상태 조회 실패', e); });
}
})();
// Category/Service Selection // Category/Service Selection
const menuTitles = document.querySelectorAll('.js-menu-trigger'); const menuTitles = document.querySelectorAll('.js-menu-trigger');
menuTitles.forEach(function (title) { menuTitles.forEach(function (title) {
@@ -1,61 +1,64 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>본인 확인</h1> <h1>본인 확인</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;"> <div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<div class="password-change-wrapper"> <div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2> <h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post"> <form th:action="@{/auth/stepup/password}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/> <input type="hidden" name="returnUrl" th:value="${returnUrl}" />
<div class="register-form-container"> <div class="register-form-container">
<div class="info-notice-box"> <div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2"> <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
<circle cx="12" cy="12" r="10"></circle> stroke="currentColor" stroke-width="2" stroke-linecap="round"
<path d="M12 16v-4"></path> stroke-linejoin="round">
<path d="M12 8h.01"></path> <circle cx="12" cy="12" r="10" />
</svg> <line x1="12" y1="8" x2="12" y2="12" />
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p> <line x1="12" y1="16" x2="12.01" y2="16" />
</div> </svg>
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div> </div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input" <div class="form-row">
placeholder="비밀번호 입력" required autofocus> <div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input"
placeholder="비밀번호 입력" required autofocus>
</div>
</div> </div>
</div> </div>
</div>
<div class="form-actions" style="justify-content: flex-end;"> <div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons"> <div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button> <button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button> <button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div> </div>
</div> </form>
</form> </div>
</div> </div>
</div> </div>
</div> <script th:if="${error}" th:inline="javascript">
<script th:if="${error}" th:inline="javascript"> $(document).ready(function () {
$(document).ready(function () { customPopups.showAlert([[${ error }]]);
customPopups.showAlert([[${error}]]); })
}) </script>
</script> </section>
</section>
</body> </body>
</html>
</html>
@@ -16,7 +16,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘" <img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" /> style="width: 100%; height: 100%; object-fit: contain;" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
@@ -17,7 +17,7 @@
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<!-- TODO: Update icon to faq icon --> <!-- TODO: Update icon to faq icon -->
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘" <img th:src="@{/img/keyimage/faq_img.png}" alt="FAQ 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" /> style="width: 100%; height: 100%; object-fit: contain;" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
@@ -76,9 +76,9 @@
<!-- Table Header --> <!-- Table Header -->
<div class="board-table-header"> <div class="board-table-header">
<div class="header-cell" style="width: 80px;">NO</div> <div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div> <div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 120px;">작성자</div> <div class="header-cell" style="width: 120px;">작성자</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="width: 80px;">조회수</div> <div class="header-cell" style="width: 80px;">조회수</div>
<div class="header-cell" style="width: 120px;">등록일</div> <div class="header-cell" style="width: 120px;">등록일</div>
</div> </div>
@@ -89,9 +89,19 @@
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'" th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')" th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'"> th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
<div class="row-cell row-cell--number" data-label="NO" <div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div> th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" data-label="제목">
<!-- 처리상태 (Moved here) -->
<div class="row-cell row-cell--status" style="width: 120px;" data-label="처리상태">
<span class="notice-type-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--reviewing' : 'notice-type-badge--pending'))}"
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
답변대기
</span>
</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}" <a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
class="notice-title-link" class="notice-title-link"
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'"> th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
@@ -99,7 +109,7 @@
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span> th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<!-- Private/Lock Icon --> <!-- Private/Lock Icon -->
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}"> <span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}" style="margin-right: 4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-label="비공개"> aria-label="비공개">
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" /> <rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
@@ -117,22 +127,14 @@
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span> th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a> </a>
</div> </div>
<div class="row-cell row-cell--writer" data-label="작성자"> <div class="row-cell row-cell--writer" style="width: 120px;" data-label="작성자">
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span> <span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
<span class="inquiry-writer-org" <span class="inquiry-writer-org"
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}" th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span> th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
</div> </div>
<div class="row-cell row-cell--status" data-label="처리상태"> <div class="row-cell row-cell--views" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING --> <div class="row-cell row-cell--date" style="width: 120px;" data-label="등록일"
<span class="notice-type-badge lg"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--maintenance' : 'notice-type-badge--pending'))}"
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
답변대기
</span>
</div>
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<div class="row-cell row-cell--date" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div> th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div> </div>
</div> </div>
@@ -16,7 +16,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" <img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘"
style="width: 100%; height: 100%; object-fit: contain;" /> style="width: 100%; height: 100%; object-fit: contain;" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
@@ -16,7 +16,7 @@
<section class="service-hero"> <section class="service-hero">
<div class="service-hero__inner"> <div class="service-hero__inner">
<div class="service-hero__icon-wrapper"> <div class="service-hero__icon-wrapper">
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" /> <img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
</div> </div>
<div class="service-hero__content"> <div class="service-hero__content">
<div class="service-hero__badge"> <div class="service-hero__badge">
@@ -84,7 +84,13 @@
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link"> <a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number" <span class="notice-number"
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span> th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span> <span class="notice-pin-icon" th:if="${notice.fixYn == 'Y'}"
style="margin-right: 6px; display: inline-flex; align-items: center; vertical-align: middle; color: #ef4444;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M16 12V4H17V2H7V4H8V12L6 14V16H11V22L12 23L13 22V16H18V14L16 12Z" />
</svg>
</span>
<span class="notice-type-badge notice-type-badge--incident" <span class="notice-type-badge notice-type-badge--incident"
th:if="${notice.noticeType == '3'}">장애</span> th:if="${notice.noticeType == '3'}">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance" <span class="notice-type-badge notice-type-badge--maintenance"
@@ -1,66 +1,73 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>아이디 찾기</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Tab Navigation -->
<div class="account-recovery-tabs">
<a href="#" class="tab-link active">아이디찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
</div>
<!-- Result Content Area --> <body>
<div class="account-recovery-result"> <section layout:fragment="title">
<div class="result-header"> <div class="page-title-banner">
<i class="fas fa-check-circle result-icon"></i> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h2 class="result-title">회원님의 아이디았습니다.</h2> <h1>아이디 찾</h1>
<p class="result-description" th:if="${#lists.size(foundUsers) > 1}"> </div>
동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다. </section>
</p> <th:block layout:fragment="contentFragment">
<div class="account-recovery-page">
<div class="account-recovery-container">
<div class="account-recovery-card">
<!-- Tab Navigation -->
<div class="account-recovery-tabs">
<a href="#" class="tab-link active">아이디찾기</a>
<a th:href="@{/account_recovery(tab='resetPassword')}" class="tab-link">비밀번호 초기화</a>
</div> </div>
<!-- Found Users List --> <!-- Result Content Area -->
<div class="found-users-list"> <div class="account-recovery-result">
<div class="found-user-item" th:each="user, stat : ${foundUsers}"> <div class="result-header">
<div class="user-info"> <i class="fas fa-check-circle result-icon"></i>
<span class="user-email" th:text="${user.loginId}">test@example.com</span> <h2 class="result-title">회원님의 아이디를 찾았습니다.</h2>
<span class="user-date"> <p class="result-description" th:if="${#lists.size(foundUsers) > 1}">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입) 동일한 정보로 가입된 계정이 <strong th:text="${#lists.size(foundUsers)}">2</strong>개 있습니다.
</span> </p>
</div>
<!-- Found Users List -->
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<div class="user-info">
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
<span class="user-date">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
</div>
</div> </div>
</div> </div>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text">
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
비밀번호가 기억나지 않는 경우에는
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>
이용해 주세요.
</p>
</div>
</div> </div>
<!-- Info Box --> <!-- Action Buttons -->
<div class="result-info-box"> <div class="form-actions">
<p class="info-text"> <a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
<i class="fas fa-info-circle"></i> <a th:href="@{/login}" class="submit-button">로그인</a>
비밀번호가 기억나지 않는 경우에는
<a th:href="@{/account_recovery(tab='resetPassword')}" class="info-link">비밀번호 초기화</a>
이용해 주세요.
</p>
</div> </div>
</div> </div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/account_recovery(tab='findId')}" class="cancel-button">다시 찾기</a>
<a th:href="@{/login}" class="submit-button">로그인</a>
</div>
</div> </div>
</div> </div>
</div> </th:block>
</th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
</th:block> </th:block>
</html>
</html>
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p> <p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2> <h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</div> </div>
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a> <a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div> </div>
<div class="hero-image-content"> <div class="hero-image-content">
<!-- Inline SVG Tech Illustration --> <!-- Inline SVG Tech Illustration -->
@@ -47,7 +47,8 @@
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p> <p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2> <h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
</div> </div>
<a href="#" class="btn-hero-signup">개발 가이드 보기 <i class="bi bi-chevron-right"></i></a> <a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
class="bi bi-chevron-right"></i></a>
</div> </div>
<div class="hero-image-content"> <div class="hero-image-content">
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -72,7 +73,7 @@
<p class="hero-subtitle">상상하는 금융의 구현</p> <p class="hero-subtitle">상상하는 금융의 구현</p>
<h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2> <h2 class="hero-title">기업의 비즈니스를<br>혁신합니다.</h2>
</div> </div>
<a href="#" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a> <a th:href="@{/partnership}" class="btn-hero-signup">제휴 문의하기 <i class="bi bi-chevron-right"></i></a>
</div> </div>
<div class="hero-image-content"> <div class="hero-image-content">
<svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="hero-illustration" viewBox="0 0 500 400" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -146,40 +147,6 @@
<!-- API 검색 섹션 --> <!-- API 검색 섹션 -->
<section class="api-search-section"> <section class="api-search-section">
<div class="search-background"></div> <div class="search-background"></div>
<!--/* <div class="search-content-wrapper">*/-->
<!--/* <div class="search-character">*/-->
<!--/* <img th:src="@{/img/img_search_character.png}" alt="검색">*/-->
<!--/* </div>*/-->
<!--/* <div class="search-text-content">*/-->
<!--/* <h2 class="search-title">*/-->
<!--/* 원하는 API를<br>*/-->
<!-- 지금 검색해 보세요.-->
<!--/* </h2>*/-->
<!--/* </div>*/-->
<!--/* </div>*/-->
<!--/* <div class="search-input-wrapper">*/-->
<!--/* <form th:action="@{/apis}" method="get" class="search-form">*/-->
<!--/* <div class="search-box">*/-->
<!--/* <input type="text" class="search-input" placeholder="어떤 API를 칮고 계신가요?" id="apiSearchInput" name="keyword">*/-->
<!--/* <button class="search-icon-button" type="submit">*/-->
<!--/* <svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">*/-->
<!--/* <path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>*/-->
<!--/* </svg>*/-->
<!--/* </button>*/-->
<!--/* </div>*/-->
<!--/* </form>*/-->
<!--/* </div>*/-->
<!--/* <div class="hashtag-section">*/-->
<!--/* <span class="hashtag-label">자주찾는 API</span>*/-->
<!--/* <div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">*/-->
<!--/* <th:block th:each="hashtag, iterStat : ${hashtags}">*/-->
<!--/* <a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">*/-->
<!--/* </a>*/-->
<!--/* <span class="hashtag-separator" th:unless="${iterStat.last}">|</span>*/-->
<!--/* </th:block>*/-->
<!--/* </div>*/-->
<!--/* </div>*/-->
<!-- 피그마 시안 적용 카드 레이아웃 --> <!-- 피그마 시안 적용 카드 레이아웃 -->
@@ -328,7 +295,8 @@
</p> </p>
<div class="action-buttons"> <div class="action-buttons">
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a> <a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a> <a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
class="bi bi-patch-question"></i></a>
</div> </div>
</div> </div>
<div class="info-image-box"> <div class="info-image-box">
@@ -597,268 +565,284 @@
<!-- 로그인 후 처리 스크립트 --> <!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script> <script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript"> <script th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
LoginSuccessHandler.init({ LoginSuccessHandler.init({
successMsg: [[${ success }]], successMsg: [[${ success }]],
emailVerificationRequired: [[${ session.emailVerificationRequired }]], emailVerificationRequired: [[${ session.emailVerificationRequired }]],
dormantAccount: [[${ session.dormantAccount }]], dormantAccount: [[${ session.dormantAccount }]],
passwordExpired: [[${ session.passwordExpired }]], passwordExpired: [[${ session.passwordExpired }]],
pendingInvitation: [[${ session.pendingInvitation }]], pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]], pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]], pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]], needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]], sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]] redirectUrl: [[${ session.redirectUrl }]]
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// ========================================
// Hero Carousel
// ========================================
const heroCarousel = {
currentSlide: 0,
totalSlides: 3,
autoPlayInterval: null,
autoPlayDelay: 5000,
isPlaying: true,
init: function () {
this.slides = document.querySelectorAll('.hero-slide');
this.indicators = document.querySelectorAll('.hero-indicator');
this.prevBtn = document.getElementById('heroPrevBtn');
this.nextBtn = document.getElementById('heroNextBtn');
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
if (!this.slides.length) return;
this.bindEvents();
this.startAutoPlay();
},
bindEvents: function () {
// Previous button
if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide - 1);
this.resetAutoPlay();
});
}
// Next button
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide + 1);
this.resetAutoPlay();
});
}
// Indicators
this.indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
this.goToSlide(index);
this.resetAutoPlay();
});
});
// Auto play toggle button
if (this.autoplayToggle) {
this.autoplayToggle.addEventListener('click', () => {
this.toggleAutoPlay();
});
}
// Pause on hover (only if playing)
const container = document.querySelector('.hero-carousel-container');
if (container) {
container.addEventListener('mouseenter', () => {
if (this.isPlaying) {
this.pauseAutoPlay();
}
});
container.addEventListener('mouseleave', () => {
if (this.isPlaying) {
this.startAutoPlay();
}
});
}
// Touch events for mobile
let touchStartX = 0;
let touchEndX = 0;
if (container) {
container.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
});
container.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
});
}
},
goToSlide: function (index) {
// Wrap around
if (index < 0) {
index = this.totalSlides - 1;
} else if (index >= this.totalSlides) {
index = 0;
}
// Update slides
this.slides.forEach((slide, i) => {
slide.classList.toggle('active', i === index);
});
// Update indicators
this.indicators.forEach((indicator, i) => {
indicator.classList.toggle('active', i === index);
});
this.currentSlide = index;
},
handleSwipe: function (startX, endX) {
const minSwipeDistance = 50;
const distance = startX - endX;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swipe left - next slide
this.goToSlide(this.currentSlide + 1);
} else {
// Swipe right - previous slide
this.goToSlide(this.currentSlide - 1);
}
this.resetAutoPlay();
}
},
startAutoPlay: function () {
this.pauseAutoPlay();
this.autoPlayInterval = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.autoPlayDelay);
},
pauseAutoPlay: function () {
if (this.autoPlayInterval) {
clearInterval(this.autoPlayInterval);
this.autoPlayInterval = null;
}
},
resetAutoPlay: function () {
if (this.isPlaying) {
this.pauseAutoPlay();
this.startAutoPlay();
}
},
toggleAutoPlay: function () {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
// Start auto play
this.startAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.add('active');
if (this.playIcon) this.playIcon.classList.remove('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
}
} else {
// Stop auto play
this.pauseAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
if (this.playIcon) this.playIcon.classList.add('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
}
}
}
};
// Initialize hero carousel
heroCarousel.init();
// ========================================
// API 검색 기능
// ========================================
const searchInput = document.getElementById('apiSearchInput');
const searchForm = document.querySelector('.search-form');
const hashtags = document.querySelectorAll('.hashtag-link');
// 해시태그 클릭 이벤트
hashtags.forEach(tag => {
tag.addEventListener('click', function (e) {
e.preventDefault();
const searchTerm = this.getAttribute('data-search');
if (searchInput) {
searchInput.value = searchTerm;
}
// 폼 제출
if (searchForm) {
searchForm.submit();
}
}); });
}); });
</script>
// ======================================== <script>
// 숫자 카운트업 애니메이션 document.addEventListener('DOMContentLoaded', function () {
// ======================================== // ========================================
const observerOptions = { // Hero Carousel
threshold: 0.5, // ========================================
rootMargin: '0px 0px -100px 0px' const heroCarousel = {
}; currentSlide: 0,
totalSlides: 3,
autoPlayInterval: null,
autoPlayDelay: 5000,
isPlaying: true,
const animateNumbers = (entries, observer) => { init: function () {
entries.forEach(entry => { this.slides = document.querySelectorAll('.hero-slide');
if (entry.isIntersecting) { this.indicators = document.querySelectorAll('.hero-indicator');
const numbers = entry.target.querySelectorAll('.stat-number'); this.prevBtn = document.getElementById('heroPrevBtn');
this.nextBtn = document.getElementById('heroNextBtn');
this.autoplayToggle = document.getElementById('heroAutoplayToggle');
this.pauseIcon = this.autoplayToggle?.querySelector('.pause-icon');
this.playIcon = this.autoplayToggle?.querySelector('.play-icon');
numbers.forEach(number => { if (!this.slides.length) return;
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
const updateNumber = () => { this.bindEvents();
current += increment; this.startAutoPlay();
if (current < target) { },
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber); bindEvents: function () {
} else { // Previous button
number.textContent = target.toLocaleString('ko-KR'); if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide - 1);
this.resetAutoPlay();
});
}
// Next button
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
this.goToSlide(this.currentSlide + 1);
this.resetAutoPlay();
});
}
// Indicators
this.indicators.forEach((indicator, index) => {
indicator.addEventListener('click', () => {
this.goToSlide(index);
this.resetAutoPlay();
});
});
// Auto play toggle button
if (this.autoplayToggle) {
this.autoplayToggle.addEventListener('click', () => {
this.toggleAutoPlay();
});
}
// Pause on hover (only if playing)
const container = document.querySelector('.hero-carousel-container');
if (container) {
container.addEventListener('mouseenter', () => {
if (this.isPlaying) {
this.pauseAutoPlay();
} }
}; });
container.addEventListener('mouseleave', () => {
if (this.isPlaying) {
this.startAutoPlay();
}
});
}
updateNumber(); // Touch events for mobile
let touchStartX = 0;
let touchEndX = 0;
if (container) {
container.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
});
container.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
this.handleSwipe(touchStartX, touchEndX);
});
}
},
goToSlide: function (index) {
// Wrap around
if (index < 0) {
index = this.totalSlides - 1;
} else if (index >= this.totalSlides) {
index = 0;
}
// Update slides
this.slides.forEach((slide, i) => {
slide.classList.toggle('active', i === index);
}); });
observer.unobserve(entry.target); // Update indicators
this.indicators.forEach((indicator, i) => {
indicator.classList.toggle('active', i === index);
});
// Retrigger draw-line and pulsing animations for Slide 3 (index 2)
if (index === 2) {
const drawLinePath = this.slides[2].querySelector('.draw-line');
const pulsingCircle = this.slides[2].querySelector('.pulsing');
if (drawLinePath) {
drawLinePath.style.animation = 'none';
drawLinePath.offsetHeight;
drawLinePath.style.animation = null;
}
if (pulsingCircle) {
pulsingCircle.style.animation = 'none';
pulsingCircle.offsetHeight;
pulsingCircle.style.animation = null;
}
}
this.currentSlide = index;
},
handleSwipe: function (startX, endX) {
const minSwipeDistance = 50;
const distance = startX - endX;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swipe left - next slide
this.goToSlide(this.currentSlide + 1);
} else {
// Swipe right - previous slide
this.goToSlide(this.currentSlide - 1);
}
this.resetAutoPlay();
}
},
startAutoPlay: function () {
this.pauseAutoPlay();
this.autoPlayInterval = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.autoPlayDelay);
},
pauseAutoPlay: function () {
if (this.autoPlayInterval) {
clearInterval(this.autoPlayInterval);
this.autoPlayInterval = null;
}
},
resetAutoPlay: function () {
if (this.isPlaying) {
this.pauseAutoPlay();
this.startAutoPlay();
}
},
toggleAutoPlay: function () {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
// Start auto play
this.startAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.add('active');
if (this.playIcon) this.playIcon.classList.remove('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 일시정지');
}
} else {
// Stop auto play
this.pauseAutoPlay();
// Update icons
if (this.pauseIcon) this.pauseIcon.classList.remove('active');
if (this.playIcon) this.playIcon.classList.add('active');
// Update aria-label
if (this.autoplayToggle) {
this.autoplayToggle.setAttribute('aria-label', '자동 재생 시작');
}
}
} }
};
// Initialize hero carousel
heroCarousel.init();
// ========================================
// API 검색 기능
// ========================================
const searchInput = document.getElementById('apiSearchInput');
const searchForm = document.querySelector('.search-form');
const hashtags = document.querySelectorAll('.hashtag-link');
// 해시태그 클릭 이벤트
hashtags.forEach(tag => {
tag.addEventListener('click', function (e) {
e.preventDefault();
const searchTerm = this.getAttribute('data-search');
if (searchInput) {
searchInput.value = searchTerm;
}
// 폼 제출
if (searchForm) {
searchForm.submit();
}
});
}); });
};
const observer = new IntersectionObserver(animateNumbers, observerOptions); // ========================================
const statsSection = document.querySelector('.api-stats-section'); // 숫자 카운트업 애니메이션
// ========================================
const observerOptions = {
threshold: 0.5,
rootMargin: '0px 0px -100px 0px'
};
if (statsSection) { const animateNumbers = (entries, observer) => {
observer.observe(statsSection); entries.forEach(entry => {
} if (entry.isIntersecting) {
}); const numbers = entry.target.querySelectorAll('.stat-number');
</script>
numbers.forEach(number => {
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
const updateNumber = () => {
current += increment;
if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
}
};
updateNumber();
});
observer.unobserve(entry.target);
}
});
};
const observer = new IntersectionObserver(animateNumbers, observerOptions);
const statsSection = document.querySelector('.api-stats-section');
if (statsSection) {
observer.observe(statsSection);
}
});
</script>
</th:block> </th:block>
</html> </html>
@@ -14,7 +14,7 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <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> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<div class="password-change-wrapper"> <div class="password-change-wrapper">
@@ -24,11 +24,12 @@
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" /> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container"> <div class="register-form-container">
<div class="info-notice-box"> <div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke-width="2"> stroke="currentColor" stroke-width="2" stroke-linecap="round"
<circle cx="12" cy="12" r="10"></circle> stroke-linejoin="round">
<path d="M12 16v-4"></path> <circle cx="12" cy="12" r="10" />
<path d="M12 8h.01"></path> <line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg> </svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br> <p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p> 비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
@@ -1,80 +1,84 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>비밀번호 변경</h1> <h1>비밀번호 변경</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="service-main app-management-layout"> <div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('password')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<div class="password-change-wrapper"> <div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2> <h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/password/verify}" method="post"> <form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container"> <div class="register-form-container">
<div class="info-notice-box"> <div class="info-notice-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2"> <svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
<circle cx="12" cy="12" r="10"></circle> stroke="currentColor" stroke-width="2" stroke-linecap="round"
<path d="M12 16v-4"></path> stroke-linejoin="round">
<path d="M12 8h.01"></path> <circle cx="12" cy="12" r="10" />
</svg> <line x1="12" y1="8" x2="12" y2="12" />
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p> <line x1="12" y1="16" x2="12.01" y2="16" />
</div> </svg>
<p>비밀번호 변경을 위하여 현재 비밀번호를 입력해 주세요</p>
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">현재 비밀번호</span>
</div> </div>
<div class="form-field-wrapper">
<input type="password" name="currentPassword" class="form-input" <div class="form-row">
placeholder="비밀번호 입력" required> <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>
</div>
<div class="form-actions" style="justify-content: flex-end;"> <div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons"> <div class="right-buttons">
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button> <button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}"
<button type="submit" class="btn-apply btn-primary">확인</button> th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div> </div>
</div> </form>
</form> </div>
</div> </div>
</div> </div>
</div> <script th:if="${error}" th:inline="javascript">
<script th:if="${error}" th:inline="javascript"> $(document).ready(function () {
$(document).ready(function() { customPopups.showAlert([[${ error }]]);
customPopups.showAlert([[${error}]]); })
}) </script>
</script> <script th:if="${forcedPasswordReset}" th:inline="javascript">
<script th:if="${forcedPasswordReset}" th:inline="javascript"> // 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능 $(function () {
$(function () { $('#customConfirmCloseButton').hide();
$('#customConfirmCloseButton').hide(); $('#customConfirmYesButton').text('변경');
$('#customConfirmYesButton').text('변경'); $('#customConfirmNoButton').text('로그아웃');
$('#customConfirmNoButton').text('로그아웃'); customPopups.showConfirm(
customPopups.showConfirm( '계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.', function (selection) {
function (selection) { if (selection) {
if (selection) { // 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행 $('#customConfirmCloseButton').show();
$('#customConfirmCloseButton').show(); } else {
} else { // 로그아웃
// 로그아웃 window.location.href = [[@{/ actionLogout.do}]];
window.location.href = [[@{/actionLogout.do}]];
} }
} }
); );
}); });
</script> </script>
</section> </section>
</body> </body>
</html>
</html>
@@ -9,34 +9,49 @@
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-container"> <div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<div class="corp-manager-wrapper"> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="common-title-bar"> <div class="app-management-content">
<h2 class="common-title">기본 정보</h2> <div class="corp-manager-wrapper">
</div> <h2 class="page-outer-title">정보 관리</h2>
<div class="register-form-container">
<form id="updateForm" method="post" th:action="@{/mypage/update}"> <form id="updateForm" method="post" th:action="@{/mypage/update}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form">
<div class="form-row"> <div class="register-form-container">
<div class="form-label-wrapper"> <!-- Section Title -->
<span class="form-label-text">소속기관</span> <div class="inner-section-title">
</div> <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2">
<div class="form-field-wrapper"> <rect x="3" y="3" width="7" height="7"></rect>
<input type="text" th:value="${user.portalOrg.orgName}" name="organization" <rect x="14" y="3" width="7" height="7"></rect>
class="form-input input-readonly info-value-box" placeholder="소속기관" disabled="disabled"> <rect x="14" y="14" width="7" height="7"></rect>
</div> <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> </div>
<th:block th:replace="~{apps/mypage/components/commonUserInfo :: commonUserInfo}"></th:block>
</div> </div>
</form> </form>
</div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="form-actions form-actions--with-withdrawal"> <div class="form-actions">
<a class="withdrawal-link"><img th:src="@{/img/btn_withdrawal.png}" alt="회원탈퇴">회원탈퇴</a> <a class="withdrawal-link btn-withdrawal">회원탈퇴</a>
<div class="form-actions-buttons"> <div class="right-buttons">
<button type="button" class="btn btn-submit btn-secondary" th:onclick="|location.href='@{/}'|">취소</button> <button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn btn-submit btn-primary submit-btn">수정 적용</button> <button type="button" class="btn-apply btn-primary submit-btn">수정</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,524 +1,529 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" <html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image"> <img th:src="@{/img/img_title_bg.png}" alt="법인회원가입" class="title-image">
<h1>법인회원가입</h1> <h1>법인회원가입</h1>
</div> </div>
</section> </section>
<section layout:fragment="contentFragment"> <section layout:fragment="contentFragment">
<div class="org-register-page corporate-register"> <div class="org-register-page corporate-register">
<!-- Page Title Banner --> <!-- Page Title Banner -->
<div class="org-register-container"> <div class="org-register-container">
<!-- Hidden Fields --> <!-- Hidden Fields -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="registrationType" th:value="${registrationType}"/> <input type="hidden" name="registrationType" th:value="${registrationType}" />
<!-- Alert Container --> <!-- Alert Container -->
<div id="alertContainer"></div> <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>
<!-- Registration Card Wrapper -->
<div class="register-card-wrapper">
<!-- Info Notice -->
<div class="org-info-notice"> <div class="org-info-notice">
<div class="notice-icon-wrapper"> <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" /> <circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" /> <line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" /> <line x1="12" y1="16" x2="12.01" y2="16" />
</svg> </svg>
</div> </div>
<p class="notice-text">법인관리자는 API 신청 및 개발자 초대 등 모든 권한을 보유합니다.</p> <p class="notice-text">법인 회원 가입 후 서비스 또는 API 이용을 하실 수 있습니다.</p>
</div> </div>
<th:block th:if="${registrationType == 'corporate'}"> <!-- Agreement Section -->
<th:block th:replace="~{apps/register/components/orgUserInfoForm :: orgUserInfoForm}"></th:block> <th:block
th:replace="~{apps/register/userAgreementContent :: agreementContent(${termsOfUse}, ${privacyCollect})}">
</th:block> </th:block>
<!-- Action Buttons --> <form name="portalOrg" id="registerForm" role="form" th:action="@{/signup/portalOrg}" th:object="${portalOrg}"
<div class="org-action-buttons"> method="post">
<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 --> <!-- Corporate Basic Information Section -->
<div class="org-loading-overlay" id="loadingOverlay"> <div class="org-section-header org-section-header--agreement">
<div class="spinner"></div> <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> </div>
</div> </section>
</section>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:if="${error}" th:inline="javascript"> <script th:if="${error}" th:inline="javascript">
$(document).ready(function () { $(document).ready(function () {
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${ error }]]);
}); });
</script> </script>
<script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script> <script th:replace="~{apps/register/userAgreementContent :: agreementScript}"></script>
<script th:replace="~{apps/register/components/orgBasicInfoForm :: orgInfoScript}"></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/orgUserInfoForm :: orgUserInfoScript}"></script>
<script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script> <script th:replace="~{apps/register/components/emailChangeForm :: emailChangeScript}"></script>
<script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script> <script th:replace="~{apps/register/components/newUserInfoForm :: newUserScript}"></script>
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
let form = document.getElementById('registerForm'); let form = document.getElementById('registerForm');
let agreementForm = document.getElementById('agreementForm'); let agreementForm = document.getElementById('agreementForm');
let btn_register = document.querySelector('.btn_register'); let btn_register = document.querySelector('.btn_register');
let isAuthVerified = false; let isAuthVerified = false;
// registrationScenario를 hidden input에서 가져오도록 수정 // registrationScenario를 hidden input에서 가져오도록 수정
let registrationScenario = document.getElementById('registrationScenario')?.value || 'new'; let registrationScenario = document.getElementById('registrationScenario')?.value || 'new';
// hidden input의 값이 변경되면 registrationScenario 변수도 업데이트 // 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;
}
// 시나리오 값 추가
const scenarioInput = document.getElementById('registrationScenario'); const scenarioInput = document.getElementById('registrationScenario');
if (scenarioInput) { if (scenarioInput) {
const input = document.createElement('input'); scenarioInput.addEventListener('change', function () {
input.type = 'hidden'; registrationScenario = this.value;
input.name = 'registrationScenario'; console.log('Registration Scenario Updated:', registrationScenario);
input.value = scenarioInput.value; });
form.appendChild(input);
} else { // MutationObserver를 사용하여 값 변경 감지
const input = document.createElement('input'); const observer = new MutationObserver(function (mutations) {
input.type = 'hidden'; mutations.forEach(function (mutation) {
input.name = 'registrationScenario'; if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
input.value = registrationScenario; registrationScenario = scenarioInput.value;
form.appendChild(input); console.log('Registration Scenario Updated (from mutation):', registrationScenario);
}
});
});
observer.observe(scenarioInput, { attributes: true });
} }
// user와 org 필드를 따로 처리 // 시나리오별 필수 필드 정의
fields.user.forEach(fieldId => { const requiredFieldsByScenario = {
const element = document.getElementById(fieldId); new: {
if (element) { 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'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
input.name = fieldId; input.name = 'registrationScenario';
input.value = element.value; 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); form.appendChild(input);
} }
});
submitOrgFields.forEach(fieldId => { // user와 org 필드를 따로 처리
const element = document.getElementById(fieldId); fields.user.forEach(fieldId => {
if (element) { const element = document.getElementById(fieldId);
if (element.type === 'file') { if (element) {
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'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
input.name = fieldId; input.name = fieldId;
input.value = element.value; input.value = element.value;
form.appendChild(input); form.appendChild(input);
} }
} });
});
// Add confirmPassword manually based on the scenario submitOrgFields.forEach(fieldId => {
if (registrationScenario === 'retain') { const element = document.getElementById(fieldId);
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual'); if (element) {
if (passwordConfirmIndividual) { 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'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
input.name = 'confirmPassword'; input.name = 'termsOfUse';
input.value = passwordConfirmIndividual.value; input.value = document.getElementById('termsOfUse').checked;
form.appendChild(input); form.appendChild(input);
} }
} else if (registrationScenario === 'change') {
const passwordConfirmEmailChange = document.getElementById('passwordConfirmEmailChange'); if (document.getElementById('privacyCollect')) {
if (passwordConfirmEmailChange) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
input.name = 'confirmPassword'; input.name = 'privacyCollect';
input.value = passwordConfirmEmailChange.value; input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input); 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')) { function validateCommonFields() {
const input = document.createElement('input');
input.type = 'hidden'; if (!validatePhoneNumber('org') || !validatePhoneNumber('sc')) {
input.name = 'termsOfUse'; return false;
input.value = document.getElementById('termsOfUse').checked; }
form.appendChild(input);
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'); function validateForm() {
input.type = 'hidden'; const scenarioValidation = validateFormByScenario();
input.name = 'privacyCollect'; const commonValidation = validateCommonFields();
input.value = document.getElementById('privacyCollect').checked;
form.appendChild(input); console.log('Form validation result:', {
scenarioValidation,
commonValidation,
registrationScenario
});
return scenarioValidation && commonValidation;
} }
if (document.getElementById('notificationConsent')) { // 미입력/미충족 항목을 구체적으로 수집
const input = document.createElement('input'); function collectFormErrors() {
input.type = 'hidden'; const errors = [];
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 시스템 필드 추가 // 시나리오별 항목
const csrfToken = document.querySelector('input[name="_csrf"]'); if (registrationScenario === 'new') {
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 loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName'); const userNameElement = document.getElementById('userName');
const isEmailValid = loginIdElement ? isValidEmail(loginIdElement.value) : false; if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
const hasUserName = userNameElement ? userNameElement.value.trim() !== '' : false; if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
const isPasswordValid = document.getElementById('isPasswordValid').value.trim() === 'true'; if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
const isPasswordMatch = document.getElementById('isPasswordMatch').value.trim() === 'true'; if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
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');
} }
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()) { return errors;
console.log('Form validation passed, collecting form data...'); }
const form = collectFormData();
document.body.appendChild(form);
form.submit();
} else {
console.log('Form validation failed');
// 현재 상태 출력 let isSubmitting = false;
const scenarioValidation = validateFormByScenario();
const commonValidation = validateCommonFields();
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:', { // Prevent resubmission
scenarioValidation, if (isSubmitting) {
commonValidation, console.log('Form is already being submitted...');
registrationScenario, return;
isAuthVerified, }
isPasswordValid,
isPasswordMatch
});
const errors = collectFormErrors(); isSubmitting = true;
let errorMessage = errors.length > 0 btn_register.disabled = true;
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
// 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; isSubmitting = false;
btn_register.disabled = false; btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active'); document.getElementById('loadingOverlay').classList.remove('active');
} }
} catch (error) { });
console.error('Form submission error:', error);
// Reset submission state on error // 신규 이메일 중복 체크 응답 처리 함수
isSubmitting = false; function handleNewEmailValidationResponse(response) {
btn_register.disabled = false; if (response.valid) {
document.getElementById('loadingOverlay').classList.remove('active'); customPopups.showAlert(response.message || '신규 이메일 정보입니다.');
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
} }
});
// 신규 이메일 중복 체크 응답 처리 함수 // 인증 완료 이벤트 리스너
function handleNewEmailValidationResponse(response) { document.addEventListener('authVerified', function () {
if (response.valid) { isAuthVerified = true;
customPopups.showAlert(response.message || '신규 이메일 정보입니다.'); });
} else {
customPopups.showAlert(response.message || '이미 사용 중인 이메일입니다.');
}
}
// 인증 완료 이벤트 리스너 // 취소 버튼 클릭 시
document.addEventListener('authVerified', function () { document.getElementById('cancelButton').addEventListener('click', function () {
isAuthVerified = true; location.href = '/login';
});
}); });
</script>
// 취소 버튼 클릭 시 </th:block>
document.getElementById('cancelButton').addEventListener('click', function () {
location.href = '/login';
});
});
</script>
</th:block>
</body> </body>
</html>
</html>
@@ -24,25 +24,24 @@
<h3>초대 정보</h3> <h3>초대 정보</h3>
</div> </div>
<div class="account-recovery-card" style="margin-bottom: 32px;"> <div class="invitation_alert_banner">
<div class="account-recovery-result"> <div class="banner-content-wrapper">
<div class="result-header"> <div class="banner-icon-area">
<i class="fas fa-envelope-open-text result-icon" style="color: #1a73e8;"></i> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<h2 class="result-title" style="font-size: 18px;"> <circle cx="12" cy="12" r="10" fill="#E8F1FF" stroke="#1976D2" stroke-width="2"/>
<strong th:text="${userName}"></strong>님, 법인회원 초대가 도착했습니다. <path d="M12 8V13" stroke="#1976D2" stroke-width="2" stroke-linecap="round"/>
</h2> <circle cx="12" cy="16" r="1.25" fill="#1976D2"/>
</svg>
</div> </div>
<div class="banner-text-area">
<div class="result-info-box"> <strong>
<p class="info-text"> <span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
<strong th:text="${orgName}"></strong>에서 </strong>
DJBank API Portal 법인회원으로 초대하였습니다. <p>
</p> <strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
<p class="info-text" style="margin-top: 8px;">
초대를 수락하시면 법인회원으로 전환됩니다. 초대를 수락하시면 법인회원으로 전환됩니다.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,50 +1,62 @@
<!doctype html> <!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image"> <img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1> <h1>회원가입</h1>
</div> </div>
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="account-recovery-page"> <div class="account-recovery-page">
<div class="account-recovery-container"> <div class="account-recovery-container">
<div class="account-recovery-card"> <div class="account-recovery-card">
<!-- Result Content Area --> <!-- Result Content Area -->
<div class="account-recovery-result"> <div class="account-recovery-result">
<div class="result-header"> <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"> <svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"
<rect width="64" height="64" fill="url(#pattern0_1130_6799)"/> xmlns:xlink="http://www.w3.org/1999/xlink">
<defs> <rect width="64" height="64" fill="url(#pattern0_1130_6799)" />
<pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1"> <defs>
<use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)"/> <pattern id="pattern0_1130_6799" patternContentUnits="objectBoundingBox" width="1" height="1">
</pattern> <use xlink:href="#image0_1130_6799" transform="translate(0 0.104294) scale(0.00613497)" />
<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=="/> </pattern>
</defs> <image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none"
</svg> 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==" />
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입이 완료되었습니다.'">회원가입이 완료되었습니다.</h2> </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> </div>
<!-- Info Box --> <!-- Action Buttons -->
<div class="result-info-box" th:if="${registrationType == 'corporate'}"> <div class="form-actions">
<p class="info-text"> <a th:href="@{/}" class="submit-button">홈으로</a>
관리자의 확인 및 승인 이후<br>
이용하실 수 있습니다.
</p>
</div> </div>
</div> </div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/}" class="submit-button">홈으로</a>
</div>
</div> </div>
</div> </div>
</div> </th:block>
</th:block>
</body> </body>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
</th:block> </th:block>
</html>
</html>
@@ -37,181 +37,244 @@
<img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" /> <img th:src="@{/img/icon/icon_check_green.png}" alt="체크 아이콘" />
</div> </div>
<ul class="signup-info-list"> <ul class="signup-info-list">
<li>회원은 법인, 개인(개발자)분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li> <li>회원은 <strong>법인, 개인(개발자)</strong>분으로 구분되며, 개발자 포털에서 온라인으로 회원 가입 신청을 합니다.</li>
<li>포털 사용은 운영 담당자의 승인 후 가능합니다.</li> <li>포털 사용은 <strong>운영 담당자의 승인 후</strong> 가능합니다.</li>
<li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 직원(개발자)을 추가 등록해 주시기 바랍니다.</li> <li>법인 관리자는 회원 승인 이후, 포털을 실제 사용할 <strong>직원(개발자)을 추가 등록</strong>해 주시기 바랍니다.</li>
</ul> </ul>
</div> </div>
<!-- Vertical Timeline Steps --> <!-- Member Type Tabs -->
<div class="signup-timeline"> <div class="signup-tabs">
<!-- Step 1 --> <button type="button" class="signup-tab-btn active" data-tab="personal">개인 회원</button>
<div class="signup-step"> <button type="button" class="signup-tab-btn" data-tab="corporate">법인 회원</button>
<div class="signup-step__icon-box one"> </div>
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
xmlns="http://www.w3.org/2000/svg"> <!-- Timeline Container for fixed height spacing -->
<path <div class="signup-timeline-container">
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" <!-- Personal Member Timeline -->
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <div class="signup-timeline active" id="timeline-personal">
stroke-linejoin="round" /> <!-- Step 1 -->
<path <div class="signup-step">
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" <div class="signup-step__icon-box one">
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
stroke-linejoin="round" /> class="bi bi-person-add" viewBox="0 0 16 16">
<path d="M20 21V20C20 18.8954 20.8954 18 22 18C23.1046 18 24 18.8954 24 20V21" <path
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" 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" />
stroke-linejoin="round" /> <path
<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" />
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" </svg>
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" </div>
stroke-linejoin="round" /> <div class="signup-step__card">
</svg> */--> <div class="signup-step__card-header">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <span class="signup-step__card-num">STEP 01</span>
class="bi bi-person-add" viewBox="0 0 16 16"> <span class="signup-step__card-dot"></span>
<path <span class="signup-step__card-title">회원가입</span>
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" /> </div>
<path <p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
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" /> </div>
</svg>
</div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 01</div>
<div class="signup-step__card-title">회원가입</div>
</div> </div>
</div> </div>
<!-- Step 2 --> <!-- Corporate Member Timeline -->
<div class="signup-step"> <div class="signup-timeline" id="timeline-corporate">
<div class="signup-step__icon-box two"> <!-- Step 1 -->
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <div class="signup-step">
xmlns="http://www.w3.org/2000/svg"> <div class="signup-step__icon-box one">
<path <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
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" class="bi bi-person-add" viewBox="0 0 16 16">
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <path
stroke-linejoin="round" /> 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 <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" 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" />
stroke="#0049B4" stroke -width="1.6" stroke-linecap="round" </svg>
stroke-linejoin="round" /> </div>
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6" <div class="signup-step__card">
stroke-linecap="round" stroke-linejoin="round" /> <div class="signup-step__card-header">
</svg> */--> <span class="signup-step__card-num">STEP 01</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <span class="signup-step__card-dot"></span>
class="bi bi-journal-check" viewBox="0 0 16 16"> <span class="signup-step__card-title">회원가입</span>
<path fill-rule="evenodd" </div>
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" /> <p class="signup-step__card-desc">개발자 포털을 이용하기 위해 회원가입을 진행합니다.</p>
<path </div>
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>
<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 --> <!-- Step 2 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box three"> <div class="signup-step__icon-box two">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
xmlns="http://www.w3.org/2000/svg"> class="bi bi-journal-check" viewBox="0 0 16 16">
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6" <path fill-rule="evenodd"
stroke-linecap="round" stroke-linejoin="round" /> 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="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> 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="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <path
</svg> */--> 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 xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" </svg>
class="bi bi-code" viewBox="0 0 16 16"> </div>
<path <div class="signup-step__card">
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" /> <div class="signup-step__card-header">
</svg> <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>
<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 --> <!-- Step 3 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box four"> <div class="signup-step__icon-box three">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
xmlns="http://www.w3.org/2000/svg"> class="bi bi-code" viewBox="0 0 16 16">
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" <path
stroke-linejoin="round" /> 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" />
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" </svg>
stroke-linejoin="round" /> </div>
</svg> */--> <div class="signup-step__card">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <div class="signup-step__card-header">
class="bi bi-display" viewBox="0 0 16 16"> <span class="signup-step__card-num">STEP 03</span>
<path <span class="signup-step__card-dot"></span>
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" /> <span class="signup-step__card-title">API / APP 사용 신청</span>
</svg> </div>
<p class="signup-step__card-desc">사용할 API를 선택하고 테스트용 키를 발급받습니다.</p>
</div>
</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 --> <!-- Step 4 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box five"> <div class="signup-step__icon-box four">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/--> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
<!-- xmlns="http://www.w3.org/2000/svg">--> class="bi bi-display" viewBox="0 0 16 16">
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/--> <path
<!-- stroke-linecap="round" stroke-linejoin="round" />--> 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" />
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/--> </svg>
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/--> </div>
<!-- stroke-linecap="round" stroke-linejoin="round" />--> <div class="signup-step__card">
<!--/* </svg>*/--> <div class="signup-step__card-header">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <span class="signup-step__card-num">STEP 04</span>
class="bi bi-ui-checks-grid" viewBox="0 0 16 16"> <span class="signup-step__card-dot"></span>
<path <span class="signup-step__card-title">개발 / 테스트</span>
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" /> </div>
</svg> <p class="signup-step__card-desc">제공된 개발 도구로 자유롭게 연동을 테스트합니다.</p>
</div>
</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 --> <!-- Step 5 -->
<div class="signup-step"> <div class="signup-step">
<div class="signup-step__icon-box six"> <div class="signup-step__icon-box five">
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
xmlns="http://www.w3.org/2000/svg"> class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6" <path
stroke-linecap="round" stroke-linejoin="round" /> 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" />
<path d="M7 13H25" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> </svg>
<path d="M12 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> </div>
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <div class="signup-step__card">
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <div class="signup-step__card-header">
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" /> <span class="signup-step__card-num">STEP 05</span>
</svg> */--> <span class="signup-step__card-dot"></span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <span class="signup-step__card-title">운영신청 및 심사</span>
class="bi bi-airplane" viewBox="0 0 16 16"> </div>
<path <p class="signup-step__card-desc">실운영 환경 적용을 위해 최종 심사를 요청합니다.</p>
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" /> </div>
</svg>
</div> </div>
<div class="signup-step__card">
<div class="signup-step__card-num">STEP 06</div> <!-- Step 6 -->
<div class="signup-step__card-title">서비스 개시(예약)</div> <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> </div>
</div> </div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) --> <!-- 회원 구분별 이용 가능 서비스 -->
<section class="signup-roles-tbd"> <section class="signup-roles-table-section">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2> <h2 class="signup-roles-table-section__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder"> <div class="signup-roles-table-wrapper">
<span class="signup-roles-tbd__badge">TBD</span> <table class="signup-roles-table">
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p> <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> </div>
</section> </section>
@@ -225,6 +288,31 @@
</body> </body>
<th:block layout:fragment="contentScript"> <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> </th:block>
</html> </html>
@@ -12,7 +12,7 @@
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> --> <!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> -->
<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('statistics')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content"> <div class="app-management-content">
<section class="api-statistics-container"> <section class="api-statistics-container">
@@ -24,7 +24,8 @@
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span> <span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}" <span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span> th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span> <span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전,
12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest" <span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span> th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div> </div>
@@ -13,8 +13,8 @@
</section> </section>
<th:block layout:fragment="contentFragment"> <th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper"> <div class="signup-guide-v2">
<div class="service-main app-management-layout"> <div class="service-main container" style="padding-top: 70px;">
<!-- Sidebar --> <!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block> <th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
@@ -22,9 +22,13 @@
<!-- Content Area --> <!-- Content Area -->
<div class="app-management-content"> <div class="app-management-content">
<div class="step1-wrap"> <div class="step1-wrap">
<!-- Header Row with Title and Dev Guide Link -->
<!-- Title --> <div class="webhook-header-row">
<h2 class="s1-title">Webhook 관리</h2> <h2 class="s1-title">Webhook 관리</h2>
<a class="webhook-guide-btn" th:href="@{/service/webhook-dev-guide}">
웹훅 개발가이드 - 수신.서명검증.응답 규칙 보러가기
</a>
</div>
<!-- Empty Card --> <!-- Empty Card -->
<div class="s1-form-card"> <div class="s1-form-card">
@@ -38,18 +42,11 @@
</p> </p>
</div> </div>
</div> </div>
<!-- 액션 --> <!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_WEBHOOK')"> <div class="s1-actions" sec:authorize="hasRole('ROLE_WEBHOOK')">
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button> <button type="button" class="s1-btn-next-figma" id="requestWebhook">Webhook 신청</button>
</div> </div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap --> </div><!-- /step1-wrap -->
</div> </div>
</div> </div>
@@ -1,7 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}"> layout:decorate="~{layout/djbank_title_layout}">
<body> <body>
@@ -36,7 +35,8 @@
<div class="webhook-card-head"> <div class="webhook-card-head">
<div class="head-title-group"> <div class="head-title-group">
<!-- List Icon SVG --> <!-- List Icon SVG -->
<svg class="head-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="head-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"></line> <line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line> <line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line> <line x1="8" y1="18" x2="21" y2="18"></line>
@@ -47,12 +47,13 @@
<h3>등록된 Webhook</h3> <h3>등록된 Webhook</h3>
</div> </div>
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}" <span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
th:text="|등록일 : ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일 : 2026-07-27</span> th:text="|등록일 : ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일
: 2026-07-27</span>
</div> </div>
<!-- Vertical Content Fields --> <!-- Vertical Content Fields -->
<div class="webhook-card-content"> <div class="webhook-card-content">
<!-- 수신 URL --> <!-- 수신 URL -->
<div class="webhook-info-group"> <div class="webhook-info-group">
<span class="group-label">수신 URL</span> <span class="group-label">수신 URL</span>
@@ -181,14 +182,14 @@
openModal('Secret 재발급', openModal('Secret 재발급',
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.', '재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
function (pw) { function (pw) {
post('/webhook/regenerate-secret', pw).then(function (res) { post('/webhook/regenerate-secret', pw).then(function (res) {
if (res.success) { if (res.success) {
document.getElementById('secretMasked').textContent = res.secret; document.getElementById('secretMasked').textContent = res.secret;
closeModal(); closeModal();
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.'); alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); } } else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); }); }).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
}); });
}); });
// 삭제 // 삭제
@@ -205,4 +206,5 @@
</script> </script>
</th:block> </th:block>
</body> </body>
</html>
</html>
@@ -1,106 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<!-- 타이틀 배너 (Figma node 1:16) -->
<div class="as-title-banner">
<div class="as-title-banner-inner">
<h1>API Status</h1>
<div class="as-title-meta" th:if="${lastFireAt != null or lastStatusChangedAt != null}">
<p class="as-title-meta-main" th:if="${lastFireAt != null}">
<span class="as-title-dot"></span>
<span>마지막 점검 </span>
<strong th:text="${lastFireRelative}">-</strong>
</p>
<p class="as-title-meta-sub" th:if="${lastFireAt != null}"
th:text="${#temporals.format(lastFireAt, 'yyyy-MM-dd HH:mm')} + ' KST · API 상태 모니터링 실행 시각'">-</p>
<p class="as-title-meta-sub" th:if="${lastStatusChangedAt != null}"
th:text="'마지막 상태 변경 ' + ${#temporals.format(lastStatusChangedAt, 'yyyy-MM-dd HH:mm')} + ' KST'">-</p>
</div>
</div>
</div>
<div class="api-status" id="apiStatusPage"
th:attr="data-window-days=${windowDays},data-base=@{/apistatus},data-api-detail-base=@{/apis/detail}"
th:classappend="${authenticated} ? 'is-authenticated' : ''">
<!-- ❶ 진행 중 장애 -->
<div id="activeIncidents"></div>
<!-- ❷ 90일 서비스 상태 -->
<section class="as-card as-uptime">
<div class="as-card-head">
<div>
<h2 th:text="|최근 ${windowDays}일 서비스 상태|">최근 90일 서비스 상태</h2>
<p class="as-desc">막대 1개 = 하루 · 장애·지연·점검이 있던 날만 색으로 표시됩니다.
막대를 클릭하면 그 날짜의 이슈 이력으로 이동합니다.</p>
</div>
</div>
<div class="as-bar-chart" id="uptimeChart"></div>
<div class="as-axis">
<span id="uptimeFromLabel" th:text="|${windowDays}일 전|">90일 전</span>
<span>오늘</span>
</div>
<div class="as-legend">
<span><span class="as-sw is-ok"></span>정상</span>
<span><span class="as-sw is-outage"></span>장애</span>
<span><span class="as-sw is-degraded"></span>지연</span>
<span><span class="as-sw is-maintenance"></span>점검</span>
</div>
</section>
<!-- ❸ 점검 사항 -->
<section>
<div class="as-section-title">
<h2>점검 사항</h2>
<span class="as-desc">예정 또는 진행 중인 점검</span>
<span class="as-count" id="maintenanceCount"></span>
</div>
<div class="as-maint-list" id="maintenanceList"></div>
</section>
<!-- ❹ My APIs (로그인 사용자) -->
<section class="as-card as-my-apis" id="myApisCard" th:if="${authenticated}" style="display:none">
<div class="as-card-head">
<div>
<h2>My APIs</h2>
<p class="as-desc">우리 기관이 이용 중인 API 의 현재 상태 · 행을 클릭하면 해당 API 의 이슈 이력을 볼 수 있습니다.</p>
</div>
<span class="as-count" id="myApisCount"></span>
</div>
<table>
<thead>
<tr>
<th scope="col">API</th>
<th scope="col">현재 상태</th>
<th scope="col" class="as-num" th:text="|${windowDays}일 가동률|">90일 가동률</th>
</tr>
</thead>
<tbody id="myApisBody"></tbody>
</table>
</section>
<!-- ❺ 지난 이슈 사항 -->
<section>
<div class="as-section-title">
<h2>지난 이슈 사항</h2>
<span class="as-desc">종결된 장애·점검 내역</span>
<span class="as-count">최근 5건</span>
</div>
<div class="as-issue-list" id="recentIssueList"></div>
<a class="as-more-link" th:href="@{/apistatus/issues}">전체 이력 보기 →</a>
</section>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:src="@{/js/djb/api-status.js}"></script>
</th:block>
</body>
</html>
@@ -1,118 +0,0 @@
<!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>
<th:block layout:fragment="contentFragment">
<!-- 타이틀 배너 (메인과 동일 스타일) -->
<div class="as-title-banner">
<div class="as-title-banner-inner">
<h1>전체 이슈 이력</h1>
<div class="as-title-meta">
<p class="as-title-meta-main">
<strong th:text="|최근 ${windowDays}일|">최근 90일</strong><span> 장애·점검 내역</span>
</p>
<p class="as-title-meta-sub">날짜·API·유형으로 필터링할 수 있습니다</p>
</div>
</div>
</div>
<div class="api-status" id="issueHistoryPage"
th:attr="data-window-days=${windowDays},
data-base=@{/apistatus},
data-api-detail-base=@{/apis/detail},
data-today=${today},
data-min-date=${minDate},
data-selected-date=${selectedDate} ?: '',
data-selected-api=${selectedApiId} ?: '',
data-selected-kind=${selectedKind} ?: ''">
<!-- 90일 이슈 인덱스 -->
<section class="as-card">
<div class="as-card-head">
<div>
<h2 th:text="|${windowDays}일 이슈 인덱스|">90일 이슈 인덱스</h2>
<p class="as-desc">이슈가 있던 날짜만 색으로 표시됩니다. 막대를 클릭하면 그 날짜로 필터링합니다.</p>
</div>
<div class="as-desc">현재 선택: <b id="selectedDateLabel">전체 기간</b></div>
</div>
<div class="as-index-bar" id="issueIndexBar"></div>
<div class="as-axis">
<span th:text="|${windowDays}일 전|">90일 전</span>
<span>오늘</span>
</div>
<div class="as-legend">
<span><span class="as-sw is-none"></span>정상 (이슈 없음)</span>
<span><span class="as-sw is-outage"></span>장애만</span>
<span><span class="as-sw is-degraded"></span>지연만</span>
<span><span class="as-sw is-maintenance"></span>점검만</span>
<span><span class="as-sw is-mixed"></span>복합 (2종 이상)</span>
</div>
</section>
<!-- 필터: 날짜 + API + 유형 -->
<div class="as-filter-bar">
<div class="as-filter-field" id="dateField">
<label class="as-filter-label" for="filterDateInput">날짜</label>
<div class="as-field-control">
<input type="date" id="filterDateInput" class="as-input"
th:attr="min=${minDate},max=${today}">
<button type="button" class="as-field-clear" id="filterDateClear"
title="날짜 선택 해제" aria-label="날짜 선택 해제">×</button>
</div>
</div>
<div class="as-filter-field as-combo" id="apiCombo">
<label class="as-filter-label" for="filterApiInput">API</label>
<div class="as-field-control">
<!-- 레이아웃에 비밀번호 팝업(input[type=password])이 늘 포함돼 있어 브라우저가
이 입력을 계정(아이디) 칸으로 오인한다. autocomplete=off 는 Chrome 이 무시하므로
new-password + 초기 readonly(focus 시 해제, JS) 조합으로 자동완성을 차단한다 -->
<input type="search" id="filterApiInput" class="as-input" readonly
autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore data-form-type="other"
placeholder="전체 API (API 명 입력)" aria-expanded="false"
role="combobox" aria-controls="filterApiList">
<button type="button" class="as-field-clear" id="filterApiClear"
title="API 선택 해제" aria-label="API 선택 해제">×</button>
<ul class="as-combo-list" id="filterApiList" role="listbox" hidden></ul>
</div>
</div>
<div class="as-filter-field">
<label class="as-filter-label" for="filterKindSelect">유형</label>
<select id="filterKindSelect" class="as-input">
<option value="">전체 유형</option>
<option value="INCIDENT">장애만</option>
<option value="DELAY">지연만</option>
<option value="MAINTENANCE">점검만</option>
</select>
</div>
<div class="as-filter-right">
<span class="as-filter-count"><b id="issueTotalCount">0</b></span>
<button type="button" class="as-btn" id="filterResetBtn">초기화</button>
</div>
</div>
<!-- 필터 중인 API 가 포탈에 게시되지 않은 경우 안내 -->
<div class="as-unlisted-banner" id="unlistedApiBanner" style="display:none">
<span class="as-unlisted-icon" aria-hidden="true"></span>
<span><b id="unlistedApiName"></b> 은(는) 포탈에 게시되지 않은 API 입니다.
이슈 이력은 조회되지만 API 상세 정보는 제공되지 않습니다.</span>
</div>
<!-- 이슈 목록 -->
<div class="as-issue-list" id="issueList"></div>
<div class="as-pager" id="issuePager"></div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:src="@{/js/djb/api-status-issues.js}"></script>
</th:block>
</body>
</html>
@@ -1,41 +1,60 @@
<!doctype html> <!doctype html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<body>
<footer th:fragment="footerFragment" class="global-footer">
<div class="container">
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do"
target="_blank" rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3"
stroke-linecap="round" stroke-linejoin="round"/>
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section --> <body>
<div class="footer-right"> <footer th:fragment="footerFragment" class="global-footer">
<div class="footer-related-sites"> <div class="container">
<select class="related-sites-select"> <div class="footer-content">
<option>DJBank 관련 사이트</option> <!-- Left Section -->
<option>DJBank 홈페이지</option> <div class="footer-left">
<option>DJBank 인터넷뱅킹</option> <img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<option>DJBank 모바일뱅킹</option> <div class="footer-links">
</select> <a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg
class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"
stroke-linejoin="round" />
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div> </div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div> <body>
</div> <footer th:fragment="footerFragment" class="global-footer">
</div> <div class="container">
</footer> <div class="footer-content">
</body> <!-- Left Section -->
</html> <div class="footer-left">
<img src="/img/logo/logo-jjb_white.png" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section -->
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
</footer>
</body>
</html>
@@ -17,11 +17,12 @@
<!--/* </a>*/--> <!--/* </a>*/-->
<!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/--> <!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
<!--/* </div>*/--> <!--/* </div>*/-->
<div > <div>
<div class="logo"> <div class="logo">
<a th:href="@{/}" class="logo-wrapper"> <a th:href="@{/}" class="mobile-logo-link">
<span class="logo-text-bold">DJBank</span> <span class="logo-text-thin">API Portal</span> <img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
</a> </a>
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
</div> </div>
</div> </div>
</div> </div>
@@ -47,7 +48,7 @@
<li><a th:href="@{/partnership}">피드백/개선요청</a></li> <li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul> </ul>
</li> </li>
<li><a th:href="@{/apistatus}" class="nav-link">API Status</a></li> <li><a href="#" class="nav-link">API Status</a></li>
</ul> </ul>
</nav> </nav>
@@ -231,7 +232,7 @@
</li> </li>
<!-- API Status --> <!-- API Status -->
<li class="drawer-menu-item"> <li class="drawer-menu-item">
<a th:href="@{/apistatus}" class="drawer-menu-btn">API Status</a> <a href="#" class="drawer-menu-btn">API Status</a>
</li> </li>
<!-- 마이페이지 (Authenticated Only) --> <!-- 마이페이지 (Authenticated Only) -->
@@ -53,7 +53,7 @@
class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a> class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''" <a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 신청 관리</a> class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">API 신청 관리</a>
<a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''" <a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a> class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
@@ -25,14 +25,10 @@
</p> </p>
<!-- Password Input Field --> <!-- Password Input Field -->
<!-- 초기 type=text: 이 팝업은 모든 페이지 DOM 에 상주하므로 type=password 로 두면
브라우저가 페이지의 다른 텍스트 입력을 아이디 칸으로 오인해 계정 자동완성을 띄운다.
팝업을 열 때 JS(custom-popups.js)가 password 로 전환하고 닫을 때 되돌린다. -->
<div class="pop_input_group"> <div class="pop_input_group">
<input type="text" <input type="password"
id="passwordPopupInput" id="passwordPopupInput"
class="pop_input_field" class="pop_input_field"
autocomplete="new-password"
placeholder="비밀번호를 입력하세요"> placeholder="비밀번호를 입력하세요">
<div id="passwordPopupError" class="error-message"></div> <div id="passwordPopupError" class="error-message"></div>
</div> </div>