From db38b8f6e910506885c98897ceb40aac673b86ee Mon Sep 17 00:00:00 2001 From: Rinjae Date: Thu, 30 Jul 2026 19:33:11 +0900 Subject: [PATCH] =?UTF-8?q?Swing=20=EC=95=8C=EB=A6=BC=20=EC=B2=B4=EA=B3=84?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=20-=20=EA=B8=B0=EC=A1=B4=20Q&A=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=95=8C=EB=A6=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20Swing=20Notifier=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apistatus/ApiStatusDetectionService.java | 398 +++++++++++++++++- .../djb/apistatus/ApiStatusRepository.java | 10 + .../MessageRequestManService.java | 4 +- 3 files changed, 395 insertions(+), 17 deletions(-) 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 f8687ef..636f412 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 @@ -1,18 +1,90 @@ package com.eactive.eai.rms.ext.djb.apistatus; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.eactive.apim.portal.common.entity.Auditable; +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.entity.IncidentState; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository; +import com.eactive.apim.portal.portalNotice.entity.PortalNotice; +import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService; +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} 초안으로 기록한다.

+ * + *

점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다 + * ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정). + * 따라서 CONTROL 이벤트를 다시 기록하면 순환이 되므로 기록하지 않는다.

+ */ @Service -@Transactional +@Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class) public class ApiStatusDetectionService { private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class); - + + public static final String EVENT_CONTROL_START = "CONTROL_START"; + public static final String EVENT_CONTROL_END = "CONTROL_END"; + public static final String EVENT_ERROR_START = "ERROR_START"; + public static final String EVENT_ERROR_END = "ERROR_END"; + public static final String EVENT_DELAY_START = "DELAY_START"; + public static final String EVENT_DELAY_END = "DELAY_END"; + + /** INTERFACE_ID 멱등 윈도우 (분) */ + private static final int IDEMPOTENT_WINDOW_MINUTES = 5; + + /** 종결 상태 - 신규 탐지 병합 대상에서 제외 */ + private static final List CLOSED_STATES = + Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED)); + + private static final String AUTHOR_SYSTEM = "SYSTEM"; + private static final String DETECTED_BY_AUTO = "AUTO"; + private static final String NOTICE_TYPE_INCIDENT = "3"; + private static final int NOTICE_SUBJECT_MAX_LENGTH = 255; + + @Autowired + private DjbApistatusIncidentRepository incidentRepository; + + @Autowired + private DjbApistatusIncidentApiRepository incidentApiRepository; + + @Autowired + private DjbApistatusIncidentTimelineRepository incidentTimelineRepository; + + @Autowired + private PortalNoticeService portalNoticeService; + + @Autowired + private ApiStatusRepository apiStatusRepository; + /** * 이벤트 감지 * @param event "CONTROL_START" : 점검시작 @@ -25,17 +97,313 @@ public class ApiStatusDetectionService { */ public void detect(String event, List apiIds) { log.debug("이벤트 감지: {} {}", event, apiIds); - } - - // API FSM 내 아직 장애로 남아있는 API 목록 - public void remainDownApiIds() { - - } - - - // 정상 동작 - detect()에서 일괄 처리 (복구 = 점검종료, 장애종료, 지연종료) -// public void recovered(String event, String[] apiIds) { -// -// } -} \ No newline at end of file + if (StringUtils.isBlank(event) || apiIds == null || apiIds.isEmpty()) { + return; + } + + List targets = apiIds.stream() + .filter(StringUtils::isNotBlank) + .distinct() + .sorted() + .collect(Collectors.toList()); + if (targets.isEmpty()) { + return; + } + + switch (event) { + case EVENT_ERROR_START: + openIncident(event, targets, "에러율 임계 초과", "장애"); + break; + case EVENT_DELAY_START: + openIncident(event, targets, "응답시간 임계 초과", "응답지연"); + break; + case EVENT_ERROR_END: + case EVENT_DELAY_END: + markRecovered(event, targets); + break; + case EVENT_CONTROL_START: + case EVENT_CONTROL_END: + // 점검은 관리자 등록 공지가 원천 - 재기록하지 않음 + log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets); + break; + default: + log.warn("알 수 없는 이벤트: {}", event); + } + } + + /** + * 미종결 장애로 남아있는 API 목록 (탐지 Job 폴링용) + */ + @Transactional(transactionManager = "transactionManagerForEMS", readOnly = true) + public List remainDownApiIds() { + List openIncidents = + incidentRepository.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES); + if (openIncidents.isEmpty()) { + return Collections.emptyList(); + } + + List incidentIds = openIncidents.stream() + .map(DjbApistatusIncident::getIncidentId) + .collect(Collectors.toList()); + + return incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds).stream() + .filter(api -> api.getRecoveredAt() == null) + .map(DjbApistatusIncidentApi::getApiId) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + // ────────────── 장애 발생 ────────────── + + private void openIncident(String event, List apiIds, String summary, String titleKeyword) { + LocalDateTime now = LocalDateTime.now(); + String interfaceId = makeInterfaceId(event, apiIds, now); + + // 1) 동일 윈도우 재탐지 - 타임라인만 남기고 종료 + Optional duplicated = incidentRepository.findByInterfaceId(interfaceId); + if (duplicated.isPresent()) { + appendTimeline(duplicated.get().getIncidentId(), null, + "재탐지 신호 수신 (" + event + ")", now); + return; + } + + // 2) 이미 열려있는 자동 등록 장애가 해당 API 를 포함하면 영향 API 만 병합 + // (관리자가 직접 작성한 장애 공지는 건드리지 않는다) + List openIncidents = incidentRepository + .findOpenByApiIds(IncidentKind.INCIDENT, DETECTED_BY_AUTO, CLOSED_STATES, apiIds); + if (!openIncidents.isEmpty()) { + mergeIntoOpenIncident(openIncidents.get(0), event, apiIds, now); + return; + } + + // 3) 신규 장애 + 공지 초안 생성 + Map apiNames = resolveApiNames(apiIds); + String title = buildTitle(titleKeyword, apiIds, apiNames); + + PortalNotice notice = createDraftNotice(title, summary, apiIds, apiNames, now); + + DjbApistatusIncident incident = new DjbApistatusIncident(); + incident.setKind(IncidentKind.INCIDENT); + 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.setFixYn("N"); + stampAudit(incident, now); + + DjbApistatusIncident saved; + try { + saved = incidentRepository.saveAndFlush(incident); + } catch (DataIntegrityViolationException e) { + // INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 공지 초안 정리 후 타임라인만 남긴다 + log.info("장애 중복 감지 (interfaceId={}) - 기존 장애에 타임라인 추가", interfaceId, e); + portalNoticeService.deleteById(notice.getId()); + incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing -> + appendTimeline(existing.getIncidentId(), null, + "재탐지 신호 수신 (" + event + ")", now)); + return; + } + + saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now); + appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING, + summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now); + + log.info("자동 장애 등록: incidentId={}, noticeId={}, apis={}", + saved.getIncidentId(), notice.getId(), apiIds); + } + + private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, + List apiIds, LocalDateTime now) { + Long incidentId = incident.getIncidentId(); + + Set mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream() + .map(DjbApistatusIncidentApi::getApiId) + .collect(Collectors.toCollection(HashSet::new)); + + List added = apiIds.stream() + .filter(apiId -> !mapped.contains(apiId)) + .collect(Collectors.toList()); + + if (added.isEmpty()) { + appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now); + 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); + } + + // ────────────── 복구 ────────────── + + private void markRecovered(String event, List apiIds) { + LocalDateTime now = LocalDateTime.now(); + + List candidates = + incidentApiRepository.findByApiIdInAndRecoveredAtIsNull(apiIds); + if (candidates.isEmpty()) { + log.debug("복구 대상 장애 없음: {} {}", event, apiIds); + return; + } + + Map openIncidents = incidentRepository + .findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES).stream() + .collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident)); + + Map> recoveredByIncident = new HashMap<>(); + for (DjbApistatusIncidentApi api : candidates) { + if (!openIncidents.containsKey(api.getIncidentId())) { + continue; + } + api.setRecoveredAt(now); + api.setLastModifiedBy(AUTHOR_SYSTEM); + api.setLastModifiedDate(now); + incidentApiRepository.save(api); + + recoveredByIncident.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>()) + .add(api.getApiId()); + } + + if (recoveredByIncident.isEmpty()) { + log.debug("복구 대상 장애 없음 (미종결 장애 미포함): {} {}", event, apiIds); + return; + } + + for (Map.Entry> entry : recoveredByIncident.entrySet()) { + Long incidentId = entry.getKey(); + List recoveredApis = entry.getValue(); + DjbApistatusIncident incident = openIncidents.get(incidentId); + + boolean allRecovered = incidentApiRepository.countByIncidentIdAndRecoveredAtIsNull(incidentId) == 0; + IncidentState nextState = allRecovered ? IncidentState.RESOLVED : IncidentState.MONITORING; + + if (incident.getState() != nextState) { + incident.setPreviousState(incident.getState()); + incident.setState(nextState); + } + if (allRecovered) { + incident.setEndAt(now); + } + incident.setLastModifiedBy(AUTHOR_SYSTEM); + incident.setLastModifiedDate(now); + incidentRepository.save(incident); + + String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n" + + String.join(", ", recoveredApis); + appendTimeline(incidentId, nextState, body, now); + + log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis); + } + } + + // ────────────── 내부 헬퍼 ────────────── + + private String makeInterfaceId(String event, List sortedApiIds, LocalDateTime now) { + long windowSeconds = IDEMPOTENT_WINDOW_MINUTES * 60L; + long flooredEpoch = now.atZone(ZoneId.systemDefault()).toEpochSecond() + / windowSeconds * windowSeconds; + String joined = String.join(",", sortedApiIds); + String interfaceId = event + ":" + joined + ":" + flooredEpoch; + + // INTERFACE_ID 는 VARCHAR2(200) - 초과 시 API 목록을 해시로 축약 + if (interfaceId.length() > 200) { + interfaceId = event + ":#" + Integer.toHexString(joined.hashCode()) + + ":" + sortedApiIds.size() + ":" + flooredEpoch; + } + return interfaceId; + } + + private Map resolveApiNames(List apiIds) { + Map names = new HashMap<>(); + try { + for (ApiNameRow row : apiStatusRepository.findApiNames(apiIds)) { + names.put(row.getEaisvcname(), row.getEaisvcdesc()); + } + } catch (Exception e) { + log.warn("API 명 조회 실패 - API ID 를 그대로 사용", e); + } + return names; + } + + private String apiLabel(String apiId, Map apiNames) { + return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId); + } + + private String buildTitle(String titleKeyword, List apiIds, Map apiNames) { + String first = apiLabel(apiIds.get(0), apiNames); + if (apiIds.size() > 1) { + return "[자동감지] " + first + " 외 " + (apiIds.size() - 1) + "종 API " + titleKeyword; + } + return "[자동감지] " + first + " API " + titleKeyword; + } + + private PortalNotice createDraftNotice(String title, String summary, List apiIds, + Map apiNames, LocalDateTime now) { + StringBuilder detail = new StringBuilder(); + detail.append("

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

"); + detail.append("

영향 API

    "); + for (String apiId : apiIds) { + detail.append("
  • ").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")
  • "); + } + detail.append("
"); + + 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.setFixYn("N"); + notice.setReadCount(0L); + notice.setInquirerName(AUTHOR_SYSTEM); + notice.setCreatedBy(AUTHOR_SYSTEM); + notice.setCreatedDate(now); + + portalNoticeService.save(notice); + return notice; + } + + private void saveIncidentApis(Long incidentId, Collection apiIds, + Map apiNames, LocalDateTime now) { + List rows = new ArrayList<>(); + for (String apiId : apiIds) { + DjbApistatusIncidentApi api = new DjbApistatusIncidentApi(); + api.setIncidentId(incidentId); + api.setApiId(apiId); + api.setApiName(apiNames.get(apiId)); + stampAudit(api, now); + rows.add(api); + } + if (!rows.isEmpty()) { + incidentApiRepository.saveAll(rows); + } + } + + private void appendTimeline(Long incidentId, IncidentState stateAfter, String body, LocalDateTime now) { + DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline(); + timeline.setIncidentId(incidentId); + timeline.setEventAt(now); + timeline.setStateAfter(stateAfter); + timeline.setBody(StringUtils.abbreviate(body, 4000)); + timeline.setAuthorType(AUTHOR_SYSTEM); + timeline.setVisibleYn("Y"); + stampAudit(timeline, now); + incidentTimelineRepository.save(timeline); + } + + /** + * 스케줄러 스레드는 로그인 세션이 없어 AuditorAware 가 비어있다. CREATED_BY 는 NOT NULL 이므로 직접 채운다. + */ + private void stampAudit(Auditable entity, LocalDateTime now) { + entity.setCreatedBy(AUTHOR_SYSTEM); + entity.setCreatedDate(now); + } +} 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 9a0f4bf..b0ed6c4 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 @@ -4,7 +4,10 @@ import java.util.List; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; + import com.eactive.eai.data.jpa.BaseRepository; +import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent; @@ -73,4 +76,11 @@ public interface ApiStatusRepository extends BaseRepository { @Param("delayRangeMinute") int delayRangeMinute, @Param("delayAvgRespTime") int delayAvgRespTime ); + + /** + * EAI 서비스명 조회. 장애/점검 영향 API 의 API_NAME 캐시용. + */ + @Query(nativeQuery = true, value = + " SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)") + List findApiNames(@Param("apiIds") Collection apiIds); } \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java b/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java index e78fab6..6629c40 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/messagerequest/MessageRequestManService.java @@ -82,9 +82,9 @@ public class MessageRequestManService extends BaseService { return t == null ? "" : t.format(f); } - /** 상태 검색 콤보 (PENDING/SENT/FAILED). */ + /** 상태 검색 콤보 (PENDING/SENT/FAILED/SKIPPED). SKIPPED 는 행번 아닌 ID 로 발송을 건너뛴 건. */ public List statusCombo() { - return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED"}) + return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED", "SKIPPED"}) .map(s -> { ComboVo v = new ComboVo(); v.setCode(s);