@@ -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 내부)이 커밋되지 않고 메서드가 끝날 때까지 대기하게 되어
|
||||
* "실패 시 즉시 로그부터 남긴다"는 의도가 무의미해진다. save() 호출마다 개별 커밋되도록 둔다.</p>
|
||||
*
|
||||
* @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 메세지";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user