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 메세지";
}