From e12534464f8f2f761a80b6e484f18366224d2d6d Mon Sep 17 00:00:00 2001 From: eastargh Date: Mon, 21 Sep 2026 10:21:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=90=EA=B2=80=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=EC=9B=B9=ED=9B=85=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiIncidentPrenoticeService.java | 195 ++++++++++++++++++ .../ext/djb/job/ApiIncidentPrenoticeJob.java | 76 +++++++ .../djb/webhook/service/WebhookService.java | 8 +- 3 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiIncidentPrenoticeService.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/job/ApiIncidentPrenoticeJob.java diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiIncidentPrenoticeService.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiIncidentPrenoticeService.java new file mode 100644 index 0000000..ef26b34 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiIncidentPrenoticeService.java @@ -0,0 +1,195 @@ +package com.eactive.eai.rms.ext.djb.apistatus; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.DjbApistatusIncidentPrenotice; +import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind; +import com.eactive.apim.portal.djb.apistatus.incident.entity.PrenoticeResult; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentPrenoticeRepository; +import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository; +import com.eactive.eai.rms.common.context.MonitoringContext; +import com.eactive.eai.rms.data.entity.onl.djb.webhook.WebhookSendLog; +import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendRequest; +import com.eactive.eai.rms.ext.djb.webhook.service.WebhookService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 점검(MAINTENANCE) 공지의 사전 발송 예약({@code DJB_APISTATUS_INCIDENT_PRENOTICE})을 처리한다. + * + *

실제 점검 시작 시점에는 {@link ApiStatusService} 가 판정 즉시 {@code CHECK_START} 웹훅을 + * 발송한다. 이 서비스는 그와 별개로, 관리자가 지정한 사전 발송 시각이 되면 + * CHECK_START 를 신청한 제휴사에게 미리 "점검 예정" 사실을 알리는 역할만 한다 - + * 그래서 구독자 조회는 {@code CHECK_START} 이벤트로 하되, 실제 전송되는 웹훅의 + * {@code eventType} 은 {@code CHECK_NOTICE} 로 바꾸고 본문에 점검 예정 일시를 담는다 + * (아직 시작되지 않았는데 "점검이 시작되었습니다"로 나가면 안 되기 때문).

+ * + *

건(사전발송 1행)마다 {@link #processOne} 을 별도 트랜잭션으로 처리한다 - 한 건의 실패가 + * 앞서 처리한 건의 결과 저장까지 롤백시키면 안 되고(이미 웹훅은 나간 뒤라 되돌릴 수 없다), + * 반대로 처리 중간에 실패한 건은 이번 저장을 통째로 롤백해 PENDING 으로 남겨 다음 주기에 + * 재시도되도록 한다. {@code this.processOne(...)} 대신 {@link #self} 를 거쳐 호출하는 것은 + * 같은 빈 안에서의 self-invocation 이 프록시를 우회해 {@code @Transactional} 이 적용되지 + * 않는 Spring AOP 제약 때문이다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiIncidentPrenoticeService { + + /** 사전 발송 구독 조회 기준 이벤트 - 신규 이벤트 코드를 등록하지 않고 기존 CHECK_START 구독자를 그대로 쓴다 */ + private static final String SUBSCRIBE_EVENT = "CHECK_START"; + + /** 실제 발송되는 웹훅의 eventType - 점검 "시작"이 아니라 "예고"임을 구분한다 */ + private static final String NOTICE_EVENT = "CHECK_NOTICE"; + + private static final int FAIL_MSG_MAX_LENGTH = 2000; + + /** 웹훅 본문에 싣는 점검 예정 일시 포맷 - Oracle {@code YYYYMMDDHH24MI} 와 동일 */ + private static final DateTimeFormatter NOTICE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + + private final DjbApistatusIncidentPrenoticeRepository incidentPrenoticeRepository; + private final DjbApistatusIncidentRepository incidentRepository; + private final DjbApistatusIncidentApiRepository incidentApiRepository; + private final WebhookService webhookService; + private final MonitoringContext monitoringContext; + + /** {@link #processOne} 을 프록시 경유로 호출하기 위한 자기 참조 (클래스 상단 Javadoc 참고) */ + @Lazy + @Autowired + private ApiIncidentPrenoticeService self; + + /** + * PENDING 이면서 지정 시각이 지난 사전 발송 건을 모두 처리한다. + * + * @return 처리 시도한 건수 (성공/실패 무관) + */ + public int processPendingPrenotices() { + if (!monitoringContext.getBooleanProperty(MonitoringContext.RMS_WEBHOOK_ENABLED, true)) { + log.info("웹훅 발송 기능 비활성화 - 사전 발송 대기 건 처리를 건너뜀"); + return 0; + } + + LocalDateTime now = LocalDateTime.now(); + List pendings = incidentPrenoticeRepository + .findByResultAndPrenotiAtLessThanEqualOrderByPrenotiAtAsc(PrenoticeResult.PENDING, now); + if (pendings.isEmpty()) { + return 0; + } + + log.info("점검 사전 발송 대기 건 처리 시작: {}건", pendings.size()); + for (DjbApistatusIncidentPrenotice prenotice : pendings) { + Long incidentId = prenotice.getIncidentId(); + try { + self.processOne(incidentId); + } catch (Exception e) { + // processOne 트랜잭션은 이미 롤백됨 - 행은 PENDING 으로 남아 다음 주기에 재시도된다 + log.error("사전 발송 처리 중 예외 발생(다음 주기 재시도): incidentId={}", incidentId, e); + } + } + return pendings.size(); + } + + /** + * 건 하나를 독립된 트랜잭션으로 처리한다. 반드시 {@link #self} 를 통해 호출한다. + */ + @Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class) + public void processOne(Long incidentId) { + DjbApistatusIncidentPrenotice prenotice = incidentPrenoticeRepository.findById(incidentId) + .orElseThrow(() -> new IllegalStateException("사전 발송 정보를 찾을 수 없습니다. incidentId=" + incidentId)); + + // 이중 실행 등으로 이미 처리된 건이면 건너뛴다 + if (prenotice.getResult() != PrenoticeResult.PENDING) { + log.debug("이미 처리된 사전 발송 - 건너뜀: incidentId={}, result={}", incidentId, prenotice.getResult()); + return; + } + + DjbApistatusIncident incident = incidentRepository.findById(incidentId).orElse(null); + if (incident == null) { + log.error("사전 발송 처리 실패 - 연결된 장애/점검 정보 없음: incidentId={}", incidentId); + saveResult(prenotice, PrenoticeResult.FAIL, null, null, "연결된 장애/점검 정보를 찾을 수 없습니다."); + return; + } + if (incident.getKind() != IncidentKind.MAINTENANCE) { + log.warn("사전 발송 처리 건너뜀 - MAINTENANCE 유형이 아님: incidentId={}, kind={}", incidentId, incident.getKind()); + saveResult(prenotice, PrenoticeResult.FAIL, null, null, "점검(MAINTENANCE) 유형이 아닙니다. kind=" + incident.getKind()); + return; + } + + List apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream() + .map(DjbApistatusIncidentApi::getApiId) + .collect(Collectors.toList()); + + if (apiIds.isEmpty()) { + log.info("사전 발송 대상 API 없음 - 발송 없이 완료 처리: incidentId={}", incidentId); + saveResult(prenotice, PrenoticeResult.SUCCESS, 0, 0, null); + return; + } + + List sendList = webhookService.findSendList(SUBSCRIBE_EVENT, apiIds); + + int sentCnt = 0; + List failures = new ArrayList<>(); + for (WebhookSendRequest req : sendList) { + req.setEventType(NOTICE_EVENT); + req.setData(buildNoticeData(incident, req.getData())); + + WebhookSendLog result = webhookService.send(req); + if (result != null && Boolean.TRUE.equals(result.getSuccess())) { + sentCnt++; + } else { + String reason = result == null ? "알 수 없는 오류" + : StringUtils.defaultIfBlank(result.getErrorMessage(), "status=" + result.getStatusCode()); + failures.add(req.getOrgId() + ":" + reason); + } + } + + int targetCnt = sendList.size(); + PrenoticeResult result = (sentCnt == targetCnt) ? PrenoticeResult.SUCCESS : PrenoticeResult.FAIL; + String failMsg = failures.isEmpty() ? null : StringUtils.abbreviate(String.join("; ", failures), FAIL_MSG_MAX_LENGTH); + + saveResult(prenotice, result, targetCnt, sentCnt, failMsg); + log.info("점검 사전 발송 처리 완료: incidentId={}, target={}, sent={}, result={}", + incidentId, targetCnt, sentCnt, result); + } + + /** 원래 org 기준 apiId 목록(req.getData())은 유지하고, 점검 예정 일정을 덧붙인다. */ + private Map buildNoticeData(DjbApistatusIncident incident, Object apiIds) { + Map data = new LinkedHashMap<>(); + data.put("incidentId", incident.getIncidentId()); + data.put("title", incident.getTitle()); + data.put("scheduledStartAt", formatNoticeDate(incident.getStartedAt())); + data.put("scheduledEndAt", formatNoticeDate(incident.getEndAt())); + data.put("apiIds", apiIds); + return data; + } + + private String formatNoticeDate(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.format(NOTICE_DATE_FORMAT); + } + + private void saveResult(DjbApistatusIncidentPrenotice prenotice, PrenoticeResult result, + Integer targetCnt, Integer sentCnt, String failMsg) { + prenotice.setRunAt(LocalDateTime.now()); + prenotice.setResult(result); + prenotice.setTargetCnt(targetCnt); + prenotice.setSentCnt(sentCnt); + prenotice.setFailMsg(failMsg); + incidentPrenoticeRepository.save(prenotice); + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiIncidentPrenoticeJob.java b/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiIncidentPrenoticeJob.java new file mode 100644 index 0000000..a4e88f2 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiIncidentPrenoticeJob.java @@ -0,0 +1,76 @@ +package com.eactive.eai.rms.ext.djb.job; + +import com.eactive.eai.rms.common.util.CommonUtil; +import com.eactive.eai.rms.ext.djb.apistatus.ApiIncidentPrenoticeService; +import com.eactive.eai.rms.onl.common.util.DateUtil; +import org.apache.commons.lang3.StringUtils; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.SchedulerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 점검 공지 사전 발송 처리 Job - DJB_APISTATUS_INCIDENT_PRENOTICE 의 PENDING 건 중 + * 지정 시각이 지난 건을 찾아 CHECK_START 구독 제휴사에게 사전 안내 웹훅을 발송하고 + * 결과(RUN_AT/RESULT/SENT_CNT/FAIL_MSG)를 저장한다. + * + *

CLUSTERED, 10분 주기 실행 예정 (실제 주기·클러스터 설정은 스케줄러 관리 화면에서 + * Job 등록 시 지정한다 - {@link PortalInquiryClosingJob} 과 동일).

+ * + *

관련 리포지토리(사전발송/incident/incidentApi/webhook)는 모두 {@code @EMSDataSource}로 + * MONITORING 스키마에 고정되어 있어 {@link com.eactive.eai.rms.common.datasource.DataSourceContextHolder} + * 전환이 필요 없다 ({@link PortalInquiryClosingJob} 과 다른 점).

+ */ +@DisallowConcurrentExecution +public class ApiIncidentPrenoticeJob implements Job { + + private static final Logger log = LoggerFactory.getLogger(ApiIncidentPrenoticeJob.class); + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + + // execute.instances 가 지정된 경우 해당 인스턴스에서만 실행 + String allowedInstances = jobDataMap.getString("execute.instances"); + if (StringUtils.isNotEmpty(allowedInstances)) { + Set instanceSet = Arrays.stream(allowedInstances.split(",")) + .map(s -> s.trim().toLowerCase()) + .collect(Collectors.toSet()); + String currentInstance = StringUtils.defaultString(System.getProperty("inst.Name")).trim().toLowerCase(); + if (!instanceSet.contains(currentInstance)) { + log.debug("Job 실행 인스턴스 제한 - 현재: {}, 허용: {}", currentInstance, allowedInstances); + return; + } + } + + log.info("*** START ApiIncidentPrenoticeJob run({})", CommonUtil.getToday("yyyy-MM-dd HH:mm")); + + ApiIncidentPrenoticeService service; + try { + ApplicationContext appContext = (ApplicationContext) context.getScheduler().getContext().get("applicationContext"); + service = appContext.getBean(ApiIncidentPrenoticeService.class); + } catch (SchedulerException e) { + log.error("applicationContext 조회 실패", e); + throw new JobExecutionException(e); + } + + try { + int processedCount = service.processPendingPrenotices(); + log.info("ApiIncidentPrenoticeService 완료: {}건 처리", processedCount); + } catch (Exception e) { + log.error("ApiIncidentPrenoticeService execution failed", e); + throw new JobExecutionException(e); + } + + log.info("*** END ApiIncidentPrenoticeJob run({})", DateUtil.getDateTime("yyyy-MM-dd HH:mm")); + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java index a3f91ac..511ff70 100644 --- a/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java +++ b/src/main/java/com/eactive/eai/rms/ext/djb/webhook/service/WebhookService.java @@ -150,8 +150,10 @@ public class WebhookService { * 이 메서드 안에서 동기로 도는데, 메서드 전체를 하나의 트랜잭션으로 감싸면 재시도 도중의 * 실패 로그 저장(sendWithRetry 내부)이 커밋되지 않고 메서드가 끝날 때까지 대기하게 되어 * "실패 시 즉시 로그부터 남긴다"는 의도가 무의미해진다. save() 호출마다 개별 커밋되도록 둔다.

+ * + * @return 저장된 발송 로그 (성공/실패 결과는 {@link WebhookSendLog#getSuccess()}로 확인) */ - public void send(WebhookSendRequest req) { + public WebhookSendLog send(WebhookSendRequest req) { String targetUrl = req.getTargetUrl(); String proxyUrl = req.getProxyUrl(); @@ -217,8 +219,7 @@ public class WebhookService { eventType, proxyUrl, e.getMessage()); } - sendLogRepository.save(sendLog); - + return sendLogRepository.save(sendLog); } private String getMessage(String event) { @@ -229,6 +230,7 @@ public class WebhookService { case "ERROR_END": return "API 서비스 장애가 복구되었습니다"; //정상 case "DELAY_START": return "API 서비스 지연이 발생하였습니다"; //지연 case "DELAY_END": return "API 서비스 지연이 복구되었습니다"; //정상 + case "CHECK_NOTICE": return "API 서비스 점검이 예정되어 있습니다"; //점검 사전 예고 default: return "Webhook 메세지"; }