API상태변화시 스윙알림에 에러메세지, 로그조회링크 추가 & 링크 클릭시 APIM 로그인페이지에서 로그인하면 로그조회 페이지로
eapim-admin CI / build (push) Waiting to run

바로 이동
This commit is contained in:
eastargh
2026-09-17 10:39:53 +09:00
parent dfcfb0ac0b
commit c3ad216ac9
12 changed files with 334 additions and 36 deletions
@@ -20,6 +20,11 @@
var url_view = '<c:url value="/onl/transaction/tracking/trackingMan.view" />'; var url_view = '<c:url value="/onl/transaction/tracking/trackingMan.view" />';
var url_excel = '<c:url value="/onl/transaction/tracking/trackingMan.excel" />'; var url_excel = '<c:url value="/onl/transaction/tracking/trackingMan.excel" />';
var GUID_LENGTH = <%=DbTrackingService.GUID_LENGTH%>; var GUID_LENGTH = <%=DbTrackingService.GUID_LENGTH%>;
// 로그인 딥링크(세션) 또는 화면 URL에 검색조건 파라미터가 직접 붙어 들어온 경우, 최초 진입시 자동조회
// 세션 딥링크는 파라미터명 그대로 모델에 등록되어 있으므로(예: ${searchStartYYYYMMDD}) bare 변수로 확인한다.
var isDeepLinkSearch = ${not empty searchStartYYYYMMDD
or not empty param.searchStartYYYYMMDD
or not empty param.searchEndYYYYMMDD};
var isProdServer = <%=com.eactive.eai.common.util.SystemUtil.isProdServer()%>; var isProdServer = <%=com.eactive.eai.common.util.SystemUtil.isProdServer()%>;
var roleString = "<%=SessionManager.getRoleIdString(request)%>"; var roleString = "<%=SessionManager.getRoleIdString(request)%>";
let totalLogs = 0; let totalLogs = 0;
@@ -441,6 +446,10 @@
getTransactionCounts(); getTransactionCounts();
}); });
if (isDeepLinkSearch) {
$("#btn_search").click();
}
$("input[name^=search]").keydown(function (key) { $("input[name^=search]").keydown(function (key) {
if (key.keyCode == 13) { if (key.keyCode == 13) {
$("#btn_search").click(); $("#btn_search").click();
@@ -568,7 +577,7 @@
</div><!-- end content_top --> </div><!-- end content_top -->
<div class="content_middle" id="content_middle"> <div class="content_middle" id="content_middle">
<div class="search_wrap"> <div class="search_wrap">
<input type="checkbox" id="schedulerToggle"> <input type="checkbox" id="schedulerToggle"/>&nbsp;<b><label for="schedulerToggle">Auto Refresh</label></b>&nbsp;
<button type="button" class="cssbtn" id="btn_excel" level="W"><i <button type="button" class="cssbtn" id="btn_excel" level="W"><i
class="material-icons">table_view</i> <%= localeMessage.getString("button.excel") %> class="material-icons">table_view</i> <%= localeMessage.getString("button.excel") %>
</button> </button>
@@ -595,11 +604,11 @@
</td> </td>
<th style="width:12%; min-width:120px;"><%=localeMessage.getString("tracking.msgrecvtm")%></th> <th style="width:12%; min-width:120px;"><%=localeMessage.getString("tracking.msgrecvtm")%></th>
<td> <td>
<input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:70px; border:1px solid #ebebec;"> <input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="${not empty searchStartYYYYMMDD ? searchStartYYYYMMDD : param.searchStartYYYYMMDD}" size="10" style="width:70px; border:1px solid #ebebec;">
<input type="text" name="searchStartHHMM" value="" size="8" style="width:70px; border:1px solid #ebebec;"> <input type="text" name="searchStartHHMM" value="${not empty searchStartHHMM ? searchStartHHMM : param.searchStartHHMM}" size="8" style="width:70px; border:1px solid #ebebec;">
~ ~
<input type="text" name="searchEndYYYYMMDD" value="" id="endDatepicker" size="10" readonly="readonly" style="width:70px; border:1px solid #ebebec;"> <input type="text" name="searchEndYYYYMMDD" value="${not empty searchEndYYYYMMDD ? searchEndYYYYMMDD : param.searchEndYYYYMMDD}" id="endDatepicker" size="10" readonly="readonly" style="width:70px; border:1px solid #ebebec;">
<input type="text" name="searchEndHHMM" value="" size="8" style="width:70px; border:1px solid #ebebec;"> <input type="text" name="searchEndHHMM" value="${not empty searchEndHHMM ? searchEndHHMM : param.searchEndHHMM}" size="8" style="width:70px; border:1px solid #ebebec;">
<input type="hidden" name="searchStartTime" value="" style="width:0px;"> <input type="hidden" name="searchStartTime" value="" style="width:0px;">
<input type="hidden" name="searchEndTime" value="" style="width:0px;"> <input type="hidden" name="searchEndTime" value="" style="width:0px;">
</td> </td>
@@ -617,8 +626,9 @@
</td> </td>
<th ><%=localeMessage.getString("tracking.logtype")%></th> <th ><%=localeMessage.getString("tracking.logtype")%></th>
<td> <td>
<input type="radio" name="searchErrorYn" value="N" checked="checked" id="sub2_2_1_1"><label for="sub2_2_1_1"><%= localeMessage.getString("tracking.allLog") %></label> <c:set var="deepLinkErrorYn" value="${not empty searchErrorYn ? searchErrorYn : param.searchErrorYn}" />
<input type="radio" name="searchErrorYn" value="Y" id="sub2_2_1_2"><label for="sub2_2_1_2"><%= localeMessage.getString("tracking.errLog") %></label> <input type="radio" name="searchErrorYn" value="N" ${deepLinkErrorYn eq 'Y' ? '' : 'checked="checked"'} id="sub2_2_1_1"><label for="sub2_2_1_1"><%= localeMessage.getString("tracking.allLog") %></label>
<input type="radio" name="searchErrorYn" value="Y" ${deepLinkErrorYn eq 'Y' ? 'checked="checked"' : ''} id="sub2_2_1_2"><label for="sub2_2_1_2"><%= localeMessage.getString("tracking.errLog") %></label>
</td> </td>
<!-- 로그조회키 --> <!-- 로그조회키 -->
@@ -6,6 +6,9 @@ import java.net.InetAddress;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Hashtable; import java.util.Hashtable;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -64,6 +67,18 @@ public class MainController implements InterceptorSkipController {
private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp"; private static final String REDIRECT_LOGIN_URL = "redirect:/emergency.jsp";
// 로그인시 target 파라미터로 지정 가능한 딥링크 대상화면 식별자 -> mainPage 경로 매핑
// (신규 대상화면 추가시 이 맵에만 등록하면 됨)
private static final Map<String, String> DEEPLINK_TARGET_MAIN_PAGE_MAP;
static {
Map<String, String> map = new HashMap<>();
map.put("tracking", "/onl/transaction/tracking/trackingMan.view"); // 로그조회
DEEPLINK_TARGET_MAIN_PAGE_MAP = Collections.unmodifiableMap(map);
}
// 로그인 완료 후 이동할 딥링크 대상화면 식별자를 담아두는 세션 Key명
private static final String SESSION_PENDING_DEEPLINK_TARGET = "pendingDeepLinkTarget";
private static final String USER_STATUS_LOCKED = "2"; private static final String USER_STATUS_LOCKED = "2";
private static final int DEFAULT_MAX_LOGIN_FAIL_COUNT = 5; private static final int DEFAULT_MAX_LOGIN_FAIL_COUNT = 5;
private static final int DEFAULT_PASSWORD_HISTORY_COUNT = 5; private static final int DEFAULT_PASSWORD_HISTORY_COUNT = 5;
@@ -121,9 +136,45 @@ public class MainController implements InterceptorSkipController {
logger.info("비상 로그인 모드 활성화 - SSO/SMS 인증 우회"); logger.info("비상 로그인 모드 활성화 - SSO/SMS 인증 우회");
} }
savePendingDeepLink(request, session);
return "/common/screen/emergency"; return "/common/screen/emergency";
} }
/**
* 로그인 페이지에 target 파라미터(딥링크 대상화면 식별자)가 있으면,
* 로그인 성공 후 이동할 화면 정보와 그 화면에 전달할 나머지 파라미터를 세션에 임시 저장한다.
* target/fullScreen/emergency 를 제외한 나머지 파라미터는 그대로(이름 변경 없이) 전달되므로,
* 로그인 URL에는 대상화면이 실제 사용하는 검색조건 파라미터명(예: searchEaiSvcName, searchStartYYYYMMDD)을
* 그대로 넘겨야 한다. (실제 사용은 각 대상화면의 컨트롤러가 담당)
*/
private void savePendingDeepLink(HttpServletRequest request, HttpSession session) {
String target = request.getParameter("target");
if (StringUtils.isBlank(target) || !DEEPLINK_TARGET_MAIN_PAGE_MAP.containsKey(target)) {
return;
}
session.setAttribute(SESSION_PENDING_DEEPLINK_TARGET, target);
Map<String, String> deepLinkParams = new HashMap<>();
Enumeration<String> paramNames = request.getParameterNames();
while (paramNames.hasMoreElements()) {
String name = paramNames.nextElement();
if ("target".equals(name) || "fullScreen".equals(name) || "emergency".equals(name)) {
continue;
}
deepLinkParams.put(name, request.getParameter(name));
}
session.setAttribute(CommonConstants.PENDING_DEEPLINK_PARAMS, deepLinkParams);
logger.info("딥링크 로그인 요청 - target: " + target);
}
@RequestMapping("/login.do") @RequestMapping("/login.do")
public String preMain(HttpServletRequest request, LoginVo dto) { public String preMain(HttpServletRequest request, LoginVo dto) {
@@ -561,9 +612,35 @@ public class MainController implements InterceptorSkipController {
if (!processServiceType(request, dto)) { return REDIRECT_LOGIN_URL; } if (!processServiceType(request, dto)) { return REDIRECT_LOGIN_URL; }
session.setAttribute(CommonConstants.LOGIN, dto); session.setAttribute(CommonConstants.LOGIN, dto);
String deepLinkMainPage = consumePendingDeepLinkMainPage(request, session);
if (deepLinkMainPage != null) {
return "redirect:/main.do?mainPage=" + deepLinkMainPage;
}
return "redirect:/main.do"; return "redirect:/main.do";
} }
// 로그인 시 저장해둔 딥링크 대상화면(target)을 1회성으로 꺼내 mainPage 경로로 변환한다.
// mainPage 값은 iframe src로 그대로 쓰이는 절대경로이므로 contextPath 를 반드시 포함해야 한다.
// (검색조건 등 나머지 파라미터는 CommonConstants.PENDING_DEEPLINK_PARAMS 에 남겨두고,
// 대상화면의 컨트롤러가 화면 진입 시점에 직접 소비한다)
private String consumePendingDeepLinkMainPage(HttpServletRequest request, HttpSession session) {
String target = (String) session.getAttribute(SESSION_PENDING_DEEPLINK_TARGET);
session.removeAttribute(SESSION_PENDING_DEEPLINK_TARGET);
if (target == null) {
return null;
}
String mainPage = DEEPLINK_TARGET_MAIN_PAGE_MAP.get(target);
if (mainPage == null) {
return null;
}
return request.getContextPath() + mainPage;
}
// 사용자 최신 로그인 정보 업데이트 // 사용자 최신 로그인 정보 업데이트
private void updateUserInfo(HttpServletRequest request, UserInfo userInfo) { private void updateUserInfo(HttpServletRequest request, UserInfo userInfo) {
String lastLoginIp = IpUtil.getClientIp(request); String lastLoginIp = IpUtil.getClientIp(request);
@@ -117,6 +117,10 @@ public class CommonConstants {
public static final String loginId_KEY = "loginId"; public static final String loginId_KEY = "loginId";
public static final String LOGIN ="login"; public static final String LOGIN ="login";
// 로그인 딥링크(target 파라미터)로 넘어온 검색조건 등 부가 파라미터를 임시 보관하는 세션 Key명.
// 대상화면의 컨트롤러가 화면 진입 시 1회성으로 꺼내 쓰고 세션에서 제거해야 한다.
public static final String PENDING_DEEPLINK_PARAMS = "pendingDeepLinkParams";
public static final String USER_SERVICETYPE_NO_CHECK = "NO_CHECK"; public static final String USER_SERVICETYPE_NO_CHECK = "NO_CHECK";
@@ -19,4 +19,6 @@ public interface EAILogRepository {
List<EAILog> getApiLog(Class<? extends EAILog> class1, String apiServiceNumber); List<EAILog> getApiLog(Class<? extends EAILog> class1, String apiServiceNumber);
String findFirstErrorContent(Class<? extends EAILog> clazz, String apiId, String rangeStart, String rangeEnd);
} }
@@ -261,4 +261,35 @@ public class EAILogRepositoryImpl implements EAILogRepository {
.fetch(); .fetch();
} }
/**
* 지정 구간에서 apiId의 장애(시스템 오류) 로그 중 가장 먼저 발생한 1건의 오류내용(EAIERRCTNT).
*
* <p>장애 판단 기준은 {@link #appendConditions}의 {@code searchErrorYn=Y} 분기와 동일하다 -
* EAIERRCD에 값이 있고, 업무 오류(ERRCODE_BIZ)가 아닌 경우만 장애로 본다.</p>
*/
@Override
public String findFirstErrorContent(Class<? extends EAILog> clazz, String apiId, String rangeStart, String rangeEnd) {
QEAILog qeaiLog = QEAILog.eAILog;
EntityPathBase<?> entityPathBase = new EntityPathBase<>(clazz, qeaiLog.getMetadata().getName());
JPAQuery<String> query = new JPAQueryFactory(em)
.select(qeaiLog.eaierrctnt)
.from(entityPathBase)
.where(qeaiLog.eaisvcname.eq(apiId))
.where(qeaiLog.id.msgdpstyms.between(rangeStart, rangeEnd))
.where(qeaiLog.eaierrcd.trim().isNotEmpty());
List<String> bizErrCodes = codeService.findByIdCodegroup(ERRCODE_BIZ_GROUP).stream()
.map(code -> code.getId().getCode())
.collect(Collectors.toList());
if (!bizErrCodes.isEmpty()) {
query.where(qeaiLog.eaierrcd.notIn(bizErrCodes));
}
return query.orderBy(qeaiLog.id.msgdpstyms.asc(), qeaiLog.id.eaisvcserno.asc(), qeaiLog.id.logprcssserno.asc())
.limit(1)
.fetchOne();
}
} }
@@ -21,4 +21,6 @@ public interface EAILogService {
Map<String, Long> getTransactionCounts(EAILogSearch eaiLogSearch); Map<String, Long> getTransactionCounts(EAILogSearch eaiLogSearch);
ApiLogDTO getApiLog(String txDate, String apiServiceNumber); ApiLogDTO getApiLog(String txDate, String apiServiceNumber);
String findFirstErrorContent(String apiId, String rangeStart, String rangeEnd);
} }
@@ -148,4 +148,11 @@ public class EAILogServiceImpl implements EAILogService {
return apiLogDTO; return apiLogDTO;
} }
@Override
public String findFirstErrorContent(String apiId, String rangeStart, String rangeEnd) {
long time = CommonUtil.getCalendar(rangeStart).getTimeInMillis();
EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time);
return eaiLogRepository.findFirstErrorContent(eaiLog.getClass(), apiId, rangeStart, rangeEnd);
}
} }
@@ -46,12 +46,10 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM (" + " FROM ("
+ " SELECT A.EAISVCNAME" + " SELECT A.EAISVCNAME"
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE" + " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
// 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CHECK_START 가 발생한다.
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X" + " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID " + " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
+ " AND Y.KIND = 'MAINTENANCE'" + " AND Y.KIND = 'MAINTENANCE'"
+ " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) " + " AND :rangeEnd BETWEEN STARTED_AT AND END_AT) "
+ " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN " + " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN "
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN (TOTAL - SUCCESS) * 100 / TOTAL >= :errorRate THEN 'Y'" + " WHEN (TOTAL - SUCCESS) * 100 / TOTAL >= :errorRate THEN 'Y'"
@@ -61,8 +59,8 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM API_STATS_MINUTE" + " FROM API_STATS_MINUTE"
+ " WHERE A.EAISVCNAME = API_NAME" + " WHERE A.EAISVCNAME = API_NAME"
+ " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')" + " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')" + " BETWEEN TO_CHAR(:errorRangeStart,'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN" + " AND TO_CHAR(:rangeEnd,'YYYYMMDDHH24MI'))) AS ERR_YN"
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'" + " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN RESP_SUM / TOTAL >= :delayAvgRespTime THEN 'Y'" + " WHEN RESP_SUM / TOTAL >= :delayAvgRespTime THEN 'Y'"
+ " ELSE 'N' END" + " ELSE 'N' END"
@@ -72,16 +70,17 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM API_STATS_MINUTE" + " FROM API_STATS_MINUTE"
+ " WHERE A.EAISVCNAME = API_NAME" + " WHERE A.EAISVCNAME = API_NAME"
+ " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')" + " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:delayRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')" + " BETWEEN TO_CHAR(:delayRangeStart,'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS DELAY_YN" + " AND TO_CHAR(:rangeEnd,'YYYYMMDDHH24MI'))) AS DELAY_YN"
+ " FROM TSEAIHE01 A LEFT OUTER JOIN API_STATUS B ON A.EAISVCNAME = B.EAISVCNAME" + " FROM TSEAIHE01 A LEFT OUTER JOIN API_STATUS B ON A.EAISVCNAME = B.EAISVCNAME"
+ " )" + " )"
+ " ) A WHERE EVENT != 'STAY'") + " ) A WHERE EVENT != 'STAY'")
List<ApiStatusEvent> findApiStatusEvents( List<ApiStatusEvent> findApiStatusEvents(
@Param("errorRate") int errorRate, @Param("errorRate") int errorRate,
@Param("errorRangeMinute") int errorRangeMinute, @Param("delayAvgRespTime") int delayAvgRespTime,
@Param("delayRangeMinute") int delayRangeMinute, @Param("errorRangeStart") LocalDateTime errorRangeStart,
@Param("delayAvgRespTime") int delayAvgRespTime @Param("delayRangeStart") LocalDateTime delayRangeStart,
@Param("rangeEnd") LocalDateTime rangeEnd
); );
/** /**
@@ -2,7 +2,10 @@ package com.eactive.eai.rms.ext.djb.apistatus;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -10,6 +13,7 @@ import java.util.Map;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -26,6 +30,7 @@ import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.data.entity.man.role.Role; import com.eactive.eai.rms.data.entity.man.role.Role;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiErrorRateRow;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent; import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
import com.eactive.eai.rms.data.entity.onl.logger.EAILogService;
import com.eactive.eai.rms.ext.djb.ums.UmsManager; import com.eactive.eai.rms.ext.djb.ums.UmsManager;
@@ -47,6 +52,9 @@ public class ApiStatusService {
@Autowired @Autowired
private MonitoringContext monitoringContext; private MonitoringContext monitoringContext;
@Autowired
private EAILogService eaiLogService;
@Autowired @Autowired
@Qualifier("transactionManagerForEMS") @Qualifier("transactionManagerForEMS")
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@@ -70,11 +78,20 @@ public class ApiStatusService {
logErrorRates(errorRate, errorRangeMinute, delayRangeMinute, delayAvgRespTime); logErrorRates(errorRate, errorRangeMinute, delayRangeMinute, delayAvgRespTime);
// 조회구간을 WAS 시각 기준으로 계산해 쿼리에 직접 바인딩한다.
// DB의 SYSDATE 를 기준으로 하면 DB 서버와 WAS 서버의 시각이 어긋날 때 판정구간이
// 미묘하게 달라질 수 있고, 알림 메시지에 넣는 로그조회 딥링크의 구간과도 어긋나게 된다.
// 장애/지연 판정기준(분)이 서로 다를 수 있으므로 각자의 시작시간을 따로 계산한다.
LocalDateTime rangeEnd = LocalDateTime.now();
LocalDateTime errorRangeStart = rangeEnd.minusMinutes(errorRangeMinute);
LocalDateTime delayRangeStart = rangeEnd.minusMinutes(delayRangeMinute);
List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents( List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents(
errorRate, errorRate,
errorRangeMinute, delayAvgRespTime,
delayRangeMinute, errorRangeStart,
delayAvgRespTime delayRangeStart,
rangeEnd
); );
HashMap<String, List<String>> eventMap = new HashMap<>(); HashMap<String, List<String>> eventMap = new HashMap<>();
@@ -112,7 +129,7 @@ public class ApiStatusService {
//내부직원 스윙챗 발송 //내부직원 스윙챗 발송
if (monitoringContext.getBooleanProperty(MonitoringContext.DJB_UMS_MESSENGER_APIMONITOR_ENABLED, true)) { if (monitoringContext.getBooleanProperty(MonitoringContext.DJB_UMS_MESSENGER_APIMONITOR_ENABLED, true)) {
HashMap<String, Object> params = getSwingChatMessage(event, apiIds); HashMap<String, Object> params = getSwingChatMessage(event, apiIds, errorRangeStart, delayRangeStart, rangeEnd);
ums.sendMessenger("api-monitor", MessageCode.API_STATUS_CHANGED, params); ums.sendMessenger("api-monitor", MessageCode.API_STATUS_CHANGED, params);
} }
@@ -247,41 +264,152 @@ public class ApiStatusService {
} }
private HashMap<String, Object> getSwingChatMessage(String event, List<String> apiIds) { private HashMap<String, Object> getSwingChatMessage(String event, List<String> apiIds,
LocalDateTime errorRangeStart, LocalDateTime delayRangeStart, LocalDateTime rangeEnd) {
String message = ""; String message = "";
switch (event) { switch (event) {
case "CHECK_START": case "CHECK_START":
message = "API 서비스 점검이 시작되었습니다"; //점검 message = "API 서비스 점검이 시작되었습니다"; //점검
break; break;
case "CHECK_END": case "CHECK_END":
message = "API 서비스 점검이 완료되었습니다"; //정상 message = "API 서비스 점검이 완료되었습니다"; //정상
break; break;
case "ERROR_START": case "ERROR_START":
message = "API 서비스 장애가 발생하였습니다"; //장애 message = "API 서비스 장애가 발생하였습니다"; //장애
break; break;
case "ERROR_END": case "ERROR_END":
message = "API 서비스 장애가 복구되었습니다"; //정상 message = "API 서비스 장애가 복구되었습니다"; //정상
break; break;
case "DELAY_START": case "DELAY_START":
message = "API 서비스 지연이 발생하였습니다"; //지연 message = "API 서비스 지연이 발생하였습니다"; //지연
break; break;
case "DELAY_END": case "DELAY_END":
message = "API 서비스 지연이 복구되었습니다"; //정상 message = "API 서비스 지연이 복구되었습니다"; //정상
break; break;
default: default:
message = ""; message = "";
} }
if (apiIds.size() > 1) { if ("CHECK_START".equals(event) || "CHECK_END".equals(event)) {
message += "\n" + apiIds.get(0) + "" + (apiIds.size()-1) + ""; if (apiIds.size() > 1) {
message += "<br/>- " + apiIds.get(0) + "" + (apiIds.size()-1) + "";
} else {
message += "<br/>- " + apiIds.get(0);
}
} else { } else {
message += "\n- " + apiIds.get(0); message += "<br/>- 인터페이스 : " + String.join(", ", apiIds);
} }
// 장애 발생시에만 첫번째 API ID의 장애 로그(가장 먼저 발생한 1건) 오류내용을 메시지에 붙인다.
if ("ERROR_START".equals(event) && !apiIds.isEmpty()) {
String errorContent = findFirstErrorContent(apiIds.get(0), errorRangeStart, rangeEnd);
if (StringUtils.isNotBlank(errorContent)) {
message += "<br/>- 에러메세지 : " + errorContent;
}
}
// 장애/지연 발생시에만 첫번째 API ID로 로그조회 화면 바로가기 링크를 메시지 끝에 추가한다.
// (장애/지연은 판정기준 시간간격이 서로 다를 수 있어 각자의 판정구간을 사용한다)
// UmsService.makeBodyData() 가 이 마커 뒤의 내용을 URL로 보고 본문에서 떼어내 확인URL(PC) 필드로 옮긴다.
LocalDateTime linkRangeStart = "ERROR_START".equals(event) ? errorRangeStart
: "DELAY_START".equals(event) ? delayRangeStart
: null;
if (linkRangeStart != null) {
String loginUrl = buildTrackingLoginUrl(linkRangeStart, rangeEnd);
if (loginUrl != null) {
message += "<br/>- 로그조회링크 : " + loginUrl;
}
}
HashMap<String, Object> params = new HashMap<>(); HashMap<String, Object> params = new HashMap<>();
params.put("message", message); params.put("message", message);
return params; return params;
} }
private static final DateTimeFormatter DEEPLINK_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter DEEPLINK_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
// MSGDPSTYMS(로그 테이블 PK 일부) 형식과 동일 - yyyyMMddHHmmss + 밀리초(3자리)
private static final DateTimeFormatter LOG_RANGE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
// WAR는 항상 이 context path로 배포된다 (readme.md/build.gradle 기준)
private static final String ADMIN_CONTEXT_PATH = "/monitoring";
/**
* 판정 구간에서 apiId의 장애 로그 중 가장 먼저 발생한 1건의 오류내용(EAIERRCTNT).
*
* <p>장애 판단 기준은 {@code EAILogRepositoryImpl}의 에러로그 검색 조건과 동일 - EAIERRCD에 값이 있고
* 업무 오류(공통코드 ERRCODE_BIZ)가 아닌 경우만 장애로 본다.</p>
*
* <p>알림 발송 자체를 막아서는 안 되므로 조회 실패는 로그만 남기고 삼킨다.</p>
*/
private String findFirstErrorContent(String apiId, LocalDateTime rangeStart, LocalDateTime rangeEnd) {
try {
String start = rangeStart.format(LOG_RANGE_FORMATTER) + "000";
String end = rangeEnd.format(LOG_RANGE_FORMATTER) + "999";
return eaiLogService.findFirstErrorContent(apiId, start, end);
} catch (Exception e) {
log.warn("장애 로그 조회 실패 - {}", apiId, e);
return null;
}
}
/**
* 관리콘솔 로그인 화면으로 이동해 로그인 완료 즉시 로그조회 화면에서
* 해당 시간구간의 에러로그로 자동조회 되도록 하는 딥링크 URL을 만든다.
* (로그조회 화면 파라미터명과 동일해야 하며, {@code MainController.loginForm.do} 의
* target=tracking 딥링크 처리와 짝을 이룬다)
*
* <p>API ID는 여러 건일 수 있어 검색조건으로 넣지 않고, 대신 장애 로그(searchErrorYn=Y)로만 좁힌다.</p>
*
* @return 현재 서버 IP에 대한 WEBSERVERPORT 프로퍼티를 못 찾으면 null
*/
private String buildTrackingLoginUrl(LocalDateTime rangeStart, LocalDateTime rangeEnd) {
String baseUrl = resolveCurrentServerBaseUrl();
if (baseUrl == null) {
return null;
}
StringBuilder url = new StringBuilder(baseUrl);
url.append("/loginForm.do?target=tracking");
url.append("&searchErrorYn=Y");
url.append("&searchStartYYYYMMDD=").append(rangeStart.format(DEEPLINK_DATE_FORMATTER));
url.append("&searchStartHHMM=").append(rangeStart.format(DEEPLINK_TIME_FORMATTER));
url.append("&searchEndYYYYMMDD=").append(rangeEnd.format(DEEPLINK_DATE_FORMATTER));
url.append("&searchEndHHMM=").append(rangeEnd.format(DEEPLINK_TIME_FORMATTER));
return url.toString();
}
/**
* 현재 서버(이 WAS가 뜬 인스턴스)의 IP로, 모니터링 프로퍼티 그룹명이
* {@code Monitoring{<ip>}} 형태로 등록된 WEBSERVERPORT 프로퍼티 값을 찾아 base URL을 만든다.
* ({@link MonitoringContext#getPortMapper()}가 이미 그룹명에서 IP를 뽑아 IP -> 포트로
* 매핑해두므로 그대로 재사용한다. {@code EaiServerInfoServiceImpl}과 동일한 조회 방식)
*
* @return WEBSERVERPORT 프로퍼티가 없으면 null
*/
private String resolveCurrentServerBaseUrl() {
String ip;
try {
ip = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.warn("현재 서버 IP 조회 실패 - 로그조회 딥링크 생략", e);
return null;
}
Map<String, String> portMapper = monitoringContext.getPortMapper();
String port = (portMapper != null) ? portMapper.get(ip) : null;
if (StringUtils.isEmpty(port)) {
log.warn("WEBSERVERPORT 프로퍼티 없음 (Monitoring{" + ip + "}) - 로그조회 딥링크 생략");
return null;
}
return "http://" + ip + ":" + port + ADMIN_CONTEXT_PATH;
}
} }
@@ -48,6 +48,9 @@ public class UmsService {
private static final String BZWK_CD = "EBK"; //업무코드 private static final String BZWK_CD = "EBK"; //업무코드
private static final String SUB_BZWK_CD = "EBG"; //서브업무코드 private static final String SUB_BZWK_CD = "EBG"; //서브업무코드
// message 끝에 붙는 확인URL(PC) 마커. 이 뒤의 내용은 URL로 간주해 본문에서 떼어낸다.
private static final String CHECK_URL_FOR_PC_MARKER = "<br/>- 로그조회링크 : ";
private final MonitoringPropertyService propertyService; private final MonitoringPropertyService propertyService;
private final MessageRequestService messageRequestService; private final MessageRequestService messageRequestService;
@@ -256,7 +259,21 @@ public class UmsService {
SwingDataVO swiDataVO = new SwingDataVO(); SwingDataVO swiDataVO = new SwingDataVO();
swiDataVO.setNotiCode("CNO_FEP_API_0002"); //0001:긴급, 0002:중요 swiDataVO.setNotiCode("CNO_FEP_API_0002"); //0001:긴급, 0002:중요
swiDataVO.setNotiTitle("알림"); swiDataVO.setNotiTitle("알림");
swiDataVO.setNotiContent(messageRequest.getMessage());
// message 끝에 "로그조회링크 : URL" 이 붙어 있으면(ApiStatusService 참고) 본문에서는
// 떼어내고 URL만 뽑아 확인URL(PC) 필드에 따로 담는다.
String notiContent = messageRequest.getMessage();
String checkUrlForPc = "";
int checkUrlForPcIdx = notiContent.indexOf(CHECK_URL_FOR_PC_MARKER);
if (checkUrlForPcIdx >= 0) {
checkUrlForPc = notiContent.substring(checkUrlForPcIdx + CHECK_URL_FOR_PC_MARKER.length()).trim();
notiContent = notiContent.substring(0, checkUrlForPcIdx).trim();
}
swiDataVO.setNotiContent(notiContent);
swiDataVO.setCheckUrlForPc(checkUrlForPc);
// 스윙챗 알림 데이터 // 스윙챗 알림 데이터
swiDataVO.setReceiverInfo(getSwingReceiver(messageRequest)); swiDataVO.setReceiverInfo(getSwingReceiver(messageRequest));
@@ -15,7 +15,12 @@ public class SwingDataVO {
/** 알림내용 */ /** 알림내용 */
private String notiContent; private String notiContent;
/** 확인URL(PC) */
private String checkUrlForPc;
/** 수신자정보 */ /** 수신자정보 */
private List<SwingReceiverDataVO> receiverInfo; private List<SwingReceiverDataVO> receiverInfo;
} }
@@ -8,6 +8,7 @@ import com.eactive.eai.data.entity.onl.server.EAIServer;
import com.eactive.eai.rms.common.combo.ComboVo; import com.eactive.eai.rms.common.combo.ComboVo;
import com.eactive.eai.rms.common.context.MonitoringContext; import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.common.logging.UserAccessLogger; import com.eactive.eai.rms.common.logging.UserAccessLogger;
import com.eactive.eai.rms.common.util.CommonConstants;
import com.eactive.eai.rms.common.util.CommonUtil; import com.eactive.eai.rms.common.util.CommonUtil;
import com.eactive.eai.rms.common.vo.GridResponse; import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.adapter.AdapterGroupService; import com.eactive.eai.rms.data.entity.onl.adapter.AdapterGroupService;
@@ -42,9 +43,11 @@ import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.ModelAndView;
import org.springframework.ui.ModelMap;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.*; import java.util.*;
@@ -73,7 +76,20 @@ public class DbTrackingController {
} }
@GetMapping(value = "/onl/transaction/tracking/trackingMan.view") @GetMapping(value = "/onl/transaction/tracking/trackingMan.view")
public String viewList() { public String viewList(HttpServletRequest request, ModelMap model) {
HttpSession session = request.getSession();
// 로그인 딥링크(target=tracking)로 넘어온 검색조건 파라미터를 1회성으로 소비한다.
// 파라미터명은 이 화면의 검색조건 input name(searchEaiSvcName, searchStartYYYYMMDD 등)과 동일해야 하며,
// 그 이름 그대로 모델에 등록해 JSP에서 파라미터명 그대로(${searchXXX}) 꺼내 쓸 수 있게 한다.
@SuppressWarnings("unchecked")
Map<String, String> pendingParams = (Map<String, String>) session.getAttribute(CommonConstants.PENDING_DEEPLINK_PARAMS);
if (pendingParams != null) {
session.removeAttribute(CommonConstants.PENDING_DEEPLINK_PARAMS);
model.addAllAttributes(pendingParams);
}
return "/onl/transaction/tracking/trackingMan"; return "/onl/transaction/tracking/trackingMan";
} }