From 5db046eb10ba2f3efb327bbad77889e64005258b Mon Sep 17 00:00:00 2001 From: eastargh Date: Thu, 30 Apr 2026 17:42:20 +0900 Subject: [PATCH] =?UTF-8?q?API=20=EC=83=81=ED=83=9C=20=EB=B3=80=ED=99=94?= =?UTF-8?q?=20=EA=B0=90=EC=8B=9C=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rms/data/ext/djb/apistatus/ApiStatus.java | 42 +++++ .../ext/djb/apistatus/ApiStatusEvent.java | 6 + .../djb/apistatus/ApiStatusRepository.java | 70 +++++++ .../ext/djb/apistatus/ApiStatusService.java | 61 ++++++ .../rms/ext/djb/job/ApiStatusUpdateJob.java | 175 ++++++++++++++++++ .../djb/statistics/ApiUseStatsController.java | 110 +++++++++++ 6 files changed, 464 insertions(+) create mode 100644 src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatus.java create mode 100644 src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusEvent.java create mode 100644 src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusRepository.java create mode 100644 src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusService.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/job/ApiStatusUpdateJob.java create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/statistics/ApiUseStatsController.java diff --git a/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatus.java b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatus.java new file mode 100644 index 0000000..68bba74 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatus.java @@ -0,0 +1,42 @@ +package com.eactive.eai.rms.data.ext.djb.apistatus; + +import java.time.LocalDateTime; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; + +import com.eactive.eai.data.entity.AbstractEntity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NonNull; + +@Data +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true) +@Entity +@Table(name = "API_STATUS") +public class ApiStatus extends AbstractEntity { + + @Id + @Column(name = "EAISVCNAME", length = 100) + private String eaisvcname; + + @Column(name = "STATUS_CODE", length = 1) + private String statusCode; + + @LastModifiedBy // ← EMSAuditorAware가 자동으로 채움 + @Column(name = "MOD_USERID", length = 50) + private String modUserid = "SCHEDULER"; + + @LastModifiedDate // ← save() 시 자동으로 sysdate 채움 + @Column(name = "MOD_DATE") + private LocalDateTime modDate; + + @Override + public @NonNull String getId() { return eaisvcname; } +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusEvent.java b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusEvent.java new file mode 100644 index 0000000..54e3763 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusEvent.java @@ -0,0 +1,6 @@ +package com.eactive.eai.rms.data.ext.djb.apistatus; + +public interface ApiStatusEvent { + String getEaisvcname(); + String getEvent(); +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusRepository.java b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusRepository.java new file mode 100644 index 0000000..2315586 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusRepository.java @@ -0,0 +1,70 @@ +package com.eactive.eai.rms.data.ext.djb.apistatus; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.eactive.eai.data.jpa.BaseRepository; + +public interface ApiStatusRepository extends BaseRepository { + + /** + * API 상태 판단. 정상(N), 점검(C), 지연(D), 장애(E) + * + * 1. 정상/지연/장애 -> 점검중 : CONTROL_START + * 2. 점검 -> 점검X : CONTROL_END + * 3. 정상/지연 -> 장애 : ERROR_START + * 4. 장애 -> 장애X : ERROR_END + * 5. 정상 -> 지연 : DELAY_START + * 6. 지연 -> 지연X : DELAY_END + * 7. 기타 : STAY + */ + @Query(nativeQuery = true, value = + " SELECT EAISVCNAME, EVENT" + + " FROM (" + + " SELECT EAISVCNAME" + + " , CASE WHEN STATUS_CODE != 'C' AND CTRL_YN = 'Y' THEN 'CONTROL_START'" + + " WHEN STATUS_CODE = 'C' AND CTRL_YN = 'N' THEN 'CONTROL_END'" + + " WHEN STATUS_CODE IN ('N','D') AND ERR_YN = 'Y' THEN 'ERROR_START'" + + " WHEN STATUS_CODE = 'E' AND ERR_YN = 'N' THEN 'ERROR_END'" + + " WHEN STATUS_CODE = 'N' AND DELAY_YN = 'Y' THEN 'DELAY_START'" + + " WHEN STATUS_CODE = 'D' AND DELAY_YN = 'N' THEN 'DELAY_END'" + + " ELSE 'STAY' END AS EVENT" + + " FROM (" + + " SELECT A.EAISVCNAME" + + " , NVL(B.STATUS_CODE, '1') AS STATUS_CODE" + + " , NVL((SELECT 'Y' FROM TSEAITI01" + + " WHERE (TO_CHAR(SYSDATE,'HH24MI') BETWEEN SUBSTR(EAICTRLDSTICCTNT,16,4) AND SUBSTR(EAICTRLDSTICCTNT,21,4)" + + " OR SUBSTR(EAICTRLDSTICCTNT,16,9) = '0000|0000')" + + " AND A.EAISVCNAME = EAICTRLNAME),'N') AS CTRL_YN" + + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + + " WHEN (TOTAL - SUCCESS) * 100 / TOTAL > :errorRate THEN 'Y'" + + " ELSE 'N' END" + + " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL" + + " , SUM(SUCCESS_CNT) AS SUCCESS" + + " FROM API_STATS_MINUTE" + + " WHERE A.EAISVCNAME = API_NAME" + + " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')" + + " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')" + + " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN" + + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + + " WHEN RESP_SUM / TOTAL > :delayAvgRespTime THEN 'Y'" + + " ELSE 'N' END" + + " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL" + + " , SUM(SUCCESS_CNT) AS SUCCESS" + + " , SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME) AS RESP_SUM" + + " FROM API_STATS_MINUTE" + + " WHERE A.EAISVCNAME = API_NAME" + + " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')" + + " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:delayRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')" + + " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS DELAY_YN" + + " FROM TSEAIHE01 A LEFT OUTER JOIN API_STATUS B ON A.EAISVCNAME = B.EAISVCNAME" + + " )" + + " ) WHERE EVENT != 'STAY'") + List findApiStatusEvents( + @Param("errorRate") int errorRate, + @Param("errorRangeMinute") int errorRangeMinute, + @Param("delayRangeMinute") int delayRangeMinute, + @Param("delayAvgRespTime") int delayAvgRespTime + ); +} \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusService.java b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusService.java new file mode 100644 index 0000000..020634d --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/ext/djb/apistatus/ApiStatusService.java @@ -0,0 +1,61 @@ +package com.eactive.eai.rms.data.ext.djb.apistatus; + +import java.util.HashMap; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class ApiStatusService { + + private static final Logger log = LoggerFactory.getLogger(ApiStatusService.class); + + @Autowired + private ApiStatusRepository apiStatusRepository; + + + public void updateApiStatus(HashMap param) { + + List list = apiStatusRepository.findApiStatusEvents( + Integer.parseInt(param.get("errorRate")), + Integer.parseInt(param.get("errorRangeMinute")), + Integer.parseInt(param.get("delayRangeMinute")), + Integer.parseInt(param.get("delayAvgRespTime")) + ); + + for (ApiStatusEvent event : list) { + String newStatusCode = resolveStatusCode(event.getEvent()); + if (newStatusCode == null) continue; + + ApiStatus apiStatus = apiStatusRepository.findById(event.getEaisvcname()) + .orElse(new ApiStatus()); + + apiStatus.setEaisvcname(event.getEaisvcname()); + apiStatus.setStatusCode(newStatusCode); + + apiStatusRepository.save(apiStatus); // PK 있으면 UPDATE, 없으면 INSERT + log.debug("API 상태 변경: {} {} → {}", event.getEaisvcname(), event.getEvent(), newStatusCode); + + //TODO: 내부직원 알림발송 & 제휴사 webhook 발송 + } + } + + private String resolveStatusCode(String event) { + switch (event) { + case "CONTROL_START": return "C"; + case "CONTROL_END": return "N"; + case "ERROR_START": return "E"; + case "ERROR_END": return "N"; + case "DELAY_START": return "D"; + case "DELAY_END": return "N"; + default: + log.warn("알 수 없는 이벤트: {}", event); + return null; + } + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiStatusUpdateJob.java b/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiStatusUpdateJob.java new file mode 100644 index 0000000..3753696 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/job/ApiStatusUpdateJob.java @@ -0,0 +1,175 @@ +package com.eactive.eai.rms.ext.djb.job; + +import java.util.HashMap; + +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 com.eactive.eai.rms.common.context.MonitoringContext; +import com.eactive.eai.rms.common.datasource.DataSourceContextHolder; +import com.eactive.eai.rms.common.datasource.DataSourceTypeManager; +import com.eactive.eai.rms.common.util.CommonUtil; +import com.eactive.eai.rms.data.ext.djb.apistatus.ApiStatusService; +import com.eactive.eai.rms.onl.common.util.DateUtil; + +/** + * Job - Quartz Job + * API_STATS_MINUTE 데이터를 1분마다 조회하여 Api 상태를 판단하여 API_STATUS 테이블을 insert/update 한다. + * & api 상태가 변경된 경우, 알림 테이블에 저장한다 + * + *

Cron Schedule 권장:

+ *
    + *
  • 기본: 0 * * * * ? (매분 1회 실행)
  • + *
+ * + *

Job 파라미터 (JobDataMap):

+ *
    + *
  • + * api.status.error.range_minute: API 장애기준 판단 시간간격 (단위: 분, 기본값: 1) + * api.status.error.rate: API 장애기준 오류건수 비율(%) (단위: 시간, 기본값: 100) + * api.status.delay.range_minute: API 지연기준 판단 시간간격 (단위: 분, 기본값: 1) + * api.status.delay.avg_resp_time: API 지연기준 평균응답시간 (단위: 밀리세컨드, 기본값: 10000) + *
  • + *
+ * + * + */ +@DisallowConcurrentExecution +public class ApiStatusUpdateJob implements Job { + + private static final Logger log = LoggerFactory.getLogger(ApiStatusUpdateJob.class); + + /** Job 파라미터 키: API 장애기준 시간간격(분) */ + public static final String KEY_API_STATUS_ERROR_RANGE_MINUTE = "api.status.error.range_minute"; + + /** Job 파라미터 키: API 장애기준 오류건수 비율(%) */ + public static final String KEY_API_STATUS_ERROR_RATE = "api.status.error.rate"; + + /** Job 파라미터 키: API 지연기준 시간간격(분) */ + public static final String KEY_API_STATUS_DELAY_RANGE_MINUTE = "api.status.delay.range_minute"; + + /** Job 파라미터 키: API 지연기준 평균응답시간(ms) */ + public static final String KEY_API_STATUS_DELAY_AVG_RESP_TIME = "api.status.delay.avg_resp_time"; + + public static final String DEFAULT_ERROR_RANGE_MINUTE = "1"; + public static final String DEFAULT_ERROR_RATE = "100"; + public static final String DEFAULT_DELAY_RANGE_MINUTE = "1"; + public static final String DEFAULT_DELAY_AVG_RESP_TIME = "10000"; + + private transient MonitoringContext monitoringContext; + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + log.info("*** START ApiStatusUpdateJob run({})", CommonUtil.getToday("yyyy-MM-dd HH:mm")); + + // Job 파라미터 로깅 + JobDataMap jobDataMap = context.getJobDetail().getJobDataMap(); + logJobParameters(jobDataMap); + + HashMap param = this.checkParameters(jobDataMap); + + + ApplicationContext appContext; + try { + appContext = (ApplicationContext) context.getScheduler().getContext().get("applicationContext"); + } catch (SchedulerException e) { + log.error("applicationContext get module error", e); + return; + } + + monitoringContext = (MonitoringContext) appContext.getBean("monitoringContext"); + ApiStatusService apiStatusUpdateService = appContext.getBean(ApiStatusService.class); + + + DataSourceContextHolder.setDataSourceType( + DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW)); + try { + apiStatusUpdateService.updateApiStatus(param); + } catch (Exception e) { + log.error("ApiStatusUpdateJob execution failed", e); + throw new JobExecutionException(e); + } finally { + DataSourceContextHolder.clearDataSourceType(); + } + + log.info("*** END ApiStatusUpdateJob run({})", DateUtil.getDateTime("yyyy-MM-dd HH:mm")); + } + + /** + * Job 파라미터 로깅 + */ + private void logJobParameters(JobDataMap jobDataMap) { + if (jobDataMap == null || jobDataMap.isEmpty()) { + log.debug("Job 파라미터 없음 (기본값 사용)"); + return; + } + + StringBuilder sb = new StringBuilder("Job 파라미터: "); + for (String key : jobDataMap.getKeys()) { + sb.append(key).append("=").append(jobDataMap.getString(key)).append(", "); + } + log.info(sb.toString()); + } + + /** + * 집계 범위 시간 파라미터 파싱 + * + * @param jobDataMap Job 파라미터 맵 + * @return 집계 범위 시간 (파싱 실패 시 기본값 반환) + */ + private HashMap checkParameters(JobDataMap jobDataMap) { + + HashMap param = new HashMap(); + + if (jobDataMap == null) { + param.put("errorRangeMinute", DEFAULT_ERROR_RANGE_MINUTE); //1분동안 + param.put("errorRate", DEFAULT_ERROR_RATE); //에러가 100% 발생시 '장애'로 판단 + param.put("delayRangeMinute", DEFAULT_DELAY_RANGE_MINUTE); //1분동안 + param.put("delayAvgRespTime", DEFAULT_DELAY_AVG_RESP_TIME); //평균 응답속도가 10초 이상이면 '지연'으로 판단 + return param; + } + + if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_ERROR_RANGE_MINUTE))) { + param.put("errorRangeMinute", DEFAULT_ERROR_RANGE_MINUTE); + } else { + param.put("errorRangeMinute", jobDataMap.getString(KEY_API_STATUS_ERROR_RANGE_MINUTE)); + } + + if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_ERROR_RATE))) { + param.put("errorRate", DEFAULT_ERROR_RATE); + } else { + param.put("errorRate", jobDataMap.getString(KEY_API_STATUS_ERROR_RATE)); + } + + if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_DELAY_RANGE_MINUTE))) { + param.put("delayRangeMinute", DEFAULT_DELAY_RANGE_MINUTE); + } else { + param.put("delayRangeMinute", jobDataMap.getString(KEY_API_STATUS_DELAY_RANGE_MINUTE)); + } + + if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_DELAY_AVG_RESP_TIME))) { + param.put("delayAvgRespTime", DEFAULT_DELAY_AVG_RESP_TIME); + } else { + param.put("delayAvgRespTime", jobDataMap.getString(KEY_API_STATUS_DELAY_AVG_RESP_TIME)); + } + + return param; + } + + /** + * 개발 모드 여부 확인 + * @return eai.systemmode=D 이면 true + */ + private boolean isDevMode() { + String systemMode = System.getProperty("eai.systemmode", ""); + return "D".equalsIgnoreCase(systemMode); + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/statistics/ApiUseStatsController.java b/src/main/java/com/eactive/eai/rms/ext/djb/statistics/ApiUseStatsController.java new file mode 100644 index 0000000..3b79e3c --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/statistics/ApiUseStatsController.java @@ -0,0 +1,110 @@ +package com.eactive.eai.rms.ext.djb.statistics; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import com.eactive.eai.rms.common.vo.GridResponse; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService; +import com.eactive.ext.kjb.statistics.mapping.ApiStatsUIMapper; +import com.eactive.ext.kjb.statistics.service.ApiStatsExcelExportService; +import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI; +import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch; +import com.eactive.ext.kjb.statistics.ui.ApiStatsUI; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ApiUseStatsController { + + private final ApiStatsDayService service; + private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; + + @GetMapping(value = "/onl/djb/statistics/apiUseStatsMan.view") + public String view() { + return "/onl/djb/statistics/apiUseStatsMan"; + } + + @PostMapping(value = "/onl/djb/statistics/apiUseStatsMan.json", params = "cmd=LIST") + public ResponseEntity> selectList(ApiStatsSearch search, Pageable pageable) { + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(Sort.Order.desc("statTime"))); + + Page page = service.selectList(search, sortedPageable); + Page uiPage = page.map(mapper::toVo); + + return ResponseEntity.ok(new GridResponse<>(uiPage)); + } + + @PostMapping(value = "/onl/djb/statistics/apiStatsDayMan.json", params = "cmd=EXCEL_EXPORT") + public void exportToExcel(ApiStatsSearch search, HttpServletResponse response) throws IOException { + log.info("Excel export started - search: {}", search); + + try { + List dataList = service.selectListForExcel(search); + log.info("Data retrieved - count: {}", dataList.size()); + + if (dataList.isEmpty()) { + log.warn("No data found for export"); + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.setContentType("application/json; charset=UTF-8"); + response.getWriter().write("{\"message\":\"조회된 데이터가 없습니다.\"}"); + response.getWriter().flush(); + return; + } + + List uiList = dataList.stream() + .map(mapper::toVo) + .collect(Collectors.toList()); + log.info("Data converted to UI list - count: {}", uiList.size()); + + String fileName = generateFileName(search); + log.info("Generating Excel file: {}", fileName); + + excelExportService.exportApiStats(uiList, fileName, response); + log.info("Excel export completed successfully"); + + } catch (Exception e) { + log.error("Excel export failed - error: {}", e.getMessage(), e); + + if (!response.isCommitted()) { + response.reset(); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json; charset=UTF-8"); + response.getWriter().write("{\"message\":\"Excel 파일 생성 중 오류가 발생했습니다: " + e.getMessage() + "\"}"); + response.getWriter().flush(); + } else { + log.error("Cannot send error response - response already committed"); + } + } + } + + private String generateFileName(ApiStatsSearch search) { + String timeRange = StringUtils.isNotBlank(search.getSearchStartDateTime()) + ? search.getSearchStartDateTime() + : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + return "API사용현황_" + timeRange + ".xlsx"; + } + +}