점검 사전공지 웹훅 발송 스케쥴러 추가
eapim-admin CI / build (push) Waiting to run

This commit is contained in:
eastargh
2026-09-21 10:21:36 +09:00
parent cb3390b90d
commit e12534464f
3 changed files with 276 additions and 3 deletions
@@ -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})을 처리한다.
*
* <p>실제 점검 시작 시점에는 {@link ApiStatusService} 가 판정 즉시 {@code CHECK_START} 웹훅을
* 발송한다. 이 서비스는 그와 별개로, 관리자가 지정한 사전 발송 시각이 되면
* <b>CHECK_START 를 신청한 제휴사</b>에게 미리 "점검 예정" 사실을 알리는 역할만 한다 -
* 그래서 구독자 조회는 {@code CHECK_START} 이벤트로 하되, 실제 전송되는 웹훅의
* {@code eventType} 은 {@code CHECK_NOTICE} 로 바꾸고 본문에 점검 예정 일시를 담는다
* (아직 시작되지 않았는데 "점검이 시작되었습니다"로 나가면 안 되기 때문).</p>
*
* <p>건(사전발송 1행)마다 {@link #processOne} 을 별도 트랜잭션으로 처리한다 - 한 건의 실패가
* 앞서 처리한 건의 결과 저장까지 롤백시키면 안 되고(이미 웹훅은 나간 뒤라 되돌릴 수 없다),
* 반대로 처리 중간에 실패한 건은 이번 저장을 통째로 롤백해 PENDING 으로 남겨 다음 주기에
* 재시도되도록 한다. {@code this.processOne(...)} 대신 {@link #self} 를 거쳐 호출하는 것은
* 같은 빈 안에서의 self-invocation 이 프록시를 우회해 {@code @Transactional} 이 적용되지
* 않는 Spring AOP 제약 때문이다.</p>
*/
@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<DjbApistatusIncidentPrenotice> 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<String> 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<WebhookSendRequest> sendList = webhookService.findSendList(SUBSCRIBE_EVENT, apiIds);
int sentCnt = 0;
List<String> 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<String, Object> buildNoticeData(DjbApistatusIncident incident, Object apiIds) {
Map<String, Object> 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);
}
}
@@ -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)를 저장한다.
*
* <p>CLUSTERED, 10분 주기 실행 예정 (실제 주기·클러스터 설정은 스케줄러 관리 화면에서
* Job 등록 시 지정한다 - {@link PortalInquiryClosingJob} 과 동일).</p>
*
* <p>관련 리포지토리(사전발송/incident/incidentApi/webhook)는 모두 {@code @EMSDataSource}로
* MONITORING 스키마에 고정되어 있어 {@link com.eactive.eai.rms.common.datasource.DataSourceContextHolder}
* 전환이 필요 없다 ({@link PortalInquiryClosingJob} 과 다른 점).</p>
*/
@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<String> 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"));
}
}
@@ -150,8 +150,10 @@ public class WebhookService {
* 이 메서드 안에서 동기로 도는데, 메서드 전체를 하나의 트랜잭션으로 감싸면 재시도 도중의 * 이 메서드 안에서 동기로 도는데, 메서드 전체를 하나의 트랜잭션으로 감싸면 재시도 도중의
* 실패 로그 저장(sendWithRetry 내부)이 커밋되지 않고 메서드가 끝날 때까지 대기하게 되어 * 실패 로그 저장(sendWithRetry 내부)이 커밋되지 않고 메서드가 끝날 때까지 대기하게 되어
* "실패 시 즉시 로그부터 남긴다"는 의도가 무의미해진다. save() 호출마다 개별 커밋되도록 둔다.</p> * "실패 시 즉시 로그부터 남긴다"는 의도가 무의미해진다. save() 호출마다 개별 커밋되도록 둔다.</p>
*
* @return 저장된 발송 로그 (성공/실패 결과는 {@link WebhookSendLog#getSuccess()}로 확인)
*/ */
public void send(WebhookSendRequest req) { public WebhookSendLog send(WebhookSendRequest req) {
String targetUrl = req.getTargetUrl(); String targetUrl = req.getTargetUrl();
String proxyUrl = req.getProxyUrl(); String proxyUrl = req.getProxyUrl();
@@ -217,8 +219,7 @@ public class WebhookService {
eventType, proxyUrl, e.getMessage()); eventType, proxyUrl, e.getMessage());
} }
sendLogRepository.save(sendLog); return sendLogRepository.save(sendLog);
} }
private String getMessage(String event) { private String getMessage(String event) {
@@ -229,6 +230,7 @@ public class WebhookService {
case "ERROR_END": return "API 서비스 장애가 복구되었습니다"; //정상 case "ERROR_END": return "API 서비스 장애가 복구되었습니다"; //정상
case "DELAY_START": return "API 서비스 지연이 발생하였습니다"; //지연 case "DELAY_START": return "API 서비스 지연이 발생하였습니다"; //지연
case "DELAY_END": return "API 서비스 지연이 복구되었습니다"; //정상 case "DELAY_END": return "API 서비스 지연이 복구되었습니다"; //정상
case "CHECK_NOTICE": return "API 서비스 점검이 예정되어 있습니다"; //점검 사전 예고
default: default:
return "Webhook 메세지"; return "Webhook 메세지";
} }