장애·지연 구분 및 집계 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 지연 유형 로직 및 UI/스타일 업데이트
- JPQL 조회와 공개 조건 로직 전면 개정
This commit is contained in:
Rinjae
2026-08-03 16:57:57 +09:00
parent 4916bdf4af
commit 07fbe1ba54
11 changed files with 100 additions and 85 deletions
@@ -15,31 +15,52 @@ import java.util.List;
import java.util.Optional;
/**
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건(공지 게시 + 초안 아님)을 항상 적용한다.
* 개발자포탈 API Status 화면 전용 조회. 읽기 전용이며 공개 조건을 항상 적용한다.
*
* <p>PTL_NOTICE 와의 조인은 두 가지 역할을 한다.
* (1) 관리자가 미게시(USE_YN='N')한 장애/점검을 숨긴다.
* (2) 공지가 삭제된 고아 장애 행을 자연히 제외한다 (물리 FK 없음 - ADR-F10).</p>
* <p>공개 조건({@link #VISIBLE})은 두 갈래다.
* <ul>
* <li>공지가 붙은 이슈(장애·점검) - 그 공지가 게시(USE_YN='Y')되어 있어야 한다.
* 공지가 삭제된 고아 행도 이 조건에서 자연히 빠진다 (물리 FK 없음 - ADR-F10).</li>
* <li>공지가 없는 이슈(지연) - 자동 탐지 전용이라 검수할 공지가 없다.
* 초안(DRAFT_YN='Y')만 아니면 노출한다.</li>
* </ul>
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.noticeId = n.id AND n.useYn = 'Y' AND i.draftYn = 'N' ";
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
String KIND_DELAY = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.DELAY";
String KIND_MAINTENANCE = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE";
/** 종결 조건 - 장애·지연은 STATE 로, 점검은 종료 시각 경과로 판정한다 */
String CLOSED_CONDITION = " ((i.kind IN (" + KIND_INCIDENT + ", " + KIND_DELAY + ")"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = " + KIND_MAINTENANCE
+ " AND i.endAt IS NOT NULL AND i.endAt < :now)) ";
/**
* 진행 중 장애 (P3)
* 진행 중 이슈 (P3). 장애와 지연을 함께 본다.
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND i.kind IN :kinds"
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kind") IncidentKind kind,
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.kind = :kind"
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
@@ -48,21 +69,15 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
@Param("now") LocalDateTime now);
/**
* 종결된 이슈 (P6). 장애 종결 상태, 점검은 종료 시각 경과.
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
*/
@Query(value = "SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query(value = "SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE"
+ " AND i.endAt IS NOT NULL AND i.endAt < :now))"
+ " AND " + CLOSED_CONDITION
+ " ORDER BY i.startedAt DESC",
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i, PortalNotice n"
countQuery = "SELECT COUNT(i) FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND ((i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT"
+ " AND i.state IN :closedStates)"
+ " OR (i.kind = com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.MAINTENANCE"
+ " AND i.endAt IS NOT NULL AND i.endAt < :now))")
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
Pageable pageable);
@@ -71,7 +86,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
* 기간과 겹치는 모든 이슈 (P2 가동률 집계, P9 일자 인덱스, P10 목록).
* 진행 중(END_AT IS NULL) 이슈도 포함한다.
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.startedAt < :to"
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
@@ -82,7 +97,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
*/
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, PortalNotice n, DjbApistatusIncidentApi a"
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
+ " WHERE " + VISIBLE
+ " AND a.incidentId = i.incidentId"
+ " AND a.apiId = :apiId"
@@ -96,7 +111,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
/**
* 공개 상세 (P7)
*/
@Query("SELECT i FROM DjbApistatusIncident i, PortalNotice n"
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
@@ -149,10 +149,10 @@ public class ApiCurrentStatusService {
return names;
}
/** 진행 중(미종결) 장애 중 API 별로 가장 심각한 한 건 */
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
@@ -204,12 +204,12 @@ public class ApiCurrentStatusService {
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() == IncidentKind.INCIDENT)
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
return Collections.emptyMap();
@@ -233,14 +233,15 @@ public class ApiCurrentStatusService {
}
/**
* 진행 중 장애 + 점검 여부로 현재 상태를 정한다.
* 진행 중 장애·지연 + 점검 여부로 현재 상태를 정한다.
*
* <p>자동 탐지 지연(INTERFACE_ID {@code DELAY_START:}) 과 모니터링 단계는 장애가 아닌 지연으로 본다.
* 이슈 이력 화면의 "지연/장애" 필터와 같은 기준이다.</p>
* <p>KIND 가 DELAY 면 지연이다. 장애(INCIDENT)라도 모니터링 단계면 이미 완화된 상태라
* 지연으로 표기한다 - 이슈 이력의 유형 필터(KIND 기준)와는 이 지점만 다르다.</p>
*/
private String resolveStatus(DjbApistatusIncident open, boolean underMaintenance) {
if (open != null) {
boolean degraded = open.getState() == IncidentState.MONITORING || ApiStatusSupport.isDelay(open);
boolean degraded = open.getKind() == IncidentKind.DELAY
|| open.getState() == IncidentState.MONITORING;
return degraded ? ApiStatusSupport.STATUS_DEGRADED : ApiStatusSupport.STATUS_OUTAGE;
}
if (underMaintenance) {
@@ -250,14 +251,14 @@ public class ApiCurrentStatusService {
}
/**
* 같은 API 에 열린 장애가 여럿일 때의 우선순위 (낮을수록 심각).
* 지연으로 판정되는 건은 장애보다 뒤로 민다.
* 같은 API 에 열린 이슈가 여럿일 때의 우선순위 (낮을수록 심각).
* 지연은 장애보다 뒤로 민다.
*/
private int severity(DjbApistatusIncident incident) {
if (incident == null) {
return 99;
}
int base = ApiStatusSupport.isDelay(incident) ? 10 : 0;
int base = incident.getKind() == IncidentKind.DELAY ? 10 : 0;
IncidentState state = incident.getState();
if (state == null) {
return base + 9;
@@ -125,7 +125,7 @@ public class ApiStatusAssembler {
}
/**
* 지난 이슈 카드. 장애 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
* 지난 이슈 카드. 장애·지연은 타임라인 전체를 붙이고 점검은 붙이지 않는다 (ADR-F15).
*/
public List<PastIssueCardDTO> toPastIssueCards(List<DjbApistatusIncident> incidents) {
if (incidents.isEmpty()) {
@@ -135,7 +135,7 @@ public class ApiStatusAssembler {
Map<Long, List<AffectedApiDTO>> apis = loadApis(ids);
List<Long> incidentKindIds = incidents.stream()
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
Map<Long, List<TimelineEntryDTO>> timelines = loadTimelines(incidentKindIds);
@@ -30,9 +30,6 @@ import java.util.stream.Collectors;
@Transactional(readOnly = true)
public class ApiStatusIssueHistoryService {
/** 지연은 IncidentKind 가 아니라 INCIDENT 의 하위 구분이므로 필터 값만 따로 둔다 */
private static final String KIND_DELAY = "DELAY";
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
@@ -85,19 +82,17 @@ public class ApiStatusIssueHistoryService {
* 인덱스바 1칸에 이슈 1건을 집계한다. 유형은 이슈 목록 필터와 같은 세 축(장애/지연/점검)으로 가른다.
*/
private void tally(IssueDateEntryDTO entry, DjbApistatusIncident incident) {
String kind;
if (incident.getKind() == IncidentKind.MAINTENANCE) {
entry.setMntCount(entry.getMntCount() + 1);
kind = IncidentKind.MAINTENANCE.name();
} else if (ApiStatusSupport.isDelay(incident)) {
entry.setDlyCount(entry.getDlyCount() + 1);
kind = KIND_DELAY;
} else {
entry.setIncCount(entry.getIncCount() + 1);
kind = IncidentKind.INCIDENT.name();
IncidentKind kind = incident.getKind();
if (kind == null) {
return;
}
if (!entry.getKinds().contains(kind)) {
entry.getKinds().add(kind);
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());
}
}
@@ -148,24 +143,14 @@ public class ApiStatusIssueHistoryService {
/**
* 유형 필터. 미지정/ALL/알 수 없는 값은 전체(null)로 본다.
*
* <p>지연(DELAY)은 별도 KIND 가 아니라 자동 탐지 시 INCIDENT 로 흡수된다(ADR-F12).
* 자동 탐지 지연 건은 INTERFACE_ID 가 {@code DELAY_START:} 로 시작하므로 이를 기준으로 가른다.
* "장애만" 은 지연을 제외한 INCIDENT 다.</p>
* 필터 값은 {@link IncidentKind} 이름 그대로다 (INCIDENT / DELAY / MAINTENANCE).
*/
private java.util.function.Predicate<DjbApistatusIncident> kindFilter(String kind) {
if (StringUtils.isBlank(kind) || "ALL".equalsIgnoreCase(kind)) {
return null;
}
if (KIND_DELAY.equalsIgnoreCase(kind)) {
return ApiStatusSupport::isDelay;
}
if ("INCIDENT".equalsIgnoreCase(kind)) {
return incident -> incident.getKind() == IncidentKind.INCIDENT
&& !ApiStatusSupport.isDelay(incident);
}
try {
IncidentKind selected = IncidentKind.valueOf(kind.toUpperCase());
IncidentKind selected = IncidentKind.valueOf(kind.trim().toUpperCase());
return incident -> incident.getKind() == selected;
} catch (IllegalArgumentException e) {
return null;
@@ -26,11 +26,11 @@ public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
/** P3 - 진행 중 장애 */
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.INCIDENT, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
return assembler.toActiveIncidents(incidents, now);
}
@@ -31,13 +31,6 @@ public final class ApiStatusSupport {
public static final String STATUS_OUTAGE = "OUTAGE";
public static final String STATUS_MAINTENANCE = "MAINTENANCE";
/**
* 자동 탐지 지연 건의 INTERFACE_ID prefix (admin ApiStatusDetectionService 가 event 명으로 생성).
*
* <p>지연(DELAY)은 별도 KIND 가 아니라 INCIDENT 로 흡수되므로(ADR-F12) 이 prefix 로 가른다.</p>
*/
public static final String DELAY_INTERFACE_PREFIX = "DELAY_START:";
private ApiStatusSupport() {
}
@@ -45,12 +38,22 @@ public final class ApiStatusSupport {
return LocalDateTime.now(ZONE);
}
/** 자동 탐지된 지연 건인지 여부 (장애와 구분) */
/** 지연 건인지 여부 (장애와 구분) */
public static boolean isDelay(DjbApistatusIncident incident) {
return incident != null
&& incident.getKind() == IncidentKind.INCIDENT
&& incident.getInterfaceId() != null
&& incident.getInterfaceId().startsWith(DELAY_INTERFACE_PREFIX);
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;
}
}
/**
@@ -64,9 +64,10 @@ public class ApiStatusUptimeService {
LocalDateTime now = ApiStatusSupport.now();
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.filter(incident -> incident.getKind() == IncidentKind.INCIDENT)
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
Map<String, List<long[]>> intervalsByApi = new HashMap<>();
@@ -135,7 +136,7 @@ public class ApiStatusUptimeService {
maintenanceIntervals.add(interval);
} else {
incidentIntervals.add(interval);
if (ApiStatusSupport.isDelay(incident)) {
if (incident.getKind() == IncidentKind.DELAY) {
delayIntervals.add(interval);
} else {
outageIntervals.add(interval);
+6
View File
@@ -27221,6 +27221,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.api-status .as-issue-card.is-incident {
border-left: 4px solid var(--as-err);
}
.api-status .as-issue-card.is-degraded {
border-left: 4px solid var(--as-warn);
}
.api-status .as-issue-card.is-maintenance {
border-left: 4px solid var(--as-info);
}
@@ -27243,6 +27246,9 @@ input[type=checkbox]:checked + .custom-checkbox {
.api-status .as-issue-card.is-incident .as-issue-title {
color: var(--as-err);
}
.api-status .as-issue-card.is-degraded .as-issue-title {
color: var(--as-warn);
}
.api-status .as-issue-card.is-maintenance .as-issue-title {
color: var(--as-info);
}
@@ -11,7 +11,8 @@
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
const PAGE_SIZE = 10;
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
@@ -191,7 +192,7 @@
}
function issueCardHtml(issue) {
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
let inner;
if (issue.kind === 'MAINTENANCE') {
@@ -12,7 +12,8 @@
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
const linkableApiIds = new Set();
const KIND_LABEL = { INCIDENT: '장애', MAINTENANCE: '점검' };
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
/** 진행 중 장애 카드 노출 상한. 초과분은 전체 이력 링크로 넘긴다. */
const ACTIVE_INCIDENT_LIMIT = 1;
@@ -262,8 +263,8 @@
// ---------------- ❺ 지난 이슈 사항 ----------------
function issueCardHtml(issue) {
const kindClass = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindBadge = issue.kind === 'MAINTENANCE' ? 'is-maintenance' : 'is-incident';
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
const kindBadge = kindClass;
let inner;
if (issue.kind === 'MAINTENANCE') {
@@ -495,6 +495,7 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
&.is-incident { border-left: 4px solid var(--as-err); }
&.is-degraded { border-left: 4px solid var(--as-warn); }
&.is-maintenance { border-left: 4px solid var(--as-info); }
.as-issue-meta-row {
@@ -516,6 +517,7 @@
}
&.is-incident .as-issue-title { color: var(--as-err); }
&.is-degraded .as-issue-title { color: var(--as-warn); }
&.is-maintenance .as-issue-title { color: var(--as-info); }
}