diff --git a/WebContent/WEB-INF/springapp-servlet.xml b/WebContent/WEB-INF/springapp-servlet.xml index 3bef9a1..89c338a 100644 --- a/WebContent/WEB-INF/springapp-servlet.xml +++ b/WebContent/WEB-INF/springapp-servlet.xml @@ -152,4 +152,9 @@ + + + + + diff --git a/WebContent/jsp/onl/admin/adapter/adapterManDetail.jsp b/WebContent/jsp/onl/admin/adapter/adapterManDetail.jsp index 66fd2f1..04c907b 100644 --- a/WebContent/jsp/onl/admin/adapter/adapterManDetail.jsp +++ b/WebContent/jsp/onl/admin/adapter/adapterManDetail.jsp @@ -513,7 +513,36 @@ function gridRendering(){ colModel:[ { name : 'PRPTYGROUPNAME' , align:'left' , hidden: true }, { name : 'PRPTYNAME' , width: 70 , align:'left' }, - { name : 'PRPTY2VAL' , width: 70 , align:'left' , title:false, editable: true, formatter:mltplFormatter, unformatter:mltplUnFormatter}, + { + name : 'PRPTY2VAL', + width: 70, + align:'left', + title:false, + editable: true, + formatter:mltplFormatter, + unformatter:mltplUnFormatter, + editoptions: { + dataEvents: [ + { + type: 'keydown', + fn: function(e) { + if (e.which === 13) { + var val = $(this).val(); + $(this).val($.trim(val)); + } + } + }, + { + type: 'blur', + fn: function(e) { + var val = $(this).val(); + $(this).val($.trim(val)); + } + } + ] + } + // [여기까지 추가] + }, { name : 'PRPTYDESC' , width: 300 , align:'left'}, { name : 'DMNKND' , hidden:true}, { name : 'DOMAINTYPE' , hidden:true, formatter:nullFormatter} @@ -942,6 +971,7 @@ $(document).ready(function() { buttonControl(isDetail); titleControl(isDetail); + }); // clientManPopup.jsp에서 호출될 함수 (전역 스코프에 정의) diff --git a/WebContent/jsp/onl/kjb/statistics/apiStatsAggregationMan.jsp b/WebContent/jsp/onl/kjb/statistics/apiStatsAggregationMan.jsp new file mode 100644 index 0000000..8322d71 --- /dev/null +++ b/WebContent/jsp/onl/kjb/statistics/apiStatsAggregationMan.jsp @@ -0,0 +1,353 @@ +<%@ page language="java" contentType="text/html; charset=utf-8"%> +<%@ page import="java.io.*"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ include file="/jsp/common/include/localemessage.jsp" %> +<% + response.setHeader("Pragma", "No-cache"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Expires", "0"); + request.setCharacterEncoding("UTF-8"); +%> + + + API 통계 집계 실행 + + + + + + + +
+
+ +
+
+
API 통계 집계 수동 실행
+ +
+ ⚠️ 주의사항 +
    +
  • 통계 집계는 스케줄러에 의해 자동으로 실행됩니다.
  • +
  • 수동 실행은 누락된 기간이나 재집계가 필요한 경우에만 사용하세요.
  • +
  • 집계 대상 기간의 원본 데이터가 존재해야 합니다.
  • +
  • 대상 날짜/월/연도를 반드시 입력해야 합니다.
  • +
+
+ + +
+

1. 시간별 → 일별 통계 집계

+
+ + + (예: 20250120) + +
+
+ 실행 결과: +

+			
+
+ + +
+

2. 일별 → 월별 통계 집계

+
+ + + (예: 202501) + +
+
+ 실행 결과: +

+			
+
+ + +
+

3. 월별 → 연별 통계 집계

+
+ + + (예: 2024) + +
+
+ 실행 결과: +

+			
+
+
+
+ + diff --git a/WebContent/jsp/onl/kjb/statistics/apiStatsDayMan.jsp b/WebContent/jsp/onl/kjb/statistics/apiStatsDayMan.jsp new file mode 100644 index 0000000..ee499a4 --- /dev/null +++ b/WebContent/jsp/onl/kjb/statistics/apiStatsDayMan.jsp @@ -0,0 +1,695 @@ +<%@ page language="java" contentType="text/html; charset=utf-8"%> +<%@ page import="java.io.*"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ include file="/jsp/common/include/localemessage.jsp" %> +<% + response.setHeader("Pragma", "No-cache"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Expires", "0"); + request.setCharacterEncoding("UTF-8"); +%> + + + 일별 통계 조회 + + + + + + + + +
+
+ +
+
+
+ + +
+
API 일별 통계
+ + + + + + + + + + + + + + + + + + + + + + + + + +
조회기간 + + ~ + + (최대 31일) +
API명 + + 인스턴스 + +
업무구분 + + 클라이언트ID + +
Inbound Adapter + + Outbound Adapter + +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + diff --git a/WebContent/jsp/onl/kjb/statistics/apiStatsHourMan.jsp b/WebContent/jsp/onl/kjb/statistics/apiStatsHourMan.jsp index 0169457..0fabaf4 100644 --- a/WebContent/jsp/onl/kjb/statistics/apiStatsHourMan.jsp +++ b/WebContent/jsp/onl/kjb/statistics/apiStatsHourMan.jsp @@ -10,7 +10,7 @@ %> - API 시간별 통계 + 시간별 통계 조회 @@ -33,7 +41,7 @@ var url_view = ''; var url_minute_view = ''; - var callChart, respChart; + var totalDonutChart, seq900DonutChart, callChart, respChart; function numberFormatter(cellvalue, options, rowObject) { if (cellvalue == null || cellvalue == '') return '0'; @@ -46,9 +54,131 @@ } function initCharts() { + totalDonutChart = echarts.init(document.getElementById('totalDonutChart')); + seq900DonutChart = echarts.init(document.getElementById('seq900DonutChart')); callChart = echarts.init(document.getElementById('callChart')); respChart = echarts.init(document.getElementById('respChart')); + // 총건수 도넛 차트 + var totalDonutOption = { + title: { + text: '총 건수 분포', + left: 'center', + top: 10, + textStyle: { fontSize: 14 } + }, + tooltip: { + trigger: 'item', + formatter: '{b}: {c} ({d}%)' + }, + legend: { + orient: 'horizontal', + bottom: 10, + data: ['성공', 'Timeout', '시스템오류', '업무오류'] + }, + series: [{ + name: '총건수', + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '50%'], + avoidLabelOverlap: true, + label: { + show: true, + formatter: '{b}: {c}' + }, + emphasis: { + label: { + show: true, + fontSize: 16, + fontWeight: 'bold' + } + }, + labelLine: { + show: true + }, + data: [ + { value: 0, name: '성공', itemStyle: { color: '#91CC75' } }, + { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, + { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }, + { value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } } + ] + }], + graphic: [{ + type: 'text', + left: 'center', + top: 'center', + style: { + text: '0', + textAlign: 'center', + fill: '#333', + fontSize: 24, + fontWeight: 'bold' + } + }] + }; + + totalDonutChart.setOption(totalDonutOption); + + // Seq900 도넛 차트 + var seq900DonutOption = { + title: { + text: 'Seq900 에러 분포', + left: 'center', + top: 10, + textStyle: { fontSize: 14 } + }, + tooltip: { + trigger: 'item', + formatter: '{b}: {c} ({d}%)' + }, + legend: { + orient: 'horizontal', + bottom: 10, + data: ['Timeout', '시스템오류', '업무오류'] + }, + series: [{ + name: 'Seq900 에러', + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '50%'], + avoidLabelOverlap: true, + label: { + show: true, + formatter: '{b}: {c}' + }, + emphasis: { + label: { + show: true, + fontSize: 16, + fontWeight: 'bold' + } + }, + labelLine: { + show: true + }, + data: [ + { value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } }, + { value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } }, + { value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } } + ] + }], + graphic: [{ + type: 'text', + left: 'center', + top: 'center', + style: { + text: '0', + textAlign: 'center', + fill: '#333', + fontSize: 24, + fontWeight: 'bold' + } + }] + }; + + seq900DonutChart.setOption(seq900DonutOption); + + // 호출량 차트 var callOption = { title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } }, tooltip: { @@ -60,13 +190,16 @@ xAxis: { type: 'category', boundaryGap: false, data: [] }, yAxis: { type: 'value', minInterval: 1 }, series: [ - { name: '성공', type: 'line', stack: 'Total', areaStyle: { opacity: 0.5 }, itemStyle: { color: '#5470C6' }, data: [] }, - { name: 'Timeout', type: 'line', stack: 'Total', areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FAC858' }, data: [] }, - { name: '시스템오류', type: 'line', stack: 'Total', areaStyle: { opacity: 0.5 }, itemStyle: { color: '#EE6666' }, data: [] }, - { name: '업무오류', type: 'line', stack: 'Total', areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FC8452' }, data: [] } + { name: '성공', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#5470C6' }, data: [] }, + { name: 'Timeout', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FAC858' }, data: [] }, + { name: '시스템오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#EE6666' }, data: [] }, + { name: '업무오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FC8452' }, data: [] } ] }; + callChart.setOption(callOption); + + // 응답시간 차트 var respOption = { title: { text: '응답시간 추이 (ms)', left: 'center', textStyle: { fontSize: 14 } }, tooltip: { @@ -78,13 +211,12 @@ xAxis: { type: 'category', boundaryGap: false, data: [] }, yAxis: { type: 'value' }, series: [ - { name: 'P95', type: 'line', itemStyle: { color: '#EE6666' }, lineStyle: { type: 'dashed' }, data: [] }, - { name: 'P50', type: 'line', itemStyle: { color: '#5470C6' }, data: [] }, - { name: '평균', type: 'line', itemStyle: { color: '#91CC75' }, data: [] } + { name: 'P95', type: 'line', smooth: true, itemStyle: { color: '#EE6666' }, lineStyle: { type: 'dashed' }, data: [] }, + { name: 'P50', type: 'line', smooth: true, itemStyle: { color: '#5470C6' }, data: [] }, + { name: '평균', type: 'line', smooth: true, itemStyle: { color: '#91CC75' }, data: [] } ] }; - callChart.setOption(callOption); respChart.setOption(respOption); // 드릴다운 이벤트 (시간 클릭 시 분단위 화면으로 이동) @@ -101,18 +233,34 @@ // rawData에서 실제 statTime 가져오기 (yyyyMMddHH00 형식) var statTime = chart.rawData[dataIndex].statTime; - var date = statTime.substring(0, 4) + '-' + statTime.substring(4, 6) + '-' + statTime.substring(6, 8); - var hour = statTime.substring(8, 10); - var startTime = hour + ':00'; - var endTime = hour + ':59'; + var year = statTime.substring(0, 4); + var month = statTime.substring(4, 6); + var day = statTime.substring(6, 8); + var hour = parseInt(statTime.substring(8, 10)); - var params = '?searchStartDate=' + date + '&searchStartTime=' + startTime - + '&searchEndDate=' + date + '&searchEndTime=' + endTime; + var startDate = year + '-' + month + '-' + day; + var startTime = (hour < 10 ? '0' + hour : hour) + ':00'; + + // 1시간 후 계산 (23시인 경우 다음날 00시로 자동 계산) + var d = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), hour, 0, 0); + d.setHours(d.getHours() + 1); + + var endDate = d.getFullYear() + '-' + + (d.getMonth() + 1 < 10 ? '0' : '') + (d.getMonth() + 1) + '-' + + (d.getDate() < 10 ? '0' : '') + d.getDate(); + var endTime = (d.getHours() < 10 ? '0' : '') + d.getHours() + ':00'; + + var params = '?searchStartDate=' + startDate + '&searchStartTime=' + startTime + + '&searchEndDate=' + endDate + '&searchEndTime=' + endTime; + params += '&menuId='+'${param.menuId}'; + params += '&cmd='+'LIST'; + params += '&serviceType='+'APIGW'; location.href = url_minute_view + params; } function updateCharts(data) { + // 시계열 차트용 변수 var times = []; var successData = []; var timeoutData = []; @@ -122,10 +270,19 @@ var p50RespData = []; var p95RespData = []; + // 도넛 차트용 총계 변수 + var totalSuccess = 0; + var totalTimeout = 0; + var totalSystemErr = 0; + var totalBizErr = 0; + var seq900Timeout = 0; + var seq900SystemErr = 0; + var seq900BizErr = 0; + data.forEach(function(item) { - // statTime: yyyyMMddHH00 -> HH시 형식으로 표시 - var hour = item.statTime.substring(8, 10); times.push(item.statTime); + + // 시계열 차트 데이터 successData.push(item.successCnt); timeoutData.push(item.timeoutCnt); systemErrData.push(item.systemErrCnt); @@ -133,8 +290,53 @@ avgRespData.push(item.avgRespTime); p50RespData.push(item.p50RespTime); p95RespData.push(item.p95RespTime); + + // 도넛 차트용 합산 + totalSuccess += (item.successCnt || 0); + totalTimeout += (item.timeoutCnt || 0); + totalSystemErr += (item.systemErrCnt || 0); + totalBizErr += (item.bizErrCnt || 0); + seq900Timeout += (item.seq900TimeoutCnt || 0); + seq900SystemErr += (item.seq900SystemErrCnt || 0); + seq900BizErr += (item.seq900BizErrCnt || 0); }); + // 총건수 도넛 차트 업데이트 + var grandTotal = totalSuccess + totalTimeout + totalSystemErr + totalBizErr; + totalDonutChart.setOption({ + series: [{ + data: [ + { value: totalSuccess, name: '성공' }, + { value: totalTimeout, name: 'Timeout' }, + { value: totalSystemErr, name: '시스템오류' }, + { value: totalBizErr, name: '업무오류' } + ] + }], + graphic: [{ + style: { + text: grandTotal.toLocaleString() + } + }] + }); + + // Seq900 도넛 차트 업데이트 + var seq900Total = seq900Timeout + seq900SystemErr + seq900BizErr; + seq900DonutChart.setOption({ + series: [{ + data: [ + { value: seq900Timeout, name: 'Timeout' }, + { value: seq900SystemErr, name: '시스템오류' }, + { value: seq900BizErr, name: '업무오류' } + ] + }], + graphic: [{ + style: { + text: seq900Total.toLocaleString() + } + }] + }); + + // 호출량 차트 업데이트 callChart.setOption({ xAxis: { data: times.map(function(t) { return t.substring(8, 10) + '시'; }) }, series: [ @@ -145,6 +347,7 @@ ] }); + // 응답시간 차트 업데이트 respChart.setOption({ xAxis: { data: times.map(function(t) { return t.substring(8, 10) + '시'; }) }, series: [ @@ -204,6 +407,124 @@ fetchChartData(); } + function exportToExcel() { + console.log('[Excel Export] Starting...'); + + var postData = { + cmd: 'EXCEL_EXPORT', + searchApiName: $("input[name=searchApiName]").val(), + searchGwInstanceId: $("input[name=searchGwInstanceId]").val(), + searchBizDivCode: $("input[name=searchBizDivCode]").val(), + searchClientId: $("input[name=searchClientId]").val(), + searchInboundAdapter: $("input[name=searchInboundAdapter]").val(), + searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val(), + serviceType: '${param.serviceType}' + }; + + var searchDate = $("input[name=searchDate]").val().replace(/-/g, ""); + if (searchDate) { + postData.searchStartDateTime = searchDate + "00"; + postData.searchEndDateTime = searchDate + "23"; + } + + console.log('[Excel Export] Request data:', postData); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.responseType = 'blob'; + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); + + xhr.onload = function() { + console.log('[Excel Export] Response received - Status:', xhr.status); + + if (xhr.status === 200) { + var blob = xhr.response; + var contentType = xhr.getResponseHeader('Content-Type'); + console.log('[Excel Export] Blob size:', blob.size, 'Content-Type:', contentType); + + // JSON 에러 응답인지 확인 + if (contentType && contentType.indexOf('application/json') !== -1) { + var reader = new FileReader(); + reader.onload = function() { + try { + var errorObj = JSON.parse(reader.result); + alert(errorObj.message || 'Excel 파일 생성에 실패했습니다.'); + } catch (e) { + alert('Excel 파일 생성에 실패했습니다.'); + } + }; + reader.readAsText(blob); + return; + } + + // Blob 크기가 0이면 에러 + if (blob.size === 0) { + alert('Excel 파일 생성에 실패했습니다. (빈 응답)'); + return; + } + + // 파일명 추출 + var filename = 'api_stats.xlsx'; + var disposition = xhr.getResponseHeader('Content-Disposition'); + console.log('[Excel Export] Content-Disposition:', disposition); + + if (disposition && disposition.indexOf('filename=') !== -1) { + var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; + var matches = filenameRegex.exec(disposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + filename = decodeURIComponent(filename); + } + } + + console.log('[Excel Export] Downloading as:', filename); + + // 파일 다운로드 + var link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(link.href); + + console.log('[Excel Export] Download triggered'); + + } else if (xhr.status === 204) { + alert('조회된 데이터가 없습니다.'); + } else { + // 에러 응답 처리 + var reader = new FileReader(); + reader.onload = function() { + var message = 'Excel 다운로드 중 오류가 발생했습니다.'; + try { + var errorObj = JSON.parse(reader.result); + if (errorObj.message) message = errorObj.message; + } catch (e) { + console.error('[Excel Export] Error parsing error response:', e); + } + alert(message); + }; + reader.readAsText(xhr.response); + } + }; + + xhr.onerror = function() { + console.error('[Excel Export] Network error'); + alert('네트워크 오류가 발생했습니다.'); + }; + + // Form data 생성 + var formData = []; + for (var key in postData) { + if (postData.hasOwnProperty(key) && postData[key] != null && postData[key] !== '') { + formData.push(encodeURIComponent(key) + '=' + encodeURIComponent(postData[key])); + } + } + + xhr.send(formData.join('&')); + } + function list() { var gridPostData = getSearchForJqgrid("cmd", "LIST"); $('#grid').jqGrid({ @@ -214,6 +535,7 @@ '통계시간', 'API명', '인스턴스', '업무구분', '클라이언트ID', 'Inbound Adapter', 'Outbound Adapter', '총건수', '성공', 'Timeout', '시스템오류', '업무오류', + 'Seq900 Timeout', 'Seq900 시스템오류', 'Seq900 업무오류', '평균응답(ms)', '최소응답(ms)', '최대응답(ms)', 'P50(ms)', 'P95(ms)' ], colModel: [ @@ -229,6 +551,9 @@ { name: 'timeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, { name: 'systemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, { name: 'bizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, + { name: 'seq900TimeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, + { name: 'seq900SystemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, + { name: 'seq900BizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false }, { name: 'avgRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false }, { name: 'minRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false }, { name: 'maxRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false }, @@ -273,6 +598,10 @@ search(); }); + $("#btn_excel_export").click(function() { + exportToExcel(); + }); + $("input[name^=search]").keydown(function(key) { if (key.keyCode == 13) { $("#btn_search").click(); @@ -281,6 +610,8 @@ // 윈도우 리사이즈 시 차트 리사이즈 $(window).resize(function() { + if (totalDonutChart) totalDonutChart.resize(); + if (seq900DonutChart) seq900DonutChart.resize(); if (callChart) callChart.resize(); if (respChart) respChart.resize(); }); @@ -301,6 +632,9 @@ +
API 시간별 통계
@@ -344,6 +678,12 @@
+ +
+
+
+
+
diff --git a/WebContent/jsp/onl/kjb/statistics/apiStatsMinuteMan.jsp b/WebContent/jsp/onl/kjb/statistics/apiStatsMinuteMan.jsp index 7013212..b94b52b 100644 --- a/WebContent/jsp/onl/kjb/statistics/apiStatsMinuteMan.jsp +++ b/WebContent/jsp/onl/kjb/statistics/apiStatsMinuteMan.jsp @@ -10,7 +10,7 @@ %> - API 분단위 통계 + 분별 통계 조회 + + + + + +
+
+ +
+
+
+ + +
+
API 월별 통계
+ + + + + + + + + + + + + + + + + + + + + + + + + +
조회기간 + + ~ + + (최대 24개월) +
API명 + + 인스턴스 + +
업무구분 + + 클라이언트ID + +
Inbound Adapter + + Outbound Adapter + +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + diff --git a/WebContent/jsp/onl/kjb/statistics/apiStatsYearMan.jsp b/WebContent/jsp/onl/kjb/statistics/apiStatsYearMan.jsp new file mode 100644 index 0000000..7e01a46 --- /dev/null +++ b/WebContent/jsp/onl/kjb/statistics/apiStatsYearMan.jsp @@ -0,0 +1,670 @@ +<%@ page language="java" contentType="text/html; charset=utf-8"%> +<%@ page import="java.io.*"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ include file="/jsp/common/include/localemessage.jsp" %> +<% + response.setHeader("Pragma", "No-cache"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Expires", "0"); + request.setCharacterEncoding("UTF-8"); +%> + + + 연간 통계 조회 + + + + + + + + +
+
+ +
+
+
+ + +
+
API 연간 통계
+ + + + + + + + + + + + + + + + + + + + + + + + + +
조회기간 + + ~ + +
API명 + + 인스턴스 + +
업무구분 + + 클라이언트ID + +
Inbound Adapter + + Outbound Adapter + +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + diff --git a/WebContent/jsp/onl/transaction/tracking/trackingMan.jsp b/WebContent/jsp/onl/transaction/tracking/trackingMan.jsp index c9bccd1..773f804 100644 --- a/WebContent/jsp/onl/transaction/tracking/trackingMan.jsp +++ b/WebContent/jsp/onl/transaction/tracking/trackingMan.jsp @@ -303,6 +303,12 @@ return cellvalue; } + function trimFormatter(cellvalue, options, rowObject) { + + return cellvalue ? String(cellvalue).trim() : ''; + + } + $(document).ready(function () { console.log(roleString); @@ -373,7 +379,7 @@ {name: 'TRACKASISKEY1CTNT', hidden: true}, {name: 'TRACKASISKEY2CTNT', hidden: true}, - {name: 'EAISVCSERNO_TMP', align: 'left', width: '220'}, + {name: 'EAISVCSERNO_TMP', align: 'left', width: '220', formatter: trimFormatter}, {name: 'KEYMGTMSGCTNT', align: 'left', width: '240'}, {name: 'LOGPRCSSSERNO_TMP', align: 'center', width: '40'}, {name: 'TELGMRECVTRANCD', align: 'left', width: '135', hidden: true}, diff --git a/WebContent/jsp/onl/transaction/tracking/trackingManDetail.jsp b/WebContent/jsp/onl/transaction/tracking/trackingManDetail.jsp index ca13dc6..2f5db38 100644 --- a/WebContent/jsp/onl/transaction/tracking/trackingManDetail.jsp +++ b/WebContent/jsp/onl/transaction/tracking/trackingManDetail.jsp @@ -216,7 +216,7 @@ $(tag+"[name="+name+"]").val(data[name.toUpperCase()]); //업체코드.업체명 - if(name=="companyName"){ + if(name=="eaiSvcSerno"){ if(data["COMPANYCODE"] !=null && data["COMPANYCODE"].trim() != ''){ $("input[name=companyName]").val("["+data["COMPANYCODE"]+"] "+data[name.toUpperCase()]); } @@ -233,6 +233,13 @@ } }); + // 거래고유번호 trim + var $svcSernoInput = $("input[name=eaiSvcSerno]"); + + if($svcSernoInput.val()) { + $svcSernoInput.val( $svcSernoInput.val().trim() ); + } + $("input[name=gstatSysAdptrBzwkGroupName],input[name=psvSysAdptrBzwkGroupName]").each(function(){ var name = $(this).attr("name"); if ("${rmsMenuAuth}" =="W"){ //2020.07.08 admin 만 버튼보이게 수정 diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDay.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDay.java new file mode 100644 index 0000000..28e7d4f --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDay.java @@ -0,0 +1,93 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +/** + * API Gateway 일별 처리 통계 + */ +@Getter +@Setter +@Entity +@Table(name = "API_STATS_DAY") +@IdClass(ApiStatsDayId.class) +public class ApiStatsDay implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "STAT_TIME", nullable = false) + private LocalDate statTime; + + @Id + @Column(name = "API_NAME", nullable = false, length = 100) + private String apiName; + + @Id + @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50) + private String gwInstanceId; + + @Id + @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50) + private String bizDivCode = "NONE"; + + @Id + @Column(name = "CLIENT_ID", nullable = false, length = 100) + private String clientId = "NONE"; + + @Id + @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100) + private String inboundAdapter = "NONE"; + + @Id + @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100) + private String outboundAdapter = "NONE"; + + @Column(name = "TOTAL_CNT", nullable = false) + private Long totalCnt = 0L; + + @Column(name = "SUCCESS_CNT", nullable = false) + private Long successCnt = 0L; + + @Column(name = "TIMEOUT_CNT", nullable = false) + private Long timeoutCnt = 0L; + + @Column(name = "SYSTEM_ERR_CNT", nullable = false) + private Long systemErrCnt = 0L; + + @Column(name = "BIZ_ERR_CNT", nullable = false) + private Long bizErrCnt = 0L; + + @Column(name = "SEQ900_TIMEOUT_CNT", nullable = false) + private Long seq900TimeoutCnt = 0L; + + @Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false) + private Long seq900SystemErrCnt = 0L; + + @Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false) + private Long seq900BizErrCnt = 0L; + + @Column(name = "AVG_RESP_TIME", precision = 10, scale = 3) + private BigDecimal avgRespTime = BigDecimal.ZERO; + + @Column(name = "MIN_RESP_TIME", precision = 10, scale = 3) + private BigDecimal minRespTime = BigDecimal.ZERO; + + @Column(name = "MAX_RESP_TIME", precision = 10, scale = 3) + private BigDecimal maxRespTime = BigDecimal.ZERO; + + @Column(name = "P50_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p50RespTime = BigDecimal.ZERO; + + @Column(name = "P95_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p95RespTime = BigDecimal.ZERO; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayId.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayId.java new file mode 100644 index 0000000..8b50d29 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayId.java @@ -0,0 +1,26 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * API 통계 일별 복합키 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatsDayId implements Serializable { + private static final long serialVersionUID = 1L; + + private LocalDate statTime; + private String apiName; + private String gwInstanceId; + private String bizDivCode; + private String clientId; + private String inboundAdapter; + private String outboundAdapter; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayRepository.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayRepository.java new file mode 100644 index 0000000..206215a --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayRepository.java @@ -0,0 +1,10 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import com.eactive.eai.data.jpa.BaseRepository; + +/** + * API 통계 일별 Repository + */ +interface ApiStatsDayRepository extends BaseRepository { + +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayService.java new file mode 100644 index 0000000..251b1b4 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsDayService.java @@ -0,0 +1,152 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.data.jpa.AbstractDataService; +import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI; +import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; + +@Service +@Transactional +public class ApiStatsDayService + extends AbstractDataService { + + private static final int MAX_DAYS = 31; + + public Page selectList(ApiStatsSearch search, Pageable pageable) { + QApiStatsDay q = QApiStatsDay.apiStatsDay; + BooleanBuilder builder = buildSearchConditions(q, search); + return repository.findAll(builder, pageable); + } + + public List selectListForExcel(ApiStatsSearch search) { + QApiStatsDay q = QApiStatsDay.apiStatsDay; + BooleanBuilder builder = buildSearchConditions(q, search); + + return getJPAQueryFactory() + .selectFrom(q) + .where(builder) + .orderBy(q.statTime.desc()) + .fetch(); + } + + public List selectChartData(ApiStatsSearch search) { + QApiStatsDay q = QApiStatsDay.apiStatsDay; + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartDate() != null) { + builder.and(q.statTime.goe(search.getParsedStartDate())); + builder.and(q.statTime.loe(search.getParsedEndDate())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + return getJPAQueryFactory() + .select(Projections.constructor(ApiStatsChartUI.class, + q.statTime, + q.totalCnt.sum(), + q.successCnt.sum(), + q.timeoutCnt.sum(), + q.systemErrCnt.sum(), + q.bizErrCnt.sum(), + q.seq900TimeoutCnt.sum(), + q.seq900SystemErrCnt.sum(), + q.seq900BizErrCnt.sum(), + q.avgRespTime.avg(), + q.p50RespTime.avg(), + q.p95RespTime.avg())) + .from(q) + .where(builder) + .groupBy(q.statTime) + .orderBy(q.statTime.asc()) + .fetch(); + } + + private BooleanBuilder buildSearchConditions(QApiStatsDay q, ApiStatsSearch search) { + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartDate() != null) { + builder.and(q.statTime.goe(search.getParsedStartDate())); + builder.and(q.statTime.loe(search.getParsedEndDate())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) { + builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter())); + } + + if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) { + builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter())); + } + + return builder; + } + + /** + * 조회 기간 계산 (최대 31일 제한) + */ + private void calculateDateRange(ApiStatsSearch search) { + if (StringUtils.isBlank(search.getSearchStartDateTime())) { + return; + } + + LocalDate startDate = LocalDate.parse(search.getSearchStartDateTime(), + DateTimeFormatter.ofPattern("yyyyMMdd")); + LocalDate endDate; + + if (StringUtils.isNotBlank(search.getSearchEndDateTime())) { + endDate = LocalDate.parse(search.getSearchEndDateTime(), + DateTimeFormatter.ofPattern("yyyyMMdd")); + // 31일 초과 시 시작날짜 기준 31일 후로 강제 설정 + if (java.time.Period.between(startDate, endDate).getDays() > MAX_DAYS) { + endDate = startDate.plusDays(MAX_DAYS); + } + } else { + endDate = startDate.plusDays(MAX_DAYS); + } + + search.setParsedStartDate(startDate); + search.setParsedEndDate(endDate); + } +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsHourService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsHourService.java index 714e783..74816c8 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsHourService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsHourService.java @@ -60,6 +60,47 @@ public class ApiStatsHourService return repository.findAll(builder, pageable); } + public List selectListForExcel(ApiStatsSearch search) { + QApiStatsHour q = QApiStatsHour.apiStatsHour; + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartDateTime() != null) { + builder.and(q.statTime.goe(search.getParsedStartDateTime())); + builder.and(q.statTime.loe(search.getParsedEndDateTime())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) { + builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter())); + } + + if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) { + builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter())); + } + + return getJPAQueryFactory() + .selectFrom(q) + .where(builder) + .orderBy(q.statTime.desc()) + .fetch(); + } + public List selectChartData(ApiStatsSearch search) { QApiStatsHour q = QApiStatsHour.apiStatsHour; BooleanBuilder builder = new BooleanBuilder(); @@ -94,6 +135,9 @@ public class ApiStatsHourService q.timeoutCnt.sum(), q.systemErrCnt.sum(), q.bizErrCnt.sum(), + q.seq900TimeoutCnt.sum(), + q.seq900SystemErrCnt.sum(), + q.seq900BizErrCnt.sum(), q.avgRespTime.avg(), q.p50RespTime.avg(), q.p95RespTime.avg())) diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMinuteService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMinuteService.java index 4570b34..9a35b35 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMinuteService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMinuteService.java @@ -62,6 +62,47 @@ public class ApiStatsMinuteService return repository.findAll(builder, pageable); } + public List selectListForExcel(ApiStatsSearch search) { + QApiStatsMinute q = QApiStatsMinute.apiStatsMinute; + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartDateTime() != null) { + builder.and(q.statTime.goe(search.getParsedStartDateTime())); + builder.and(q.statTime.loe(search.getParsedEndDateTime())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) { + builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter())); + } + + if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) { + builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter())); + } + + return getJPAQueryFactory() + .selectFrom(q) + .where(builder) + .orderBy(q.statTime.desc()) + .fetch(); + } + public List selectChartData(ApiStatsSearch search) { QApiStatsMinute q = QApiStatsMinute.apiStatsMinute; BooleanBuilder builder = new BooleanBuilder(); @@ -96,6 +137,9 @@ public class ApiStatsMinuteService q.timeoutCnt.sum(), q.systemErrCnt.sum(), q.bizErrCnt.sum(), + q.seq900TimeoutCnt.sum(), + q.seq900SystemErrCnt.sum(), + q.seq900BizErrCnt.sum(), q.avgRespTime.avg(), q.p50RespTime.avg(), q.p95RespTime.avg())) diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonth.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonth.java new file mode 100644 index 0000000..944595d --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonth.java @@ -0,0 +1,92 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; +import java.math.BigDecimal; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +/** + * API Gateway 월별 처리 통계 + */ +@Getter +@Setter +@Entity +@Table(name = "API_STATS_MONTH") +@IdClass(ApiStatsMonthId.class) +public class ApiStatsMonth implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "STAT_TIME", nullable = false, length = 6) + private String statTime; // YYYYMM format + + @Id + @Column(name = "API_NAME", nullable = false, length = 100) + private String apiName; + + @Id + @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50) + private String gwInstanceId; + + @Id + @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50) + private String bizDivCode = "NONE"; + + @Id + @Column(name = "CLIENT_ID", nullable = false, length = 100) + private String clientId = "NONE"; + + @Id + @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100) + private String inboundAdapter = "NONE"; + + @Id + @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100) + private String outboundAdapter = "NONE"; + + @Column(name = "TOTAL_CNT", nullable = false) + private Long totalCnt = 0L; + + @Column(name = "SUCCESS_CNT", nullable = false) + private Long successCnt = 0L; + + @Column(name = "TIMEOUT_CNT", nullable = false) + private Long timeoutCnt = 0L; + + @Column(name = "SYSTEM_ERR_CNT", nullable = false) + private Long systemErrCnt = 0L; + + @Column(name = "BIZ_ERR_CNT", nullable = false) + private Long bizErrCnt = 0L; + + @Column(name = "SEQ900_TIMEOUT_CNT", nullable = false) + private Long seq900TimeoutCnt = 0L; + + @Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false) + private Long seq900SystemErrCnt = 0L; + + @Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false) + private Long seq900BizErrCnt = 0L; + + @Column(name = "AVG_RESP_TIME", precision = 10, scale = 3) + private BigDecimal avgRespTime = BigDecimal.ZERO; + + @Column(name = "MIN_RESP_TIME", precision = 10, scale = 3) + private BigDecimal minRespTime = BigDecimal.ZERO; + + @Column(name = "MAX_RESP_TIME", precision = 10, scale = 3) + private BigDecimal maxRespTime = BigDecimal.ZERO; + + @Column(name = "P50_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p50RespTime = BigDecimal.ZERO; + + @Column(name = "P95_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p95RespTime = BigDecimal.ZERO; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthId.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthId.java new file mode 100644 index 0000000..91366ec --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthId.java @@ -0,0 +1,25 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * API 통계 월별 복합키 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatsMonthId implements Serializable { + private static final long serialVersionUID = 1L; + + private String statTime; // YYYYMM format + private String apiName; + private String gwInstanceId; + private String bizDivCode; + private String clientId; + private String inboundAdapter; + private String outboundAdapter; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthRepository.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthRepository.java new file mode 100644 index 0000000..6942bde --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthRepository.java @@ -0,0 +1,10 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import com.eactive.eai.data.jpa.BaseRepository; + +/** + * API 통계 월별 Repository + */ +interface ApiStatsMonthRepository extends BaseRepository { + +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthService.java new file mode 100644 index 0000000..928ce4a --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsMonthService.java @@ -0,0 +1,153 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.data.jpa.AbstractDataService; +import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI; +import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; + +@Service +@Transactional +public class ApiStatsMonthService + extends AbstractDataService { + + private static final int MAX_MONTHS = 24; + + public Page selectList(ApiStatsSearch search, Pageable pageable) { + QApiStatsMonth q = QApiStatsMonth.apiStatsMonth; + BooleanBuilder builder = buildSearchConditions(q, search); + return repository.findAll(builder, pageable); + } + + public List selectListForExcel(ApiStatsSearch search) { + QApiStatsMonth q = QApiStatsMonth.apiStatsMonth; + BooleanBuilder builder = buildSearchConditions(q, search); + + return getJPAQueryFactory() + .selectFrom(q) + .where(builder) + .orderBy(q.statTime.desc()) + .fetch(); + } + + public List selectChartData(ApiStatsSearch search) { + QApiStatsMonth q = QApiStatsMonth.apiStatsMonth; + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartMonth() != null) { + builder.and(q.statTime.goe(search.getParsedStartMonth())); + builder.and(q.statTime.loe(search.getParsedEndMonth())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + return getJPAQueryFactory() + .select(Projections.constructor(ApiStatsChartUI.class, + q.statTime, + q.totalCnt.sum(), + q.successCnt.sum(), + q.timeoutCnt.sum(), + q.systemErrCnt.sum(), + q.bizErrCnt.sum(), + q.seq900TimeoutCnt.sum(), + q.seq900SystemErrCnt.sum(), + q.seq900BizErrCnt.sum(), + q.avgRespTime.avg(), + q.p50RespTime.avg(), + q.p95RespTime.avg())) + .from(q) + .where(builder) + .groupBy(q.statTime) + .orderBy(q.statTime.asc()) + .fetch(); + } + + private BooleanBuilder buildSearchConditions(QApiStatsMonth q, ApiStatsSearch search) { + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartMonth() != null) { + builder.and(q.statTime.goe(search.getParsedStartMonth())); + builder.and(q.statTime.loe(search.getParsedEndMonth())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) { + builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter())); + } + + if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) { + builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter())); + } + + return builder; + } + + /** + * 조회 기간 계산 (최대 24개월 제한) + */ + private void calculateDateRange(ApiStatsSearch search) { + if (StringUtils.isBlank(search.getSearchStartDateTime())) { + return; + } + + YearMonth startMonth = YearMonth.parse(search.getSearchStartDateTime(), + DateTimeFormatter.ofPattern("yyyyMM")); + YearMonth endMonth; + + if (StringUtils.isNotBlank(search.getSearchEndDateTime())) { + endMonth = YearMonth.parse(search.getSearchEndDateTime(), + DateTimeFormatter.ofPattern("yyyyMM")); + // 12개월 초과 시 시작월 기준 12개월 후로 강제 설정 + long monthsBetween = java.time.temporal.ChronoUnit.MONTHS.between(startMonth, endMonth); + if (monthsBetween > MAX_MONTHS) { + endMonth = startMonth.plusMonths(MAX_MONTHS); + } + } else { + endMonth = startMonth.plusMonths(MAX_MONTHS); + } + + search.setParsedStartMonth(startMonth.format(DateTimeFormatter.ofPattern("yyyyMM"))); + search.setParsedEndMonth(endMonth.format(DateTimeFormatter.ofPattern("yyyyMM"))); + } +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYear.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYear.java new file mode 100644 index 0000000..bd5f6ba --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYear.java @@ -0,0 +1,92 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; +import java.math.BigDecimal; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.Table; + +import lombok.Getter; +import lombok.Setter; + +/** + * API Gateway 연간 처리 통계 + */ +@Getter +@Setter +@Entity +@Table(name = "API_STATS_YEAR") +@IdClass(ApiStatsYearId.class) +public class ApiStatsYear implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "STAT_TIME", nullable = false, length = 4) + private String statTime; // YYYY format + + @Id + @Column(name = "API_NAME", nullable = false, length = 100) + private String apiName; + + @Id + @Column(name = "GW_INSTANCE_ID", nullable = false, length = 50) + private String gwInstanceId; + + @Id + @Column(name = "BIZ_DIV_CODE", nullable = false, length = 50) + private String bizDivCode = "NONE"; + + @Id + @Column(name = "CLIENT_ID", nullable = false, length = 100) + private String clientId = "NONE"; + + @Id + @Column(name = "INBOUND_ADAPTER", nullable = false, length = 100) + private String inboundAdapter = "NONE"; + + @Id + @Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100) + private String outboundAdapter = "NONE"; + + @Column(name = "TOTAL_CNT", nullable = false) + private Long totalCnt = 0L; + + @Column(name = "SUCCESS_CNT", nullable = false) + private Long successCnt = 0L; + + @Column(name = "TIMEOUT_CNT", nullable = false) + private Long timeoutCnt = 0L; + + @Column(name = "SYSTEM_ERR_CNT", nullable = false) + private Long systemErrCnt = 0L; + + @Column(name = "BIZ_ERR_CNT", nullable = false) + private Long bizErrCnt = 0L; + + @Column(name = "SEQ900_TIMEOUT_CNT", nullable = false) + private Long seq900TimeoutCnt = 0L; + + @Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false) + private Long seq900SystemErrCnt = 0L; + + @Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false) + private Long seq900BizErrCnt = 0L; + + @Column(name = "AVG_RESP_TIME", precision = 10, scale = 3) + private BigDecimal avgRespTime = BigDecimal.ZERO; + + @Column(name = "MIN_RESP_TIME", precision = 10, scale = 3) + private BigDecimal minRespTime = BigDecimal.ZERO; + + @Column(name = "MAX_RESP_TIME", precision = 10, scale = 3) + private BigDecimal maxRespTime = BigDecimal.ZERO; + + @Column(name = "P50_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p50RespTime = BigDecimal.ZERO; + + @Column(name = "P95_RESP_TIME", precision = 10, scale = 3) + private BigDecimal p95RespTime = BigDecimal.ZERO; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearId.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearId.java new file mode 100644 index 0000000..a37a29f --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearId.java @@ -0,0 +1,25 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * API 통계 연간 복합키 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiStatsYearId implements Serializable { + private static final long serialVersionUID = 1L; + + private String statTime; // YYYY format + private String apiName; + private String gwInstanceId; + private String bizDivCode; + private String clientId; + private String inboundAdapter; + private String outboundAdapter; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearRepository.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearRepository.java new file mode 100644 index 0000000..5961d89 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearRepository.java @@ -0,0 +1,10 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import com.eactive.eai.data.jpa.BaseRepository; + +/** + * API 통계 연간 Repository + */ +interface ApiStatsYearRepository extends BaseRepository { + +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearService.java new file mode 100644 index 0000000..64145eb --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/kjb/statistics/ApiStatsYearService.java @@ -0,0 +1,143 @@ +package com.eactive.eai.rms.data.entity.onl.kjb.statistics; + +import java.time.Year; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.data.jpa.AbstractDataService; +import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI; +import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; + +@Service +@Transactional +public class ApiStatsYearService + extends AbstractDataService { + + public Page selectList(ApiStatsSearch search, Pageable pageable) { + QApiStatsYear q = QApiStatsYear.apiStatsYear; + BooleanBuilder builder = buildSearchConditions(q, search); + return repository.findAll(builder, pageable); + } + + public List selectListForExcel(ApiStatsSearch search) { + QApiStatsYear q = QApiStatsYear.apiStatsYear; + BooleanBuilder builder = buildSearchConditions(q, search); + + return getJPAQueryFactory() + .selectFrom(q) + .where(builder) + .orderBy(q.statTime.desc()) + .fetch(); + } + + public List selectChartData(ApiStatsSearch search) { + QApiStatsYear q = QApiStatsYear.apiStatsYear; + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartYear() != null) { + builder.and(q.statTime.goe(search.getParsedStartYear())); + builder.and(q.statTime.loe(search.getParsedEndYear())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + return getJPAQueryFactory() + .select(Projections.constructor(ApiStatsChartUI.class, + q.statTime, + q.totalCnt.sum(), + q.successCnt.sum(), + q.timeoutCnt.sum(), + q.systemErrCnt.sum(), + q.bizErrCnt.sum(), + q.seq900TimeoutCnt.sum(), + q.seq900SystemErrCnt.sum(), + q.seq900BizErrCnt.sum(), + q.avgRespTime.avg(), + q.p50RespTime.avg(), + q.p95RespTime.avg())) + .from(q) + .where(builder) + .groupBy(q.statTime) + .orderBy(q.statTime.asc()) + .fetch(); + } + + private BooleanBuilder buildSearchConditions(QApiStatsYear q, ApiStatsSearch search) { + BooleanBuilder builder = new BooleanBuilder(); + + calculateDateRange(search); + if (search.getParsedStartYear() != null) { + builder.and(q.statTime.goe(search.getParsedStartYear())); + builder.and(q.statTime.loe(search.getParsedEndYear())); + } + + if (StringUtils.isNotBlank(search.getSearchApiName())) { + builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName())); + } + + if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) { + builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId())); + } + + if (StringUtils.isNotBlank(search.getSearchBizDivCode())) { + builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode())); + } + + if (StringUtils.isNotBlank(search.getSearchClientId())) { + builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId())); + } + + if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) { + builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter())); + } + + if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) { + builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter())); + } + + return builder; + } + + /** + * 조회 기간 계산 + */ + private void calculateDateRange(ApiStatsSearch search) { + if (StringUtils.isBlank(search.getSearchStartDateTime())) { + return; + } + + Year startYear = Year.parse(search.getSearchStartDateTime()); + Year endYear; + + if (StringUtils.isNotBlank(search.getSearchEndDateTime())) { + endYear = Year.parse(search.getSearchEndDateTime()); + } else { + endYear = Year.now(); + } + + search.setParsedStartYear(String.valueOf(startYear.getValue())); + search.setParsedEndYear(String.valueOf(endYear.getValue())); + } +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java index aad5b34..53f4852 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java @@ -44,38 +44,47 @@ public class EAILogRepositoryImpl implements EAILogRepository { QEAIMessageEntity qeaiMessageEntity = QEAIMessageEntity.eAIMessageEntity; - JPAQuery query = new JPAQueryFactory(em).from(entityPathBase); - appendConditions(eaiLogSearch, qeaiLog, entityPathBase, query); + JPAQuery query = new JPAQueryFactory(em) + .from(entityPathBase) + .leftJoin(qeaiMessageEntity) + .on(qeaiLog.eaisvcname.eq(qeaiMessageEntity.eaisvcname)); + + appendConditions(eaiLogSearch, qeaiLog, entityPathBase, query, qeaiMessageEntity); long totalCount = query.select(qeaiLog.id.msgdpstyms.count()).fetchOne(); - if (totalCount == 0) { - return new PageImpl<>(new ArrayList<>(), pageable, 0); - } + if (totalCount == 0) { + return new PageImpl<>(new ArrayList<>(), pageable, 0); + } - List list = query -// .select(Projections.constructor(EAILogDto.class, qeaiLog, qeaiMessageEntity.eaisvcdesc)) - .select(Projections.constructor(EAILogDto.class, - qeaiLog.id, qeaiLog.trackasiskey1ctnt, qeaiLog.trackasiskey2ctnt, qeaiLog.keymgtmsgctnt, - qeaiLog.eaisvcname, qeaiLog.extbizcd, qeaiLog.refkey, qeaiLog.clientname, - qeaiLog.orgname, qeaiLog.msgprcssyms, qeaiLog.companycode, qeaiLog.eaierrctnt, - qeaiMessageEntity.eaisvcdesc)) - .from(entityPathBase) - .leftJoin(qeaiMessageEntity) - .on(qeaiLog.eaisvcname.eq(qeaiMessageEntity.eaisvcname)) - .orderBy(qeaiLog.id.msgdpstyms.desc(), qeaiLog.id.eaisvcserno.asc(), qeaiLog.id.logprcssserno.asc()) - .limit(1000) - .fetch(); + List list = query +// .select(Projections.constructor(EAILogDto.class, qeaiLog, qeaiMessageEntity.eaisvcdesc)) + .select(Projections.constructor(EAILogDto.class, + qeaiLog.id, qeaiLog.trackasiskey1ctnt, qeaiLog.trackasiskey2ctnt, qeaiLog.keymgtmsgctnt, + qeaiLog.eaisvcname, qeaiLog.extbizcd, qeaiLog.refkey, qeaiLog.clientname, + qeaiLog.orgname, qeaiLog.msgprcssyms, qeaiLog.companycode, qeaiLog.eaierrctnt, + qeaiMessageEntity.eaisvcdesc)) + // .from(...) --> 이미 위에서 선언했으므로 생략 + // .leftJoin(...) --> 이미 위에서 선언했으므로 생략 + .orderBy(qeaiLog.id.msgdpstyms.desc(), qeaiLog.id.eaisvcserno.asc(), qeaiLog.id.logprcssserno.asc()) + .limit(1000) + .fetch(); - return new PageImpl<>(list, pageable, totalCount); + return new PageImpl<>(list, pageable, totalCount); } public Map countDetailedTransactions(Class clazz, EAILogSearch eaiLogSearch) { QEAILog qeaiLog = QEAILog.eAILog; EntityPathBase entityPathBase = new EntityPathBase<>(clazz, qeaiLog.getMetadata().getName()); + + QEAIMessageEntity qeaiMessageEntity = QEAIMessageEntity.eAIMessageEntity; - JPAQuery query = new JPAQueryFactory(em).from(entityPathBase); - appendConditions(eaiLogSearch, qeaiLog, entityPathBase, query); + JPAQuery query = new JPAQueryFactory(em) + .from(entityPathBase) + .leftJoin(qeaiMessageEntity) + .on(qeaiLog.eaisvcname.eq(qeaiMessageEntity.eaisvcname)); + + appendConditions(eaiLogSearch, qeaiLog, entityPathBase, query, qeaiMessageEntity); List results = query.select( qeaiLog.id.logprcssserno, // 로그 처리 상태 @@ -91,7 +100,7 @@ public class EAILogRepositoryImpl implements EAILogRepository { return reult; } - private void appendConditions(EAILogSearch eaiLogSearch, QEAILog qeaiLog, EntityPathBase entityPathBase, JPAQuery query) { + private void appendConditions(EAILogSearch eaiLogSearch, QEAILog qeaiLog, EntityPathBase entityPathBase, JPAQuery query, QEAIMessageEntity qeaiMessageEntity) { if (StringUtils.equalsIgnoreCase(eaiLogSearch.getAuthChk(), "Y")) { QUserBusiness qUserBusiness = QUserBusiness.userBusiness; @@ -127,6 +136,10 @@ public class EAILogRepositoryImpl implements EAILogRepository { if (StringUtils.isNotBlank(eaiLogSearch.getSearchEaiSvcName())) { query.where(qeaiLog.eaisvcname.containsIgnoreCase(eaiLogSearch.getSearchEaiSvcName())); } + + if (StringUtils.isNotBlank(eaiLogSearch.getSearchEaiSvcDesc())) { + query.where(qeaiMessageEntity.eaisvcdesc.containsIgnoreCase(eaiLogSearch.getSearchEaiSvcDesc())); + } if (StringUtils.isNotBlank(eaiLogSearch.getSearchKeyMgtMsgCtnt())) { query.where(qeaiLog.keymgtmsgctnt.contains(eaiLogSearch.getSearchKeyMgtMsgCtnt())); diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsAggregationController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsAggregationController.java new file mode 100644 index 0000000..a8b1df0 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsAggregationController.java @@ -0,0 +1,185 @@ +package com.eactive.ext.kjb.statistics; + +import java.time.LocalDate; +import java.time.Year; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +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 org.springframework.web.bind.annotation.RequestParam; + +import com.eactive.ext.kjb.statistics.job.DailyToMonthlyAggregationJob; +import com.eactive.ext.kjb.statistics.job.HourlyToDailyAggregationJob; +import com.eactive.ext.kjb.statistics.job.MonthlyToYearlyAggregationJob; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * API 통계 집계 수동 실행 Controller + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class ApiStatsAggregationController { + + private final HourlyToDailyAggregationJob hourlyToDailyAggregationJob; + private final DailyToMonthlyAggregationJob dailyToMonthlyAggregationJob; + private final MonthlyToYearlyAggregationJob monthlyToYearlyAggregationJob; + + @GetMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.view") + public String view() { + return "/onl/kjb/statistics/apiStatsAggregationMan"; + } + + /** + * 시간별→일별 통계 집계 수동 실행 + * @param targetDate 집계 대상 날짜 (yyyyMMdd 형식, 예: 20250120) + * @return 처리 결과 + */ + @PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_DAY") + public ResponseEntity> executeHourlyToDaily( + @RequestParam(required = false) String targetDate) { + + Map result = new HashMap<>(); + + try { + // 날짜 입력 검증 + if (targetDate == null || targetDate.trim().isEmpty()) { + log.error("targetDate 미입력"); + result.put("success", false); + result.put("message", "대상 날짜를 입력하세요. (예: 20250120)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + LocalDate date; + try { + date = LocalDate.parse(targetDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (DateTimeParseException e) { + log.error("잘못된 날짜 형식: {}", targetDate); + result.put("success", false); + result.put("message", "잘못된 날짜 형식입니다. yyyyMMdd 형식으로 입력하세요. (예: 20250120)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + log.info("시간별→일별 통계 집계 수동 실행 시작. targetDate: {}", date); + int count = hourlyToDailyAggregationJob.executeManual(date); + + result.put("success", true); + result.put("message", "시간별→일별 통계 집계가 완료되었습니다."); + result.put("targetDate", date.format(DateTimeFormatter.ofPattern("yyyyMMdd"))); + result.put("processedCount", count); + + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("시간별→일별 통계 집계 실행 중 오류 발생", e); + result.put("success", false); + result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); + } + } + + /** + * 일별→월별 통계 집계 수동 실행 + * @param targetMonth 집계 대상 월 (yyyyMM 형식, 예: 202501) + * @return 처리 결과 + */ + @PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_MONTH") + public ResponseEntity> executeDailyToMonthly( + @RequestParam(required = false) String targetMonth) { + + Map result = new HashMap<>(); + + try { + // 월 입력 검증 + if (targetMonth == null || targetMonth.trim().isEmpty()) { + log.error("targetMonth 미입력"); + result.put("success", false); + result.put("message", "대상 월을 입력하세요. (예: 202501)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + YearMonth month; + try { + month = YearMonth.parse(targetMonth, DateTimeFormatter.ofPattern("yyyyMM")); + } catch (DateTimeParseException e) { + log.error("잘못된 월 형식: {}", targetMonth); + result.put("success", false); + result.put("message", "잘못된 월 형식입니다. yyyyMM 형식으로 입력하세요. (예: 202501)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + log.info("일별→월별 통계 집계 수동 실행 시작. targetMonth: {}", month); + int count = dailyToMonthlyAggregationJob.executeManual(month); + + result.put("success", true); + result.put("message", "일별→월별 통계 집계가 완료되었습니다."); + result.put("targetMonth", month.format(DateTimeFormatter.ofPattern("yyyyMM"))); + result.put("processedCount", count); + + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("일별→월별 통계 집계 실행 중 오류 발생", e); + result.put("success", false); + result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); + } + } + + /** + * 월별→연별 통계 집계 수동 실행 + * @param targetYear 집계 대상 연도 (yyyy 형식, 예: 2024) + * @return 처리 결과 + */ + @PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_YEAR") + public ResponseEntity> executeMonthlyToYearly( + @RequestParam(required = false) String targetYear) { + + Map result = new HashMap<>(); + + try { + // 연도 입력 검증 + if (targetYear == null || targetYear.trim().isEmpty()) { + log.error("targetYear 미입력"); + result.put("success", false); + result.put("message", "대상 연도를 입력하세요. (예: 2024)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + Year year; + try { + year = Year.parse(targetYear); + } catch (DateTimeParseException e) { + log.error("잘못된 연도 형식: {}", targetYear); + result.put("success", false); + result.put("message", "잘못된 연도 형식입니다. yyyy 형식으로 입력하세요. (예: 2024)"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); + } + + log.info("월별→연별 통계 집계 수동 실행 시작. targetYear: {}", year); + int count = monthlyToYearlyAggregationJob.executeManual(year); + + result.put("success", true); + result.put("message", "월별→연별 통계 집계가 완료되었습니다."); + result.put("targetYear", year.toString()); + result.put("processedCount", count); + + return ResponseEntity.ok(result); + + } catch (Exception e) { + log.error("월별→연별 통계 집계 실행 중 오류 발생", e); + result.put("success", false); + result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); + } + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsDayController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsDayController.java new file mode 100644 index 0000000..3221419 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsDayController.java @@ -0,0 +1,115 @@ +package com.eactive.ext.kjb.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 ApiStatsDayController { + + private final ApiStatsDayService service; + private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; + + @GetMapping(value = "/onl/kjb/statistics/apiStatsDayMan.view") + public String view() { + return "/onl/kjb/statistics/apiStatsDayMan"; + } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsDayMan.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/kjb/statistics/apiStatsDayMan.json", params = "cmd=CHART") + public ResponseEntity> selectChartData(ApiStatsSearch search) { + List chartData = service.selectChartData(search); + return ResponseEntity.ok(chartData); + } + + @PostMapping(value = "/onl/kjb/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"; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsHourController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsHourController.java index 1b11f19..1869eba 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsHourController.java +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsHourController.java @@ -1,7 +1,14 @@ package com.eactive.ext.kjb.statistics; +import java.io.IOException; +import java.time.LocalDateTime; +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; @@ -15,6 +22,7 @@ import com.eactive.eai.rms.common.vo.GridResponse; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHour; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHourService; 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; @@ -29,6 +37,7 @@ public class ApiStatsHourController { private final ApiStatsHourService service; private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; @GetMapping(value = "/onl/kjb/statistics/apiStatsHourMan.view") public String view() { @@ -53,4 +62,54 @@ public class ApiStatsHourController { List chartData = service.selectChartData(search); return ResponseEntity.ok(chartData); } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsHourMan.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() + : LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHH")); + return "API시간별통계_" + timeRange + ".xlsx"; + } } diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMinuteController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMinuteController.java index a1b9021..baf46a5 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMinuteController.java +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMinuteController.java @@ -1,7 +1,14 @@ package com.eactive.ext.kjb.statistics; +import java.io.IOException; +import java.time.LocalDateTime; +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; @@ -15,6 +22,7 @@ import com.eactive.eai.rms.common.vo.GridResponse; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMinute; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMinuteService; 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; @@ -29,6 +37,7 @@ public class ApiStatsMinuteController { private final ApiStatsMinuteService service; private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; @GetMapping(value = "/onl/kjb/statistics/apiStatsMinuteMan.view") public String view() { @@ -53,4 +62,54 @@ public class ApiStatsMinuteController { List chartData = service.selectChartData(search); return ResponseEntity.ok(chartData); } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsMinuteMan.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() + : LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + return "API분단위통계_" + timeRange + ".xlsx"; + } } diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMonthController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMonthController.java new file mode 100644 index 0000000..55e474e --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsMonthController.java @@ -0,0 +1,115 @@ +package com.eactive.ext.kjb.statistics; + +import java.io.IOException; +import java.time.YearMonth; +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.ApiStatsMonth; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService; +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 ApiStatsMonthController { + + private final ApiStatsMonthService service; + private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; + + @GetMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.view") + public String view() { + return "/onl/kjb/statistics/apiStatsMonthMan"; + } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.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/kjb/statistics/apiStatsMonthMan.json", params = "cmd=CHART") + public ResponseEntity> selectChartData(ApiStatsSearch search) { + List chartData = service.selectChartData(search); + return ResponseEntity.ok(chartData); + } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.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() + : YearMonth.now().format(DateTimeFormatter.ofPattern("yyyyMM")); + return "API월별통계_" + timeRange + ".xlsx"; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsYearController.java b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsYearController.java new file mode 100644 index 0000000..c2e587a --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/ApiStatsYearController.java @@ -0,0 +1,114 @@ +package com.eactive.ext.kjb.statistics; + +import java.io.IOException; +import java.time.Year; +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.ApiStatsYear; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYearService; +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 ApiStatsYearController { + + private final ApiStatsYearService service; + private final ApiStatsUIMapper mapper; + private final ApiStatsExcelExportService excelExportService; + + @GetMapping(value = "/onl/kjb/statistics/apiStatsYearMan.view") + public String view() { + return "/onl/kjb/statistics/apiStatsYearMan"; + } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsYearMan.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/kjb/statistics/apiStatsYearMan.json", params = "cmd=CHART") + public ResponseEntity> selectChartData(ApiStatsSearch search) { + List chartData = service.selectChartData(search); + return ResponseEntity.ok(chartData); + } + + @PostMapping(value = "/onl/kjb/statistics/apiStatsYearMan.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() + : String.valueOf(Year.now().getValue()); + return "API연간통계_" + timeRange + ".xlsx"; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/README.md b/src/main/java/com/eactive/ext/kjb/statistics/README.md deleted file mode 100644 index 3a93ba4..0000000 --- a/src/main/java/com/eactive/ext/kjb/statistics/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# API Gateway 통계 화면 - -## 개요 - -API Gateway의 분단위/시간별 처리 통계를 조회하는 관리 화면입니다. - -## 파일 구조 - -``` -com.eactive.ext.kjb.statistics/ -├── ApiStatsMinuteController.java # 분단위 통계 Controller -├── ApiStatsMinuteService.java # 분단위 통계 Service -├── ApiStatsHourController.java # 시간별 통계 Controller -├── ApiStatsHourService.java # 시간별 통계 Service -├── entity/ -│ ├── ApiStatsMinute.java # 분단위 통계 Entity -│ ├── ApiStatsMinuteId.java # 분단위 통계 복합키 -│ ├── ApiStatsHour.java # 시간별 통계 Entity -│ └── ApiStatsHourId.java # 시간별 통계 복합키 -├── repository/ -│ ├── ApiStatsMinuteRepository.java -│ └── ApiStatsHourRepository.java -├── ui/ -│ ├── ApiStatsUI.java # 통계 UI DTO -│ └── ApiStatsSearch.java # 검색조건 DTO -├── mapping/ -│ └── ApiStatsUIMapper.java # Entity ↔ UI 매핑 -└── README.md - -JSP 화면: -WebContent/jsp/kjb/statistics/ -├── apiStatsMinuteMan.jsp # 분단위 통계 화면 -└── apiStatsHourMan.jsp # 시간별 통계 화면 -``` - -## URL - -| 화면 | URL | -|------|-----| -| 분단위 통계 | `/kjb/statistics/apiStatsMinuteMan.view` | -| 시간별 통계 | `/kjb/statistics/apiStatsHourMan.view` | - -## 테이블 - -| 테이블명 | 설명 | -|----------|------| -| API_STATS_MINUTE | 분단위 통계 | -| API_STATS_HOUR | 시간별 통계 | - -## 복합키 구성 - -- statTime (통계 시간) -- apiName (API명) -- gwInstanceId (Gateway 인스턴스 ID) -- bizDivCode (업무구분코드) -- clientId (클라이언트 ID) -- inboundAdapter (Inbound Adapter) -- outboundAdapter (Outbound Adapter) - -## 메트릭 - -### 건수 메트릭 -- totalCnt: 총 처리 건수 -- successCnt: 성공 건수 -- timeoutCnt: Timeout 오류 건수 -- systemErrCnt: 시스템 오류 건수 -- bizErrCnt: 업무 오류 건수 -- seq900TimeoutCnt: 로그시퀀스900 Timeout 건수 -- seq900SystemErrCnt: 로그시퀀스900 시스템 오류 건수 -- seq900BizErrCnt: 로그시퀀스900 업무 오류 건수 - -### 응답시간 메트릭 (ms) -- avgRespTime: 평균 응답시간 -- minRespTime: 최소 응답시간 -- maxRespTime: 최대 응답시간 -- p50RespTime: 50 백분위 응답시간 -- p95RespTime: 95 백분위 응답시간 - -## Multi-tenancy - -API Gateway 통계는 `APIGW` 스키마에 저장됩니다. -Controller에서 `DataSourceContextHolder`를 사용하여 스키마를 전환합니다. - -```java -DataSourceContextHolder.setDataSourceType( - DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW)); -``` - -## 검색 조건 - -- 조회기간 (시작~종료) -- API명 (like 검색) -- Gateway 인스턴스 ID -- 업무구분코드 (like 검색) -- 클라이언트 ID (like 검색) - -## 기술 스택 - -- JPA + QueryDSL -- MapStruct (Entity ↔ UI 매핑) -- Spring MVC (ResponseEntity) -- jqGrid (목록 화면) diff --git a/src/main/java/com/eactive/ext/kjb/statistics/STATS_README.md b/src/main/java/com/eactive/ext/kjb/statistics/STATS_README.md new file mode 100644 index 0000000..69d400f --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/STATS_README.md @@ -0,0 +1,167 @@ +# API Gateway 통계 화면 + +## 개요 + +API Gateway의 분단위/시간별/일별/월별/연도별 처리 통계를 조회하는 관리 화면입니다. + +## 파일 구조 + +``` +com.eactive.ext.kjb.statistics/ +├── ApiStatsMinuteController.java # 분단위 통계 Controller +├── ApiStatsHourController.java # 시간별 통계 Controller +├── ApiStatsDayController.java # 일별 통계 Controller +├── ApiStatsMonthController.java # 월별 통계 Controller +├── ApiStatsYearController.java # 연도별 통계 Controller +├── ApiStatsAggregationController.java # 통계 집계 수동 실행 Controller +├── service/ +│ └── ApiStatsExcelExportService.java # Excel 내보내기 Service +├── entity/ +│ ├── ApiStatsMinute.java # 분단위 통계 Entity +│ ├── ApiStatsMinuteId.java # 분단위 통계 복합키 +│ ├── ApiStatsHour.java # 시간별 통계 Entity +│ ├── ApiStatsHourId.java # 시간별 통계 복합키 +│ ├── ApiStatsDay.java # 일별 통계 Entity +│ ├── ApiStatsDayId.java # 일별 통계 복합키 +│ ├── ApiStatsMonth.java # 월별 통계 Entity +│ ├── ApiStatsMonthId.java # 월별 통계 복합키 +│ ├── ApiStatsYear.java # 연도별 통계 Entity +│ └── ApiStatsYearId.java # 연도별 통계 복합키 +├── repository/ +│ ├── ApiStatsMinuteRepository.java +│ ├── ApiStatsHourRepository.java +│ ├── ApiStatsDayRepository.java +│ ├── ApiStatsMonthRepository.java +│ └── ApiStatsYearRepository.java +├── ui/ +│ ├── ApiStatsUI.java # 통계 UI DTO +│ ├── ApiStatsSearch.java # 검색조건 DTO +│ └── ApiStatsChartUI.java # 차트 UI DTO +├── mapping/ +│ └── ApiStatsUIMapper.java # Entity ↔ UI 매핑 +└── README.md + +JSP 화면: +WebContent/jsp/kjb/statistics/ +├── apiStatsMinuteMan.jsp # 분단위 통계 화면 +├── apiStatsHourMan.jsp # 시간별 통계 화면 +├── apiStatsDayMan.jsp # 일별 통계 화면 +├── apiStatsMonthMan.jsp # 월별 통계 화면 +├── apiStatsYearMan.jsp # 연도별 통계 화면 +└── apiStatsAggregationMan.jsp # 통계 집계 수동 실행 화면 +``` + +## URL + +| 화면 | URL | +|------|-----| +| 분단위 통계 | `/kjb/statistics/apiStatsMinuteMan.view` | +| 시간별 통계 | `/kjb/statistics/apiStatsHourMan.view` | +| 일별 통계 | `/kjb/statistics/apiStatsDayMan.view` | +| 월별 통계 | `/kjb/statistics/apiStatsMonthMan.view` | +| 연도별 통계 | `/kjb/statistics/apiStatsYearMan.view` | +| 통계 집계 실행 | `/kjb/statistics/apiStatsAggregationMan.view` | + +## 테이블 + +| 테이블명 | 설명 | +|----------|------| +| API_STATS_MINUTE | 분단위 통계 | +| API_STATS_HOUR | 시간별 통계 | +| API_STATS_DAY | 일별 통계 | +| API_STATS_MONTH | 월별 통계 (STAT_TIME: YYYYMM 형식) | +| API_STATS_YEAR | 연도별 통계 (STAT_TIME: YYYY 형식) | + +> 상세 테이블 스키마는 [statistics_table.md](statistics_table.md) 참조 + +## 복합키 구성 + +- statTime (통계 시간) +- apiName (API명) +- gwInstanceId (Gateway 인스턴스 ID) +- bizDivCode (업무구분코드) +- clientId (클라이언트 ID) +- inboundAdapter (Inbound Adapter) +- outboundAdapter (Outbound Adapter) + +## 메트릭 + +### 건수 메트릭 +- totalCnt: 총 처리 건수 +- successCnt: 성공 건수 +- timeoutCnt: Timeout 오류 건수 +- systemErrCnt: 시스템 오류 건수 +- bizErrCnt: 업무 오류 건수 +- seq900TimeoutCnt: 로그시퀀스900 Timeout 건수 +- seq900SystemErrCnt: 로그시퀀스900 시스템 오류 건수 +- seq900BizErrCnt: 로그시퀀스900 업무 오류 건수 + +### 응답시간 메트릭 (ms) +- avgRespTime: 평균 응답시간 +- minRespTime: 최소 응답시간 +- maxRespTime: 최대 응답시간 +- p50RespTime: 50 백분위 응답시간 +- p95RespTime: 95 백분위 응답시간 + +## Multi-tenancy + +API Gateway 통계는 `APIGW` 스키마에 저장됩니다. +Controller에서 `DataSourceContextHolder`를 사용하여 스키마를 전환합니다. + +```java +DataSourceContextHolder.setDataSourceType( + DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW)); +``` + +## 검색 조건 + +- 조회기간 (시작~종료) +- API명 (like 검색) +- Gateway 인스턴스 ID +- 업무구분코드 (like 검색) +- 클라이언트 ID (like 검색) + +## 주요 기능 + +- 통계 데이터 조회 (분/시간/일/월/년 단위) +- Excel 내보내기 +- 차트 데이터 제공 (ApiStatsChartUI) +- 통계 집계 수동 실행 (시간→일, 일→월, 월→년) + +## 통계 집계 수동 실행 + +통계 데이터는 스케줄러에 의해 자동으로 집계되지만, 누락된 기간이나 재집계가 필요한 경우 수동 실행할 수 있습니다. + +### 집계 실행 화면 (apiStatsAggregationMan.jsp) + +**접근 URL**: `/kjb/statistics/apiStatsAggregationMan.view` + +한 화면에서 3가지 집계 작업을 모두 수동으로 실행할 수 있습니다: + +1. **시간별 → 일별 집계** + - API 엔드포인트: `POST /onl/kjb/statistics/apiStatsDayMan.json?cmd=AGGREGATION` + - 파라미터: `targetDate` (yyyyMMdd 형식, 예: 20250120) + - 기본값: 전일 날짜 + +2. **일별 → 월별 집계** + - API 엔드포인트: `POST /onl/kjb/statistics/apiStatsMonthMan.json?cmd=AGGREGATION` + - 파라미터: `targetMonth` (yyyyMM 형식, 예: 202501) + - 기본값: 전월 + +3. **월별 → 연별 집계** + - API 엔드포인트: `POST /onl/kjb/statistics/apiStatsYearMan.json?cmd=AGGREGATION` + - 파라미터: `targetYear` (yyyy 형식, 예: 2024) + - 기본값: 전년 + +**주의사항**: +- 집계 대상 기간의 원본 데이터가 존재해야 정상 집계됩니다. +- 입력값을 비워두면 기본값(전일/전월/전년)으로 실행됩니다. +- 실행 결과는 처리 건수와 함께 화면에 표시됩니다. + +## 기술 스택 + +- JPA + QueryDSL +- MapStruct (Entity ↔ UI 매핑) +- Spring MVC (ResponseEntity) +- jqGrid (목록 화면) +- Apache POI (Excel 내보내기) diff --git a/src/main/java/com/eactive/ext/kjb/statistics/job/DailyToMonthlyAggregationJob.java b/src/main/java/com/eactive/ext/kjb/statistics/job/DailyToMonthlyAggregationJob.java new file mode 100644 index 0000000..4f5e8f1 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/job/DailyToMonthlyAggregationJob.java @@ -0,0 +1,164 @@ +package com.eactive.ext.kjb.statistics.job; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.rms.common.datasource.DataSourceContextHolder; +import com.eactive.eai.rms.common.datasource.DataSourceType; +import com.eactive.eai.rms.common.datasource.DataSourceTypeManager; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonth; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsDay; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsMonth; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +/** + * 일별 통계를 월별 통계로 집계하는 배치 작업 + * API_STATS_DAY → API_STATS_MONTH + * 실행 주기: 매월 1일 00:20 (전월 데이터 집계) + */ +@Component +public class DailyToMonthlyAggregationJob implements Job { + private static final Logger log = LoggerFactory.getLogger(DailyToMonthlyAggregationJob.class); + + private ApiStatsDayService apiStatsDayService; + private ApiStatsMonthService apiStatsMonthService; + private DailyToMonthlyAggregationJob self; + + @Autowired + public void setApiStatsDayService(ApiStatsDayService apiStatsDayService) { + this.apiStatsDayService = apiStatsDayService; + } + + @Autowired + public void setApiStatsMonthService(ApiStatsMonthService apiStatsMonthService) { + this.apiStatsMonthService = apiStatsMonthService; + } + + @Autowired + public void setSelf(DailyToMonthlyAggregationJob self) { + this.self = self; + } + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + DataSourceType dataType = DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW); + DataSourceContextHolder.setDataSourceType(dataType); + try { + YearMonth targetMonth = YearMonth.now().minusMonths(1); + self.executeManual(targetMonth); + } catch (Exception e) { + throw new JobExecutionException(e); + } finally { + DataSourceContextHolder.clearDataSourceType(); + } + } + + /** + * 수동 실행 메서드 (월 지정) + */ + @Transactional + public int executeManual(YearMonth targetMonth) { + long startTime = System.currentTimeMillis(); + log.info("=== 일별→월별 통계 집계 작업 시작 ==="); + + LocalDate startDate = targetMonth.atDay(1); + LocalDate endDate = targetMonth.atEndOfMonth(); + + log.info("집계 대상 기간: {} ~ {}", startDate, endDate); + + List aggregatedData = aggregateDailyToMonthly(startDate, endDate, targetMonth); + + // 1. 기존 데이터 삭제 (QueryDSL 사용) + JPAQueryFactory queryFactory = apiStatsMonthService.getJPAQueryFactory(); + QApiStatsMonth qm = QApiStatsMonth.apiStatsMonth; + String targetMonthStr = targetMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + long deletedCount = queryFactory + .delete(qm) + .where(qm.statTime.eq(targetMonthStr)) + .execute(); + log.info("기존 데이터 삭제: {} 건", deletedCount); + + // 2. 배치 INSERT + apiStatsMonthService.saveAll(aggregatedData); + int savedCount = aggregatedData.size(); + + long elapsedTime = System.currentTimeMillis() - startTime; + log.info("=== 일별→월별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime); + + return savedCount; + } + + /** + * 일별 데이터를 월별로 집계 + */ + private List aggregateDailyToMonthly(LocalDate startDate, LocalDate endDate, YearMonth targetMonth) { + QApiStatsDay qd = QApiStatsDay.apiStatsDay; + JPAQueryFactory queryFactory = apiStatsDayService.getJPAQueryFactory(); + + String targetMonthStr = targetMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); + + List aggregatedData = queryFactory + .select(Projections.bean(ApiStatsMonth.class, + qd.apiName, + qd.gwInstanceId, + qd.bizDivCode, + qd.clientId, + qd.inboundAdapter, + qd.outboundAdapter, + qd.totalCnt.sum().as("totalCnt"), + qd.successCnt.sum().as("successCnt"), + qd.timeoutCnt.sum().as("timeoutCnt"), + qd.systemErrCnt.sum().as("systemErrCnt"), + qd.bizErrCnt.sum().as("bizErrCnt"), + qd.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"), + qd.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"), + qd.seq900BizErrCnt.sum().as("seq900BizErrCnt"), + qd.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"), + qd.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"), + qd.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"), + qd.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"), + qd.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime"))) + .from(qd) + .where(qd.statTime.goe(startDate) + .and(qd.statTime.loe(endDate))) + .groupBy(qd.apiName, qd.gwInstanceId, qd.bizDivCode, qd.clientId, qd.inboundAdapter, qd.outboundAdapter) + .fetch(); + + for (ApiStatsMonth statsMonth : aggregatedData) { + statsMonth.setStatTime(targetMonthStr); + if (statsMonth.getAvgRespTime() != null) { + statsMonth.setAvgRespTime(statsMonth.getAvgRespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsMonth.getP50RespTime() != null) { + statsMonth.setP50RespTime(statsMonth.getP50RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsMonth.getP95RespTime() != null) { + statsMonth.setP95RespTime(statsMonth.getP95RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsMonth.getMinRespTime() == null) { + statsMonth.setMinRespTime(BigDecimal.ZERO); + } + if (statsMonth.getMaxRespTime() == null) { + statsMonth.setMaxRespTime(BigDecimal.ZERO); + } + } + + return aggregatedData; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/job/HourlyToDailyAggregationJob.java b/src/main/java/com/eactive/ext/kjb/statistics/job/HourlyToDailyAggregationJob.java new file mode 100644 index 0000000..2495d2d --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/job/HourlyToDailyAggregationJob.java @@ -0,0 +1,160 @@ +package com.eactive.ext.kjb.statistics.job; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.rms.common.datasource.DataSourceContextHolder; +import com.eactive.eai.rms.common.datasource.DataSourceType; +import com.eactive.eai.rms.common.datasource.DataSourceTypeManager; +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.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHourService; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsDay; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsHour; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +/** + * 시간별 통계를 일별 통계로 집계하는 배치 작업 + * API_STATS_HOUR → API_STATS_DAY + * 실행 주기: 매일 00:10 (전일 데이터 집계) + */ +@Component +public class HourlyToDailyAggregationJob implements Job { + private static final Logger log = LoggerFactory.getLogger(HourlyToDailyAggregationJob.class); + + private ApiStatsHourService apiStatsHourService; + private ApiStatsDayService apiStatsDayService; + private HourlyToDailyAggregationJob self; + + @Autowired + public void setApiStatsHourService(ApiStatsHourService apiStatsHourService) { + this.apiStatsHourService = apiStatsHourService; + } + + @Autowired + public void setApiStatsDayService(ApiStatsDayService apiStatsDayService) { + this.apiStatsDayService = apiStatsDayService; + } + + @Autowired + public void setSelf(HourlyToDailyAggregationJob self) { + this.self = self; + } + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + DataSourceType dataType = DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW); + DataSourceContextHolder.setDataSourceType(dataType); + try { + LocalDate targetDate = LocalDate.now().minusDays(1); + self.executeManual(targetDate); + } catch (Exception e) { + throw new JobExecutionException(e); + } finally { + DataSourceContextHolder.clearDataSourceType(); + } + } + + /** + * 수동 실행 메서드 (날짜 지정) + */ + @Transactional + public int executeManual(LocalDate targetDate) { + long startTime = System.currentTimeMillis(); + log.info("=== 시간별→일별 통계 집계 작업 시작 ==="); + + LocalDateTime startDateTime = targetDate.atStartOfDay(); + LocalDateTime endDateTime = targetDate.plusDays(1).atStartOfDay().minusSeconds(1); + + log.info("집계 대상 기간: {} ~ {}", startDateTime, endDateTime); + + List aggregatedData = aggregateHourlyToDaily(startDateTime, endDateTime, targetDate); + + // 1. 기존 데이터 삭제 (QueryDSL 사용) + JPAQueryFactory queryFactory = apiStatsDayService.getJPAQueryFactory(); + QApiStatsDay qd = QApiStatsDay.apiStatsDay; + long deletedCount = queryFactory + .delete(qd) + .where(qd.statTime.eq(targetDate)) + .execute(); + log.info("기존 데이터 삭제: {} 건", deletedCount); + + // 2. 배치 INSERT + apiStatsDayService.saveAll(aggregatedData); + int savedCount = aggregatedData.size(); + + long elapsedTime = System.currentTimeMillis() - startTime; + log.info("=== 시간별→일별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime); + + return savedCount; + } + + /** + * 시간별 데이터를 일별로 집계 + */ + private List aggregateHourlyToDaily(LocalDateTime startDateTime, LocalDateTime endDateTime, LocalDate targetDate) { + QApiStatsHour qh = QApiStatsHour.apiStatsHour; + JPAQueryFactory queryFactory = apiStatsHourService.getJPAQueryFactory(); + + List aggregatedData = queryFactory + .select(Projections.bean(ApiStatsDay.class, + qh.apiName, + qh.gwInstanceId, + qh.bizDivCode, + qh.clientId, + qh.inboundAdapter, + qh.outboundAdapter, + qh.totalCnt.sum().as("totalCnt"), + qh.successCnt.sum().as("successCnt"), + qh.timeoutCnt.sum().as("timeoutCnt"), + qh.systemErrCnt.sum().as("systemErrCnt"), + qh.bizErrCnt.sum().as("bizErrCnt"), + qh.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"), + qh.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"), + qh.seq900BizErrCnt.sum().as("seq900BizErrCnt"), + qh.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"), + qh.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"), + qh.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"), + qh.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"), + qh.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime"))) + .from(qh) + .where(qh.statTime.goe(startDateTime) + .and(qh.statTime.loe(endDateTime))) + .groupBy(qh.apiName, qh.gwInstanceId, qh.bizDivCode, qh.clientId, qh.inboundAdapter, qh.outboundAdapter) + .fetch(); + + for (ApiStatsDay statsDay : aggregatedData) { + statsDay.setStatTime(targetDate); + if (statsDay.getAvgRespTime() != null) { + statsDay.setAvgRespTime(statsDay.getAvgRespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsDay.getP50RespTime() != null) { + statsDay.setP50RespTime(statsDay.getP50RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsDay.getP95RespTime() != null) { + statsDay.setP95RespTime(statsDay.getP95RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsDay.getMinRespTime() == null) { + statsDay.setMinRespTime(BigDecimal.ZERO); + } + if (statsDay.getMaxRespTime() == null) { + statsDay.setMaxRespTime(BigDecimal.ZERO); + } + } + + return aggregatedData; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/job/MonthlyToYearlyAggregationJob.java b/src/main/java/com/eactive/ext/kjb/statistics/job/MonthlyToYearlyAggregationJob.java new file mode 100644 index 0000000..0e4f932 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/job/MonthlyToYearlyAggregationJob.java @@ -0,0 +1,162 @@ +package com.eactive.ext.kjb.statistics.job; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Year; +import java.util.List; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.eactive.eai.rms.common.datasource.DataSourceContextHolder; +import com.eactive.eai.rms.common.datasource.DataSourceType; +import com.eactive.eai.rms.common.datasource.DataSourceTypeManager; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYear; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYearService; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsMonth; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsYear; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +/** + * 월별 통계를 연별 통계로 집계하는 배치 작업 + * API_STATS_MONTH → API_STATS_YEAR + * 실행 주기: 매년 1월 1일 00:30 (전년 데이터 집계) + */ +@Component +public class MonthlyToYearlyAggregationJob implements Job { + private static final Logger log = LoggerFactory.getLogger(MonthlyToYearlyAggregationJob.class); + + private ApiStatsMonthService apiStatsMonthService; + private ApiStatsYearService apiStatsYearService; + private MonthlyToYearlyAggregationJob self; + + @Autowired + public void setApiStatsMonthService(ApiStatsMonthService apiStatsMonthService) { + this.apiStatsMonthService = apiStatsMonthService; + } + + @Autowired + public void setApiStatsYearService(ApiStatsYearService apiStatsYearService) { + this.apiStatsYearService = apiStatsYearService; + } + + @Autowired + public void setSelf(MonthlyToYearlyAggregationJob self) { + this.self = self; + } + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + DataSourceType dataType = DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW); + DataSourceContextHolder.setDataSourceType(dataType); + try { + Year targetYear = Year.now().minusYears(1); + self.executeManual(targetYear); + } catch (Exception e) { + throw new JobExecutionException(e); + } finally { + DataSourceContextHolder.clearDataSourceType(); + } + } + + /** + * 수동 실행 메서드 (연도 지정) + */ + @Transactional + public int executeManual(Year targetYear) { + long startTime = System.currentTimeMillis(); + log.info("=== 월별→연별 통계 집계 작업 시작 ==="); + + String startMonth = targetYear.toString() + "01"; + String endMonth = targetYear.toString() + "12"; + + log.info("집계 대상 기간: {} ~ {}", startMonth, endMonth); + + List aggregatedData = aggregateMonthlyToYearly(startMonth, endMonth, targetYear); + + // 1. 기존 데이터 삭제 (QueryDSL 사용) + JPAQueryFactory queryFactory = apiStatsYearService.getJPAQueryFactory(); + QApiStatsYear qy = QApiStatsYear.apiStatsYear; + String targetYearStr = targetYear.toString(); + long deletedCount = queryFactory + .delete(qy) + .where(qy.statTime.eq(targetYearStr)) + .execute(); + log.info("기존 데이터 삭제: {} 건", deletedCount); + + // 2. 배치 INSERT + apiStatsYearService.saveAll(aggregatedData); + int savedCount = aggregatedData.size(); + + long elapsedTime = System.currentTimeMillis() - startTime; + log.info("=== 월별→연별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime); + + return savedCount; + } + + /** + * 월별 데이터를 연별로 집계 + */ + private List aggregateMonthlyToYearly(String startMonth, String endMonth, Year targetYear) { + QApiStatsMonth qm = QApiStatsMonth.apiStatsMonth; + JPAQueryFactory queryFactory = apiStatsMonthService.getJPAQueryFactory(); + + String targetYearStr = targetYear.toString(); + + List aggregatedData = queryFactory + .select(Projections.bean(ApiStatsYear.class, + qm.apiName, + qm.gwInstanceId, + qm.bizDivCode, + qm.clientId, + qm.inboundAdapter, + qm.outboundAdapter, + qm.totalCnt.sum().as("totalCnt"), + qm.successCnt.sum().as("successCnt"), + qm.timeoutCnt.sum().as("timeoutCnt"), + qm.systemErrCnt.sum().as("systemErrCnt"), + qm.bizErrCnt.sum().as("bizErrCnt"), + qm.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"), + qm.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"), + qm.seq900BizErrCnt.sum().as("seq900BizErrCnt"), + qm.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"), + qm.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"), + qm.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"), + qm.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"), + qm.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime"))) + .from(qm) + .where(qm.statTime.goe(startMonth) + .and(qm.statTime.loe(endMonth))) + .groupBy(qm.apiName, qm.gwInstanceId, qm.bizDivCode, qm.clientId, qm.inboundAdapter, qm.outboundAdapter) + .fetch(); + + for (ApiStatsYear statsYear : aggregatedData) { + statsYear.setStatTime(targetYearStr); + if (statsYear.getAvgRespTime() != null) { + statsYear.setAvgRespTime(statsYear.getAvgRespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsYear.getP50RespTime() != null) { + statsYear.setP50RespTime(statsYear.getP50RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsYear.getP95RespTime() != null) { + statsYear.setP95RespTime(statsYear.getP95RespTime().setScale(3, RoundingMode.HALF_UP)); + } + if (statsYear.getMinRespTime() == null) { + statsYear.setMinRespTime(BigDecimal.ZERO); + } + if (statsYear.getMaxRespTime() == null) { + statsYear.setMaxRespTime(BigDecimal.ZERO); + } + } + + return aggregatedData; + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/mapping/ApiStatsUIMapper.java b/src/main/java/com/eactive/ext/kjb/statistics/mapping/ApiStatsUIMapper.java index 59b241b..c5fff5b 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/mapping/ApiStatsUIMapper.java +++ b/src/main/java/com/eactive/ext/kjb/statistics/mapping/ApiStatsUIMapper.java @@ -1,5 +1,6 @@ package com.eactive.ext.kjb.statistics.mapping; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -8,8 +9,11 @@ import org.mapstruct.Mapping; import org.mapstruct.Named; import com.eactive.eai.data.mapper.BaseMapperConfig; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHour; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMinute; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonth; +import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYear; import com.eactive.ext.kjb.statistics.ui.ApiStatsUI; @Mapper(config = BaseMapperConfig.class) @@ -17,6 +21,7 @@ public interface ApiStatsUIMapper { DateTimeFormatter MINUTE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); DateTimeFormatter HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00"); + DateTimeFormatter DAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatMinuteTime") ApiStatsUI toVo(ApiStatsMinute entity); @@ -24,6 +29,15 @@ public interface ApiStatsUIMapper { @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatHourTime") ApiStatsUI toVo(ApiStatsHour entity); + @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatDayTime") + ApiStatsUI toVo(ApiStatsDay entity); + + @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatMonthTime") + ApiStatsUI toVo(ApiStatsMonth entity); + + @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatYearTime") + ApiStatsUI toVo(ApiStatsYear entity); + @Named("formatMinuteTime") default String formatMinuteTime(LocalDateTime dateTime) { if (dateTime == null) { @@ -39,4 +53,25 @@ public interface ApiStatsUIMapper { } return dateTime.format(HOUR_FORMATTER); } + + @Named("formatDayTime") + default String formatDayTime(LocalDate date) { + if (date == null) { + return null; + } + return date.format(DAY_FORMATTER); + } + + @Named("formatMonthTime") + default String formatMonthTime(String yyyymm) { + if (yyyymm == null || yyyymm.length() != 6) { + return yyyymm; + } + return yyyymm.substring(0, 4) + "-" + yyyymm.substring(4, 6); + } + + @Named("formatYearTime") + default String formatYearTime(String yyyy) { + return yyyy; + } } diff --git a/src/main/java/com/eactive/ext/kjb/statistics/service/ApiStatsExcelExportService.java b/src/main/java/com/eactive/ext/kjb/statistics/service/ApiStatsExcelExportService.java new file mode 100644 index 0000000..a97c2fe --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/service/ApiStatsExcelExportService.java @@ -0,0 +1,274 @@ +package com.eactive.ext.kjb.statistics.service; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; + +import com.eactive.ext.kjb.statistics.ui.ApiStatsUI; + +import lombok.extern.slf4j.Slf4j; + +/** + * API 통계 Excel Export 서비스 + * Hour/Minute 통계 데이터를 Excel 파일로 생성 + */ +@Slf4j +@Service +public class ApiStatsExcelExportService { + + private static final String[] COLUMN_HEADERS = { + "통계시간", "API명", "인스턴스", "업무구분", "클라이언트ID", + "Inbound Adapter", "Outbound Adapter", + "총건수", "성공건수", "Timeout건수", "시스템오류건수", "업무오류건수", + "Seq900 Timeout", "Seq900 시스템오류", "Seq900 업무오류", + "평균응답시간", "최소응답시간", "최대응답시간", "P50응답시간", "P95응답시간" + }; + + /** + * API 통계 데이터를 Excel 파일로 생성하여 HTTP 응답으로 전송 + * + * @param dataList API 통계 데이터 리스트 + * @param fileName 다운로드 파일명 + * @param response HTTP 응답 객체 + * @throws IOException 파일 생성 실패 시 + */ + public void exportApiStats(List dataList, String fileName, HttpServletResponse response) + throws IOException { + + log.info("Starting Excel export - rows: {}, filename: {}", dataList.size(), fileName); + + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("API통계"); + log.debug("Excel sheet created"); + + // 스타일 생성 + CellStyle headerStyle = createHeaderStyle(workbook); + CellStyle stringStyle = createStringStyle(workbook); + CellStyle numberStyle = createNumberStyle(workbook); + CellStyle decimalStyle = createDecimalStyle(workbook); + log.debug("Excel styles created"); + + // 헤더 행 생성 + createHeaderRow(sheet, headerStyle); + log.debug("Header row created"); + + // 데이터 행 생성 + int rowNum = 1; + for (ApiStatsUI data : dataList) { + createDataRow(sheet, rowNum++, data, stringStyle, numberStyle, decimalStyle); + } + log.info("Data rows created - count: {}", dataList.size()); + + // 컬럼 너비 자동 조정 + autoSizeColumns(sheet); + log.debug("Column widths adjusted"); + + // HTTP 응답 설정 + setHttpResponse(response, fileName, workbook); + log.info("Excel file sent to response"); + + } catch (Exception e) { + log.error("Error creating Excel file", e); + throw new IOException("Excel file creation failed: " + e.getMessage(), e); + } + } + + /** + * 헤더 스타일 생성 (회색 배경, 볼드, 테두리) + */ + private CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + // 배경색 + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + // 테두리 + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + + // 정렬 + style.setAlignment(HorizontalAlignment.CENTER); + + // 폰트 (볼드) + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + + return style; + } + + /** + * 문자열 데이터 스타일 생성 (기본 테두리) + */ + private CellStyle createStringStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } + + /** + * 숫자 데이터 스타일 생성 (우측정렬 + 쉼표 구분) + */ + private CellStyle createNumberStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setAlignment(HorizontalAlignment.RIGHT); + style.setDataFormat(workbook.createDataFormat().getFormat("#,##0")); + return style; + } + + /** + * 소수점 데이터 스타일 생성 (우측정렬 + 소수점 3자리) + */ + private CellStyle createDecimalStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setAlignment(HorizontalAlignment.RIGHT); + style.setDataFormat(workbook.createDataFormat().getFormat("0.000")); + return style; + } + + /** + * 헤더 행 생성 + */ + private void createHeaderRow(Sheet sheet, CellStyle headerStyle) { + Row headerRow = sheet.createRow(0); + + for (int i = 0; i < COLUMN_HEADERS.length; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(COLUMN_HEADERS[i]); + cell.setCellStyle(headerStyle); + } + } + + /** + * 데이터 행 생성 + */ + private void createDataRow(Sheet sheet, int rowNum, ApiStatsUI data, + CellStyle stringStyle, CellStyle numberStyle, CellStyle decimalStyle) { + + Row row = sheet.createRow(rowNum); + int colNum = 0; + + // 문자열 컬럼 (0-6) + createStringCell(row, colNum++, data.getStatTime(), stringStyle); + createStringCell(row, colNum++, data.getApiName(), stringStyle); + createStringCell(row, colNum++, data.getGwInstanceId(), stringStyle); + createStringCell(row, colNum++, data.getBizDivCode(), stringStyle); + createStringCell(row, colNum++, data.getClientId(), stringStyle); + createStringCell(row, colNum++, data.getInboundAdapter(), stringStyle); + createStringCell(row, colNum++, data.getOutboundAdapter(), stringStyle); + + // 숫자 컬럼 (7-14) + createLongCell(row, colNum++, data.getTotalCnt(), numberStyle); + createLongCell(row, colNum++, data.getSuccessCnt(), numberStyle); + createLongCell(row, colNum++, data.getTimeoutCnt(), numberStyle); + createLongCell(row, colNum++, data.getSystemErrCnt(), numberStyle); + createLongCell(row, colNum++, data.getBizErrCnt(), numberStyle); + createLongCell(row, colNum++, data.getSeq900TimeoutCnt(), numberStyle); + createLongCell(row, colNum++, data.getSeq900SystemErrCnt(), numberStyle); + createLongCell(row, colNum++, data.getSeq900BizErrCnt(), numberStyle); + + // 소수점 컬럼 (15-19) + createDecimalCell(row, colNum++, data.getAvgRespTime(), decimalStyle); + createDecimalCell(row, colNum++, data.getMinRespTime(), decimalStyle); + createDecimalCell(row, colNum++, data.getMaxRespTime(), decimalStyle); + createDecimalCell(row, colNum++, data.getP50RespTime(), decimalStyle); + createDecimalCell(row, colNum++, data.getP95RespTime(), decimalStyle); + } + + /** + * 문자열 셀 생성 + */ + private void createStringCell(Row row, int colNum, String value, CellStyle style) { + Cell cell = row.createCell(colNum); + cell.setCellValue(value != null ? value : ""); + cell.setCellStyle(style); + } + + /** + * Long 타입 숫자 셀 생성 + */ + private void createLongCell(Row row, int colNum, Long value, CellStyle style) { + Cell cell = row.createCell(colNum); + if (value != null) { + cell.setCellValue(value.doubleValue()); + } else { + cell.setCellValue(0); + } + cell.setCellStyle(style); + } + + /** + * BigDecimal 타입 소수점 셀 생성 + */ + private void createDecimalCell(Row row, int colNum, BigDecimal value, CellStyle style) { + Cell cell = row.createCell(colNum); + if (value != null) { + cell.setCellValue(value.doubleValue()); + } else { + cell.setCellValue(0.0); + } + cell.setCellStyle(style); + } + + /** + * 컬럼 너비 자동 조정 + */ + private void autoSizeColumns(Sheet sheet) { + for (int i = 0; i < COLUMN_HEADERS.length; i++) { + sheet.autoSizeColumn(i); + // 한글 문자 고려하여 약간 여유 공간 추가 + sheet.setColumnWidth(i, sheet.getColumnWidth(i) + 512); + } + } + + /** + * HTTP 응답 설정 및 파일 전송 + */ + private void setHttpResponse(HttpServletResponse response, String fileName, Workbook workbook) + throws IOException { + + // Content-Type 설정 + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + // Content-Disposition 설정 (파일명 UTF-8 인코딩) + String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + encodedFileName + "\"; filename*=UTF-8''" + encodedFileName); + + // Workbook을 응답 스트림으로 전송 + workbook.write(response.getOutputStream()); + response.getOutputStream().flush(); + } +} diff --git a/src/main/java/com/eactive/ext/kjb/statistics/statistics_table.md b/src/main/java/com/eactive/ext/kjb/statistics/statistics_table.md index 671acd5..6267b7b 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/statistics_table.md +++ b/src/main/java/com/eactive/ext/kjb/statistics/statistics_table.md @@ -167,3 +167,225 @@ COMMENT ON COLUMN API_STATS_HOUR.P95_RESP_TIME IS '95 백분위 응답시간(ms) --- +## 일별 통계 테이블 DDL + +```sql +-- API Gateway 일별 통계 테이블 +CREATE TABLE API_STATS_DAY ( + STAT_TIME DATE NOT NULL, -- 통계 시간 (일 단위) + API_NAME VARCHAR2(100) NOT NULL, -- API명 + GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID + BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드 + CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID + INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter + OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter + + -- 건수 메트릭 + TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL, + TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 로그 시퀀스 900 구간 에러 카운트 + SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 응답시간 메트릭 (ms, 소수점 3자리) + AVG_RESP_TIME NUMBER(10,3) DEFAULT 0, + MIN_RESP_TIME NUMBER(10,3) DEFAULT 0, + MAX_RESP_TIME NUMBER(10,3) DEFAULT 0, + P50_RESP_TIME NUMBER(10,3) DEFAULT 0, + P95_RESP_TIME NUMBER(10,3) DEFAULT 0, + + CONSTRAINT PK_API_STATS_DAY PRIMARY KEY ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER + ) +); + +-- 코멘트 +COMMENT ON TABLE API_STATS_DAY IS 'API Gateway 일별 처리 통계'; +COMMENT ON COLUMN API_STATS_DAY.STAT_TIME IS '통계시간(일단위)'; +COMMENT ON COLUMN API_STATS_DAY.API_NAME IS 'API 명칭'; +COMMENT ON COLUMN API_STATS_DAY.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID'; +COMMENT ON COLUMN API_STATS_DAY.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_DAY.CLIENT_ID IS '클라이언트 ID (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_DAY.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_DAY.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_DAY.TOTAL_CNT IS '총 처리 건수'; +COMMENT ON COLUMN API_STATS_DAY.SUCCESS_CNT IS '성공 건수'; +COMMENT ON COLUMN API_STATS_DAY.TIMEOUT_CNT IS 'Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.SYSTEM_ERR_CNT IS '시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.BIZ_ERR_CNT IS '업무 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수'; +COMMENT ON COLUMN API_STATS_DAY.AVG_RESP_TIME IS '평균 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_DAY.MIN_RESP_TIME IS '최소 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_DAY.MAX_RESP_TIME IS '최대 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_DAY.P50_RESP_TIME IS '50 백분위 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_DAY.P95_RESP_TIME IS '95 백분위 응답시간(ms)'; +``` + +--- + +## 월별 통계 테이블 DDL + +```sql +-- API Gateway 월별 통계 테이블 +CREATE TABLE API_STATS_MONTH ( + STAT_TIME VARCHAR2(6) NOT NULL, -- 통계 시간 (YYYYMM 형식) + API_NAME VARCHAR2(100) NOT NULL, -- API명 + GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID + BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드 + CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID + INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter + OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter + + -- 건수 메트릭 + TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL, + TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 로그 시퀀스 900 구간 에러 카운트 + SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 응답시간 메트릭 (ms, 소수점 3자리) + AVG_RESP_TIME NUMBER(10,3) DEFAULT 0, + MIN_RESP_TIME NUMBER(10,3) DEFAULT 0, + MAX_RESP_TIME NUMBER(10,3) DEFAULT 0, + P50_RESP_TIME NUMBER(10,3) DEFAULT 0, + P95_RESP_TIME NUMBER(10,3) DEFAULT 0, + + CONSTRAINT PK_API_STATS_MONTH PRIMARY KEY ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER + ) +); + +-- 코멘트 +COMMENT ON TABLE API_STATS_MONTH IS 'API Gateway 월별 처리 통계'; +COMMENT ON COLUMN API_STATS_MONTH.STAT_TIME IS '통계시간(월단위, YYYYMM 형식)'; +COMMENT ON COLUMN API_STATS_MONTH.API_NAME IS 'API 명칭'; +COMMENT ON COLUMN API_STATS_MONTH.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID'; +COMMENT ON COLUMN API_STATS_MONTH.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MONTH.CLIENT_ID IS '클라이언트 ID (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MONTH.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MONTH.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MONTH.TOTAL_CNT IS '총 처리 건수'; +COMMENT ON COLUMN API_STATS_MONTH.SUCCESS_CNT IS '성공 건수'; +COMMENT ON COLUMN API_STATS_MONTH.TIMEOUT_CNT IS 'Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.SYSTEM_ERR_CNT IS '시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.BIZ_ERR_CNT IS '업무 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수'; +COMMENT ON COLUMN API_STATS_MONTH.AVG_RESP_TIME IS '평균 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MONTH.MIN_RESP_TIME IS '최소 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MONTH.MAX_RESP_TIME IS '최대 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MONTH.P50_RESP_TIME IS '50 백분위 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MONTH.P95_RESP_TIME IS '95 백분위 응답시간(ms)'; +``` + +--- + +## 연도별 통계 테이블 DDL + +```sql +-- API Gateway 연도별 통계 테이블 +CREATE TABLE API_STATS_YEAR ( + STAT_TIME VARCHAR2(4) NOT NULL, -- 통계 시간 (YYYY 형식) + API_NAME VARCHAR2(100) NOT NULL, -- API명 + GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID + BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드 + CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID + INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter + OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter + + -- 건수 메트릭 + TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL, + TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 로그 시퀀스 900 구간 에러 카운트 + SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 응답시간 메트릭 (ms, 소수점 3자리) + AVG_RESP_TIME NUMBER(10,3) DEFAULT 0, + MIN_RESP_TIME NUMBER(10,3) DEFAULT 0, + MAX_RESP_TIME NUMBER(10,3) DEFAULT 0, + P50_RESP_TIME NUMBER(10,3) DEFAULT 0, + P95_RESP_TIME NUMBER(10,3) DEFAULT 0, + + CONSTRAINT PK_API_STATS_YEAR PRIMARY KEY ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER + ) +); + +-- 코멘트 +COMMENT ON TABLE API_STATS_YEAR IS 'API Gateway 연도별 처리 통계'; +COMMENT ON COLUMN API_STATS_YEAR.STAT_TIME IS '통계시간(연도단위, YYYY 형식)'; +COMMENT ON COLUMN API_STATS_YEAR.API_NAME IS 'API 명칭'; +COMMENT ON COLUMN API_STATS_YEAR.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID'; +COMMENT ON COLUMN API_STATS_YEAR.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_YEAR.CLIENT_ID IS '클라이언트 ID (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_YEAR.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_YEAR.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_YEAR.TOTAL_CNT IS '총 처리 건수'; +COMMENT ON COLUMN API_STATS_YEAR.SUCCESS_CNT IS '성공 건수'; +COMMENT ON COLUMN API_STATS_YEAR.TIMEOUT_CNT IS 'Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.SYSTEM_ERR_CNT IS '시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.BIZ_ERR_CNT IS '업무 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수'; +COMMENT ON COLUMN API_STATS_YEAR.AVG_RESP_TIME IS '평균 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_YEAR.MIN_RESP_TIME IS '최소 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_YEAR.MAX_RESP_TIME IS '최대 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_YEAR.P50_RESP_TIME IS '50 백분위 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_YEAR.P95_RESP_TIME IS '95 백분위 응답시간(ms)'; +``` + +--- + +## 샘플 데이터 (테스트용) + +```sql +-- 월별 통계 샘플 데이터 +INSERT INTO API_STATS_MONTH ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, + INBOUND_ADAPTER, OUTBOUND_ADAPTER, TOTAL_CNT, SUCCESS_CNT, + TIMEOUT_CNT, SYSTEM_ERR_CNT, BIZ_ERR_CNT, + SEQ900_TIMEOUT_CNT, SEQ900_SYSTEM_ERR_CNT, SEQ900_BIZ_ERR_CNT, + AVG_RESP_TIME, MIN_RESP_TIME, MAX_RESP_TIME, P50_RESP_TIME, P95_RESP_TIME +) VALUES ( + '202512', 'TEST_API', 'GW01', 'MOBILE', 'CLIENT01', + 'HTTP', 'REST', 30000, 29400, 300, 150, 150, 60, 30, 60, + 120.500, 8.000, 600.000, 95.000, 280.000 +); + +-- 연도별 통계 샘플 데이터 +INSERT INTO API_STATS_YEAR ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, + INBOUND_ADAPTER, OUTBOUND_ADAPTER, TOTAL_CNT, SUCCESS_CNT, + TIMEOUT_CNT, SYSTEM_ERR_CNT, BIZ_ERR_CNT, + SEQ900_TIMEOUT_CNT, SEQ900_SYSTEM_ERR_CNT, SEQ900_BIZ_ERR_CNT, + AVG_RESP_TIME, MIN_RESP_TIME, MAX_RESP_TIME, P50_RESP_TIME, P95_RESP_TIME +) VALUES ( + '2025', 'TEST_API', 'GW01', 'MOBILE', 'CLIENT01', + 'HTTP', 'REST', 365000, 357700, 3650, 1825, 1825, 730, 365, 730, + 118.750, 5.000, 700.000, 90.000, 270.000 +); + +COMMIT; +``` + +--- diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsChartUI.java b/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsChartUI.java index d2e7c7c..3f788be 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsChartUI.java +++ b/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsChartUI.java @@ -1,6 +1,7 @@ package com.eactive.ext.kjb.statistics.ui; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -21,22 +22,60 @@ public class ApiStatsChartUI { private Long timeoutCnt; private Long systemErrCnt; private Long bizErrCnt; + private Long seq900TimeoutCnt; + private Long seq900SystemErrCnt; + private Long seq900BizErrCnt; // 응답시간 메트릭 (AVG) private BigDecimal avgRespTime; private BigDecimal p50RespTime; private BigDecimal p95RespTime; - // QueryDSL Projections용 생성자 + // QueryDSL Projections용 생성자 - Hour/Minute (LocalDateTime) public ApiStatsChartUI(LocalDateTime statTime, Long totalCnt, Long successCnt, Long timeoutCnt, Long systemErrCnt, Long bizErrCnt, + Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt, Double avgRespTime, Double p50RespTime, Double p95RespTime) { this.statTime = statTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt, + seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt, + avgRespTime, p50RespTime, p95RespTime); + } + + // QueryDSL Projections용 생성자 - Day (LocalDate) + public ApiStatsChartUI(LocalDate statTime, Long totalCnt, Long successCnt, + Long timeoutCnt, Long systemErrCnt, Long bizErrCnt, + Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt, + Double avgRespTime, Double p50RespTime, Double p95RespTime) { + this.statTime = statTime.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt, + seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt, + avgRespTime, p50RespTime, p95RespTime); + } + + // QueryDSL Projections용 생성자 - Month/Year (String) + public ApiStatsChartUI(String statTime, Long totalCnt, Long successCnt, + Long timeoutCnt, Long systemErrCnt, Long bizErrCnt, + Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt, + Double avgRespTime, Double p50RespTime, Double p95RespTime) { + this.statTime = statTime; + setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt, + seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt, + avgRespTime, p50RespTime, p95RespTime); + } + + private void setMetrics(Long totalCnt, Long successCnt, Long timeoutCnt, + Long systemErrCnt, Long bizErrCnt, Long seq900TimeoutCnt, + Long seq900SystemErrCnt, Long seq900BizErrCnt, + Double avgRespTime, Double p50RespTime, Double p95RespTime) { this.totalCnt = totalCnt != null ? totalCnt : 0L; this.successCnt = successCnt != null ? successCnt : 0L; this.timeoutCnt = timeoutCnt != null ? timeoutCnt : 0L; this.systemErrCnt = systemErrCnt != null ? systemErrCnt : 0L; this.bizErrCnt = bizErrCnt != null ? bizErrCnt : 0L; + this.seq900TimeoutCnt = seq900TimeoutCnt != null ? seq900TimeoutCnt : 0L; + this.seq900SystemErrCnt = seq900SystemErrCnt != null ? seq900SystemErrCnt : 0L; + this.seq900BizErrCnt = seq900BizErrCnt != null ? seq900BizErrCnt : 0L; this.avgRespTime = avgRespTime != null ? BigDecimal.valueOf(avgRespTime) : BigDecimal.ZERO; this.p50RespTime = p50RespTime != null ? BigDecimal.valueOf(p50RespTime) : BigDecimal.ZERO; this.p95RespTime = p95RespTime != null ? BigDecimal.valueOf(p95RespTime) : BigDecimal.ZERO; diff --git a/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsSearch.java b/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsSearch.java index 68c75cd..96c0e58 100644 --- a/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsSearch.java +++ b/src/main/java/com/eactive/ext/kjb/statistics/ui/ApiStatsSearch.java @@ -1,5 +1,6 @@ package com.eactive.ext.kjb.statistics.ui; +import java.time.LocalDate; import java.time.LocalDateTime; import lombok.Data; @@ -22,4 +23,19 @@ public class ApiStatsSearch { private LocalDateTime parsedStartDateTime; /** 파싱된 종료시간 */ private LocalDateTime parsedEndDateTime; + + /** 파싱된 시작날짜 (Day 통계용) */ + private LocalDate parsedStartDate; + /** 파싱된 종료날짜 (Day 통계용) */ + private LocalDate parsedEndDate; + + /** 파싱된 시작월 (Month 통계용, YYYYMM) */ + private String parsedStartMonth; + /** 파싱된 종료월 (Month 통계용, YYYYMM) */ + private String parsedEndMonth; + + /** 파싱된 시작년 (Year 통계용, YYYY) */ + private String parsedStartYear; + /** 파싱된 종료년 (Year 통계용, YYYY) */ + private String parsedEndYear; }