diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java index 636f412..ad25d18 100644 --- a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java @@ -32,6 +32,7 @@ import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusInc import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository; import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository; import com.eactive.apim.portal.portalNotice.entity.PortalNotice; +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow; @@ -39,7 +40,13 @@ import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow; * API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다. * *

탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를 - * {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 초안으로 기록한다.

+ * {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 로 기록한다.

+ * + *

자동 등록 여부와 공지 게시 시점은 PTL_PROPERTY + * ({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 로 정한다. + * {@code OFF}면 자동 등록을 하지 않고, {@code DRAFT}(기본)면 관리자가 공지사항 관리에서 + * 검수·게시해야 개발자포탈에 노출되며, {@code PUBLISH}면 탐지 즉시 노출된다. + * {@code OFF} 여도 이미 열려있는 장애의 복구 처리(ERROR_END/DELAY_END)는 계속 동작한다.

* *

점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다 * ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정). @@ -70,6 +77,24 @@ public class ApiStatusDetectionService { private static final String NOTICE_TYPE_INCIDENT = "3"; private static final int NOTICE_SUBJECT_MAX_LENGTH = 255; + /** 자동 탐지 공지 처리 방식 프로퍼티 그룹 (개발자포탈과 동일 그룹을 쓴다) */ + public static final String PROPERTY_GROUP = "Portal"; + + /** 자동 탐지 장애/지연의 공지 처리 방식 - OFF / DRAFT / PUBLISH */ + public static final String KEY_AUTO_INCIDENT_MODE = "djb.apistatus.auto-incident-mode"; + + /** 자동 등록 안 함. 신규 등록·병합 모두 하지 않는다 (복구 처리는 계속 동작) */ + public static final String MODE_OFF = "OFF"; + + /** 초안만 생성. 관리자가 공지사항 관리에서 검수·게시해야 개발자포탈에 노출된다 */ + public static final String MODE_DRAFT = "DRAFT"; + + /** 초안 생성 후 곧바로 게시. 검수 없이 개발자포탈에 즉시 노출된다 */ + public static final String MODE_PUBLISH = "PUBLISH"; + + /** 오탐이 그대로 고객에게 노출되지 않도록 기본은 검수(DRAFT) */ + private static final String DEFAULT_AUTO_INCIDENT_MODE = MODE_DRAFT; + @Autowired private DjbApistatusIncidentRepository incidentRepository; @@ -85,6 +110,9 @@ public class ApiStatusDetectionService { @Autowired private ApiStatusRepository apiStatusRepository; + @Autowired + private PortalPropertyService portalPropertyService; + /** * 이벤트 감지 * @param event "CONTROL_START" : 점검시작 @@ -113,10 +141,10 @@ public class ApiStatusDetectionService { switch (event) { case EVENT_ERROR_START: - openIncident(event, targets, "에러율 임계 초과", "장애"); + openIncident(event, IncidentKind.INCIDENT, targets, "에러율 임계 초과", "장애"); break; case EVENT_DELAY_START: - openIncident(event, targets, "응답시간 임계 초과", "응답지연"); + openIncident(event, IncidentKind.DELAY, targets, "응답시간 임계 초과", "응답지연"); break; case EVENT_ERROR_END: case EVENT_DELAY_END: @@ -133,12 +161,12 @@ public class ApiStatusDetectionService { } /** - * 미종결 장애로 남아있는 API 목록 (탐지 Job 폴링용) + * 미종결 장애·지연으로 남아있는 API 목록 (탐지 Job 폴링용) */ @Transactional(transactionManager = "transactionManagerForEMS", readOnly = true) public List remainDownApiIds() { - List openIncidents = - incidentRepository.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES); + List openIncidents = incidentRepository + .findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES); if (openIncidents.isEmpty()) { return Collections.emptyList(); } @@ -157,7 +185,20 @@ public class ApiStatusDetectionService { // ────────────── 장애 발생 ────────────── - private void openIncident(String event, List apiIds, String summary, String titleKeyword) { + /** + * 신규 이슈 등록. 지연(DELAY)은 공지를 만들지 않고, 장애(INCIDENT)만 공지 초안/게시를 동반한다. + */ + private void openIncident(String event, IncidentKind kind, List apiIds, + String summary, String titleKeyword) { + // 0) OFF 면 신규 등록·병합·타임라인 기록을 모두 건너뛴다. + // 이미 열려있는 이슈의 복구(markRecovered)는 계속 동작해야 하므로 여기서만 막는다. + String mode = resolveAutoIncidentMode(); + if (MODE_OFF.equals(mode)) { + log.info("자동 이슈 등록 비활성(OFF) - 기록하지 않음: {} {}", event, apiIds); + return; + } + boolean autoPublish = MODE_PUBLISH.equals(mode); + LocalDateTime now = LocalDateTime.now(); String interfaceId = makeInterfaceId(event, apiIds, now); @@ -169,31 +210,34 @@ public class ApiStatusDetectionService { return; } - // 2) 이미 열려있는 자동 등록 장애가 해당 API 를 포함하면 영향 API 만 병합 + // 2) 이미 열려있는 자동 등록 이슈가 해당 API 를 포함하면 영향 API 만 병합 // (관리자가 직접 작성한 장애 공지는 건드리지 않는다) + // 지연으로 열린 건에 장애가 얹히는 경우가 있어 지연·장애를 함께 찾는다. List openIncidents = incidentRepository - .findOpenByApiIds(IncidentKind.INCIDENT, DETECTED_BY_AUTO, CLOSED_STATES, apiIds); + .findOpenByApiIds(IncidentKind.DEGRADING, DETECTED_BY_AUTO, CLOSED_STATES, apiIds); if (!openIncidents.isEmpty()) { - mergeIntoOpenIncident(openIncidents.get(0), event, apiIds, now); + mergeIntoOpenIncident(openIncidents.get(0), event, kind, apiIds, now, autoPublish); return; } - // 3) 신규 장애 + 공지 초안 생성 + // 3) 신규 이슈 생성. 장애는 공지도 같이 만들고, PUBLISH 면 검수 없이 바로 게시한다. Map apiNames = resolveApiNames(apiIds); String title = buildTitle(titleKeyword, apiIds, apiNames); - PortalNotice notice = createDraftNotice(title, summary, apiIds, apiNames, now); + PortalNotice notice = kind == IncidentKind.DELAY + ? null : createNotice(title, summary, apiIds, apiNames, now, autoPublish); DjbApistatusIncident incident = new DjbApistatusIncident(); - incident.setKind(IncidentKind.INCIDENT); + incident.setKind(kind); incident.setState(IncidentState.INVESTIGATING); incident.setTitle(title); incident.setSummary(summary); incident.setStartedAt(now); incident.setDetectedBy(DETECTED_BY_AUTO); incident.setInterfaceId(interfaceId); - incident.setNoticeId(notice.getId()); - incident.setDraftYn("Y"); + incident.setNoticeId(notice == null ? null : notice.getId()); + // 개발자포탈 노출 조건: 공지가 있으면 DRAFT_YN='N' + USE_YN='Y', 없으면(지연) DRAFT_YN='N' 만 + incident.setDraftYn(autoPublish ? "N" : "Y"); incident.setFixYn("N"); stampAudit(incident, now); @@ -201,9 +245,11 @@ public class ApiStatusDetectionService { try { saved = incidentRepository.saveAndFlush(incident); } catch (DataIntegrityViolationException e) { - // INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 공지 초안 정리 후 타임라인만 남긴다 - log.info("장애 중복 감지 (interfaceId={}) - 기존 장애에 타임라인 추가", interfaceId, e); - portalNoticeService.deleteById(notice.getId()); + // INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 만든 공지를 정리하고 타임라인만 남긴다 + log.info("이슈 중복 감지 (interfaceId={}) - 기존 이슈에 타임라인 추가", interfaceId, e); + if (notice != null) { + portalNoticeService.deleteById(notice.getId()); + } incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing -> appendTimeline(existing.getIncidentId(), null, "재탐지 신호 수신 (" + event + ")", now)); @@ -214,14 +260,62 @@ public class ApiStatusDetectionService { appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING, summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now); - log.info("자동 장애 등록: incidentId={}, noticeId={}, apis={}", - saved.getIncidentId(), notice.getId(), apiIds); + log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}", + saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(), + autoPublish ? MODE_PUBLISH : MODE_DRAFT, apiIds); } - private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, - List apiIds, LocalDateTime now) { + /** + * 자동 탐지 장애/지연의 공지 처리 방식. + * + *

PTL_PROPERTY({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 를 읽는다. + * 최초 접근 시 기본값으로 row 가 자동 생성된다.

+ * + * + * + *

조회 실패나 알 수 없는 값은 검수 쪽(DRAFT)으로 떨어뜨린다 - 오탐이 고객 화면에 + * 그대로 나가는 것도, 진짜 장애를 아예 안 남기는 것도 기본 동작으로는 위험하기 때문이다.

+ */ + private String resolveAutoIncidentMode() { + try { + String value = portalPropertyService.getOrCreateProperty( + PROPERTY_GROUP, KEY_AUTO_INCIDENT_MODE, DEFAULT_AUTO_INCIDENT_MODE, + "API 상태 자동 탐지 공지 처리 방식 - OFF: 자동 등록 안 함, " + + "DRAFT: 초안만 생성(관리자 검수 후 게시), PUBLISH: 초안 생성 후 즉시 게시"); + String normalized = StringUtils.trimToEmpty(value); + if (MODE_OFF.equalsIgnoreCase(normalized)) { + return MODE_OFF; + } + if (MODE_PUBLISH.equalsIgnoreCase(normalized)) { + return MODE_PUBLISH; + } + if (!MODE_DRAFT.equalsIgnoreCase(normalized)) { + log.warn("알 수 없는 자동 탐지 공지 처리 방식 '{}' - 기본값 {} 적용", value, DEFAULT_AUTO_INCIDENT_MODE); + } + return MODE_DRAFT; + } catch (Exception e) { + log.warn("자동 탐지 공지 처리 방식 조회 실패 - 기본값 {} 적용", DEFAULT_AUTO_INCIDENT_MODE, e); + return DEFAULT_AUTO_INCIDENT_MODE; + } + } + + private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, IncidentKind kind, + List apiIds, LocalDateTime now, boolean autoPublish) { Long incidentId = incident.getIncidentId(); + // 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다) + escalateIfNeeded(incident, event, kind, now, autoPublish); + + // DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가 + // 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다. + if (autoPublish) { + publishIfPending(incident); + } + Set mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream() .map(DjbApistatusIncidentApi::getApiId) .collect(Collectors.toCollection(HashSet::new)); @@ -232,14 +326,81 @@ public class ApiStatusDetectionService { if (added.isEmpty()) { appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now); - log.debug("기존 장애에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds); + log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds); return; } saveIncidentApis(incidentId, added, resolveApiNames(added), now); appendTimeline(incidentId, null, "영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now); - log.info("기존 장애에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added); + log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added); + } + + /** + * 지연(DELAY)으로 열린 이슈에 장애(ERROR_START)가 감지되면 종류를 장애로 올린다. + * + *

지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은 + * 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.

+ */ + private void escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind, + LocalDateTime now, boolean autoPublish) { + if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) { + return; + } + + List apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream() + .map(DjbApistatusIncidentApi::getApiId) + .collect(Collectors.toList()); + Map apiNames = resolveApiNames(apiIds); + String title = buildTitle("장애", apiIds, apiNames); + String summary = "에러율 임계 초과"; + + incident.setKind(IncidentKind.INCIDENT); + incident.setTitle(title); + incident.setSummary(summary); + if (incident.getNoticeId() == null) { + // 이미 노출 중이던(DRAFT_YN='N') 지연이 격상되는데 공지가 미게시로 붙으면 + // 보이던 이슈가 사라진다. 그 경우엔 공지도 게시 상태로 만든다. + boolean publishNotice = autoPublish || "N".equals(incident.getDraftYn()); + PortalNotice notice = createNotice(title, summary, apiIds, apiNames, now, publishNotice); + incident.setNoticeId(notice.getId()); + } + incident.setLastModifiedBy(AUTHOR_SYSTEM); + incident.setLastModifiedDate(now); + incidentRepository.save(incident); + + appendTimeline(incident.getIncidentId(), null, + "지연에서 장애로 격상 (" + event + ")", now); + log.info("지연 → 장애 격상: incidentId={}, noticeId={}", + incident.getIncidentId(), incident.getNoticeId()); + } + + /** + * 아직 초안으로 남아있는 자동 탐지 이슈를 게시 상태로 올린다 (PUBLISH 모드에서만 호출). + * + *

대상은 자동 등록(DETECTED_BY='AUTO') 건뿐이다. 관리자가 공지사항 관리에서 손댄 건은 + * DETECTED_BY 가 'MANUAL' 로 바뀌므로 여기 오지 않는다.

+ * + *

지연은 연결 공지가 없으므로 DRAFT_YN 만 바꾸면 노출된다.

+ */ + private void publishIfPending(DjbApistatusIncident incident) { + if (!"Y".equals(incident.getDraftYn())) { + return; + } + + incident.setDraftYn("N"); + incidentRepository.save(incident); + + if (StringUtils.isNotBlank(incident.getNoticeId())) { + portalNoticeService.findById(incident.getNoticeId()) + .filter(notice -> !"Y".equals(notice.getUseYn())) + .ifPresent(notice -> { + notice.setUseYn("Y"); + portalNoticeService.save(notice); + }); + } + log.info("자동 탐지 이슈 뒤늦은 게시: incidentId={}, noticeId={}", + incident.getIncidentId(), incident.getNoticeId()); } // ────────────── 복구 ────────────── @@ -254,8 +415,10 @@ public class ApiStatusDetectionService { return; } + // 복구는 이벤트 종류를 구분하지 않는다. ERROR_END 하나로 그 API 의 지연 건까지 닫는다 + // (상태머신상 E → N 으로 돌아가므로 DELAY_END 가 따로 오지 않는다). Map openIncidents = incidentRepository - .findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES).stream() + .findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES).stream() .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident)); Map> recoveredByIncident = new HashMap<>(); @@ -345,22 +508,49 @@ public class ApiStatusDetectionService { return "[자동감지] " + first + " API " + titleKeyword; } - private PortalNotice createDraftNotice(String title, String summary, List apiIds, - Map apiNames, LocalDateTime now) { + /** 관리자가 검수 시 채워 넣을 항목 - {제목, 작성 힌트} */ + private static final String[][] NOTICE_FILL_IN_SECTIONS = { + {"발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"}, + {"영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"}, + {"조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"}, + {"예상 복구 시간", "예) 2026-08-03 14:30 (복구 완료 시 실제 시각으로 갱신)"} + }; + + /** + * 자동 탐지 장애 공지 생성. + * + *

본문에는 관리자가 검수하며 채울 항목(발생 원인·영향 범위·조치 내용·예상 복구 시간)을 + * 미리 넣어 둔다. 초안이면 작성 힌트를 함께 남기고, 이미 게시되는 경우엔 고객에게 + * 그대로 노출되므로 "확인 중입니다" 로 채워 둔다.

+ * + * @param publish true 면 게시(USE_YN='Y'), false 면 초안(USE_YN='N') + */ + private PortalNotice createNotice(String title, String summary, List apiIds, + Map apiNames, LocalDateTime now, boolean publish) { StringBuilder detail = new StringBuilder(); - detail.append("

").append(summary).append(" 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.

"); - detail.append("

영향 API

    "); + detail.append("

    ").append(summary).append(" 로 자동 감지된 장애입니다."); + detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다."); + detail.append("

    "); + detail.append("

    영향 API

      "); for (String apiId : apiIds) { detail.append("
    • ").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")
    • "); } detail.append("
    "); + for (String[] section : NOTICE_FILL_IN_SECTIONS) { + detail.append("

    ").append(section[0]).append("

    "); + // 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다 + detail.append(publish + ? "

    확인 중입니다.

    " + : "

    [작성 필요] " + section[1] + "

    "); + } + PortalNotice notice = new PortalNotice(); notice.setId(null); notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH)); notice.setNoticeDetail(detail.toString()); notice.setNoticeType(NOTICE_TYPE_INCIDENT); - notice.setUseYn("N"); + notice.setUseYn(publish ? "Y" : "N"); notice.setFixYn("N"); notice.setReadCount(0L); notice.setInquirerName(AUTHOR_SYSTEM); diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java index b0ed6c4..ee8445d 100644 --- a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java @@ -42,8 +42,11 @@ public interface ApiStatusRepository extends BaseRepository { + " FROM (" + " SELECT A.EAISVCNAME" + " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE" + // 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을 + // 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CONTROL_START 가 발생한다. + " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X" + " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID " + + " AND Y.KIND = 'MAINTENANCE'" + " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) " + " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN " + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"