API 통계 도메인 구조 개편 및 조회 기능 확장:
- API 통계 엔티티/리포지토리 다층 나눔 (분/시/일/월) - API 상세/요약 통계 및 기간별 누적 통계 DTO 추가 - 고객센터 연락처 조회 로직 추가 (@ModelAttribute) - 글로벌 푸터 적용 - @EMSDataSource 지원 엔티티-패키지 다중 검색 적용 - 기존 ApiStatisticsRepository 삭제, 분리된 구성으로 대체
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
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 일별 처리 통계 (AGWAPP.API_STATS_DAY) — 읽기 전용.
|
||||
*
|
||||
* 월별 조회 시 "현재 월"은 아직 API_STATS_MONTH 에 집계되지 않으므로(월 집계 잡은 익월 1일 실행)
|
||||
* 현재 월분은 이 DAY 테이블에서 조회한다.
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
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 시간별 처리 통계 (AGWAPP.API_STATS_HOUR).
|
||||
*
|
||||
* 스키마명은 소스에 하드코딩하지 않는다 — gateway datasource 의 hibernate.default_schema(AGWAPP)로 해석된다.
|
||||
* 포털은 읽기 전용으로만 사용한다(집계 주체는 eapim-admin 스케줄러). 사용 컬럼(카운트)만 매핑한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "API_STATS_HOUR")
|
||||
@IdClass(ApiStatsHourId.class)
|
||||
public class ApiStatsHour implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "STAT_TIME", nullable = false)
|
||||
private LocalDateTime 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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 시간별 복합키
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatsHourId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private LocalDateTime statTime;
|
||||
private String apiName;
|
||||
private String gwInstanceId;
|
||||
private String bizDivCode;
|
||||
private String clientId;
|
||||
private String inboundAdapter;
|
||||
private String outboundAdapter;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
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 월별 처리 통계 (AGWAPP.API_STATS_MONTH).
|
||||
*
|
||||
* STAT_TIME 은 YYYYMM 문자열. 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
* 포털은 읽기 전용으로만 사용한다. 사용 컬럼(카운트)만 매핑한다.
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* GW 인증 Client 정보 (AGWAPP.TSEAIAU01) — 읽기 전용.
|
||||
*
|
||||
* API 통계의 org 스코핑에 사용한다: 통계 테이블에는 org_id 가 없으므로
|
||||
* TSEAIAU01.ORGID 로 org 의 CLIENTID 목록을 구해 API_STATS_*.CLIENT_ID 와 매칭한다.
|
||||
* (admin ApiUseStatsService 도 API_STATS.CLIENT_ID = TSEAIAU01.CLIENTID 로 조인)
|
||||
*
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIAU01")
|
||||
public class GwAuthClient implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENTID", nullable = false, length = 256)
|
||||
private String clientId;
|
||||
|
||||
@Column(name = "CLIENTNAME", length = 150)
|
||||
private String clientName;
|
||||
|
||||
@Column(name = "ORGID", length = 38)
|
||||
private String orgId;
|
||||
|
||||
@Column(name = "APPSTATUS", length = 1)
|
||||
private String appStatus;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* GW 인터페이스(서비스) 정보 (AGWAPP.TSEAIHE01) — 읽기 전용.
|
||||
*
|
||||
* API_STATS_*.API_NAME 은 인터페이스 ID(=EAISVCNAME)이며, 화면 표시용 실제 명칭은 EAISVCDESC 다.
|
||||
* (admin ApiUseStatsService 도 API_STATS.API_NAME = TSEAIHE01.EAISVCNAME 으로 조인)
|
||||
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIHE01")
|
||||
public class GwSvcInfo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "EAISVCNAME", nullable = false, length = 30)
|
||||
private String svcName; // 인터페이스 ID (= API_STATS.API_NAME)
|
||||
|
||||
@Column(name = "EAISVCDESC", length = 400)
|
||||
private String svcDesc; // 인터페이스 표시 명칭
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_DAY(AGWAPP) 조회 리포지토리.
|
||||
* 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
|
||||
*/
|
||||
public interface ApiStatsDayRepository extends JpaRepository<ApiStatsDay, ApiStatsDayId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (일자 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
|
||||
* 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
|
||||
* row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||
*/
|
||||
@Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
|
||||
"ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
|
||||
List<Object[]> findPeriodByDay(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHour;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHourId;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_HOUR(AGWAPP) 조회 리포지토리.
|
||||
*
|
||||
* 통계 수치는 보존기간이 긴 API_STATS_DAY 를 사용하되, "오늘"은 아직 DAY 로 집계되지 않았으므로
|
||||
* (일 집계 잡은 익일 실행) 오늘분만 HOUR 에서 합산해 DAY 결과에 더한다. 최신 집계 시각도 HOUR 로 산출.
|
||||
*/
|
||||
public interface ApiStatsHourRepository extends JpaRepository<ApiStatsHour, ApiStatsHourId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (시각 범위, 지정 clientId 집합) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 최신 집계 시각 (안내 문구용). 데이터 없으면 null.
|
||||
*/
|
||||
@Query("SELECT MAX(s.statTime) FROM ApiStatsHour s WHERE s.clientId IN :clientIds")
|
||||
LocalDateTime findLatestStatTime(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonth;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonthId;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_MONTH(AGWAPP) 통계 조회 리포지토리 (월별 모드).
|
||||
* STAT_TIME 은 YYYYMM 문자열이므로 문자열 범위 비교로 조회한다.
|
||||
*/
|
||||
public interface ApiStatsMonthRepository extends JpaRepository<ApiStatsMonth, ApiStatsMonthId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (YYYYMM 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* 조회 가능한 월 목록 (YYYYMM, 최신순).
|
||||
*/
|
||||
@Query("SELECT DISTINCT s.statTime FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds ORDER BY s.statTime DESC")
|
||||
List<String> findAvailableMonths(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* TSEAIAU01(AGWAPP 인증 Client) 조회 — API 통계 org 스코핑용.
|
||||
* PTL_ORG.ID == TSEAIAU01.ORGID (1:N), TSEAIAU01.CLIENTID == API_STATS_*.CLIENT_ID.
|
||||
*/
|
||||
public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, String> {
|
||||
|
||||
/**
|
||||
* org 소속 클라이언트 목록 (앱 선택 드롭다운용).
|
||||
*/
|
||||
List<GwAuthClient> findByOrgIdOrderByClientNameAsc(String orgId);
|
||||
|
||||
/**
|
||||
* org 소속 CLIENTID 목록 (통계 필터용).
|
||||
*/
|
||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIHE01(AGWAPP 인터페이스 정보) 조회 — API_NAME(인터페이스 ID) → 표시명(EAISVCDESC) 매핑용.
|
||||
*/
|
||||
public interface GwSvcInfoRepository extends JpaRepository<GwSvcInfo, String> {
|
||||
|
||||
List<GwSvcInfo> findBySvcNameIn(Collection<String> svcNames);
|
||||
}
|
||||
+31
-12
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
/**
|
||||
* API 통계 Controller
|
||||
* API 통계 Controller.
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@@ -31,7 +31,7 @@ public class ApiStatisticsController {
|
||||
private final ApiStatisticsService apiStatisticsService;
|
||||
|
||||
/**
|
||||
* 통계 페이지 진입
|
||||
* 통계 페이지 진입.
|
||||
*/
|
||||
@GetMapping
|
||||
public String statisticsPage(Model model) {
|
||||
@@ -41,11 +41,12 @@ public class ApiStatisticsController {
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
log.debug("[통계] 페이지 진입 - orgId={}", orgId);
|
||||
|
||||
// 앱 목록 조회 (선택용)
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (7일 전 ~ 오늘, 전체 앱)
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
@@ -54,13 +55,19 @@ public class ApiStatisticsController {
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
model.addAttribute("summary", result.getSummary());
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
model.addAttribute("statsAggregationMinute", apiStatisticsService.getAggregationMinute());
|
||||
model.addAttribute("statsLatestTime", apiStatisticsService.getLatestStatTime(orgId));
|
||||
|
||||
return "apps/statistics/apiStatistics";
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회 (AJAX)
|
||||
* 통계 조회 (AJAX).
|
||||
*/
|
||||
@PostMapping("/search")
|
||||
@ResponseBody
|
||||
@@ -70,11 +77,12 @@ public class ApiStatisticsController {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// 날짜 범위 검증 (최대 40일)
|
||||
if (!searchDto.isValidDateRange()) {
|
||||
log.debug("[통계] 검색 요청(AJAX) - orgId={}, mode={}, clientId={}",
|
||||
org.getId(), searchDto.getMode(), searchDto.getClientId());
|
||||
|
||||
if (!isValidRange(searchDto)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(java.util.Collections.singletonMap("error",
|
||||
"조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다."));
|
||||
.body(java.util.Collections.singletonMap("error", rangeErrorMessage(searchDto)));
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
@@ -83,7 +91,9 @@ public class ApiStatisticsController {
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드
|
||||
* CSV 다운로드.
|
||||
*
|
||||
* ※ UI에서 다운로드 버튼은 제거되었으나 백엔드 기능은 유지한다(직접 호출용).
|
||||
*/
|
||||
@GetMapping("/download")
|
||||
public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||
@@ -93,9 +103,8 @@ public class ApiStatisticsController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 범위 검증 (최대 40일)
|
||||
if (!searchDto.isValidDateRange()) {
|
||||
response.sendError(400, "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.");
|
||||
if (!isValidRange(searchDto)) {
|
||||
response.sendError(400, rangeErrorMessage(searchDto));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,6 +112,16 @@ public class ApiStatisticsController {
|
||||
apiStatisticsService.downloadCsv(orgId, searchDto, response);
|
||||
}
|
||||
|
||||
private boolean isValidRange(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly() ? searchDto.isValidMonth() : searchDto.isValidDateRange();
|
||||
}
|
||||
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
if (SecurityUtil.getPortalAuthenticatedUser() != null) {
|
||||
return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
|
||||
|
||||
+27
-25
@@ -5,50 +5,52 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 개별 API 통계 상세 DTO
|
||||
* 개별 API 통계 상세 DTO (API_NAME 단위 집계).
|
||||
* 성공/타임아웃/실패(오류) 배타 구분. {@link ApiStatisticsSummaryDto} 참고.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsDetailDto {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
private Long attemptedCount; // 호출 건
|
||||
private Long completedCount; // 성공 건
|
||||
private Long failedCount; // 실패 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double failureRate; // 실패율 (%)
|
||||
private Long totalCount; // 호출 건
|
||||
private Long successCount; // 성공 건
|
||||
private Long timeoutCount; // 타임아웃 건
|
||||
private Long errorCount; // 실패(오류) 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double timeoutRate; // 타임아웃율 (%)
|
||||
private Double errorRate; // 실패(오류)율 (%)
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자
|
||||
* Repository 조회용 생성자.
|
||||
*/
|
||||
public ApiStatisticsDetailDto(String apiId, String apiName, Long attemptedCount, Long completedCount) {
|
||||
this.apiId = apiId;
|
||||
public ApiStatisticsDetailDto(String apiName, Long totalCount, Long successCount,
|
||||
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||
this.apiName = apiName;
|
||||
this.attemptedCount = attemptedCount != null ? attemptedCount : 0L;
|
||||
this.completedCount = completedCount != null ? completedCount : 0L;
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패건, 성공률, 실패율 계산
|
||||
* 성공율/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
if (attemptedCount == null) {
|
||||
attemptedCount = 0L;
|
||||
}
|
||||
if (completedCount == null) {
|
||||
completedCount = 0L;
|
||||
}
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||
|
||||
this.failedCount = attemptedCount - completedCount;
|
||||
|
||||
if (attemptedCount > 0) {
|
||||
this.successRate = Math.round((completedCount * 1000.0) / attemptedCount) / 10.0;
|
||||
this.failureRate = Math.round((failedCount * 1000.0) / attemptedCount) / 10.0;
|
||||
if (totalCount > 0) {
|
||||
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.failureRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.apps.statistics.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 기간별(일별/월별) 누적 통계 DTO — API별 통계 하단 표에 사용.
|
||||
*
|
||||
* period 는 일별 모드에서 "yyyy-MM-dd", 월별 모드에서 "yyyy-MM" 형태의 라벨.
|
||||
* 각 행은 해당 기간의 전체 앱/전체 API 합계이다.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsPeriodDto {
|
||||
|
||||
private String period; // 기간 라벨 (yyyy-MM-dd 또는 yyyy-MM)
|
||||
private Long totalCount;
|
||||
private Long successCount;
|
||||
private Long timeoutCount;
|
||||
private Long errorCount;
|
||||
private Double successRate;
|
||||
private Double timeoutRate;
|
||||
private Double errorRate;
|
||||
|
||||
/** 해당 일자에 실제 집계 데이터가 존재하는지 여부. false면 표에 '-'로 표기(0과 구분). */
|
||||
private boolean hasData;
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자 (실제 데이터 행 → hasData=true).
|
||||
*/
|
||||
public ApiStatisticsPeriodDto(String period, Long totalCount, Long successCount,
|
||||
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||
this.period = period;
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||
this.hasData = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공율/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||
|
||||
if (totalCount > 0) {
|
||||
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -7,7 +7,8 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 조회 결과 DTO
|
||||
* API 통계 조회 결과 DTO.
|
||||
* summary: 전체 요약, details: API별, periods: 기간별(일/월) 누적.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -16,13 +17,15 @@ public class ApiStatisticsResultDto {
|
||||
|
||||
private ApiStatisticsSummaryDto summary;
|
||||
private List<ApiStatisticsDetailDto> details;
|
||||
private List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
/**
|
||||
* 빈 결과 생성
|
||||
* 빈 결과 생성.
|
||||
*/
|
||||
public static ApiStatisticsResultDto empty() {
|
||||
return new ApiStatisticsResultDto(
|
||||
ApiStatisticsSummaryDto.empty(),
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>()
|
||||
);
|
||||
}
|
||||
|
||||
+30
-11
@@ -6,16 +6,25 @@ import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
/**
|
||||
* API 통계 검색 조건 DTO
|
||||
* API 통계 검색 조건 DTO.
|
||||
*
|
||||
* mode = DAILY 이면 startDate~endDate(날짜범위, API_STATS_HOUR),
|
||||
* mode = MONTHLY 이면 month(YYYYMM 단일 선택)를 사용한다.
|
||||
*/
|
||||
@Data
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
public enum Mode {
|
||||
DAILY, MONTHLY
|
||||
}
|
||||
|
||||
private Mode mode = Mode.DAILY;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate startDate;
|
||||
|
||||
@@ -23,7 +32,12 @@ public class ApiStatisticsSearchDto {
|
||||
private LocalDate endDate;
|
||||
|
||||
/**
|
||||
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱)
|
||||
* 월별 조회 대상 월 (YYYYMM, 단일 선택).
|
||||
*/
|
||||
private String month;
|
||||
|
||||
/**
|
||||
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱).
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
@@ -33,15 +47,19 @@ public class ApiStatisticsSearchDto {
|
||||
this.clientId = null;
|
||||
}
|
||||
|
||||
public boolean isMonthly() {
|
||||
return mode == Mode.MONTHLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 앱이 선택되었는지 여부
|
||||
* 특정 앱이 선택되었는지 여부.
|
||||
*/
|
||||
public boolean hasSelectedApp() {
|
||||
return clientId != null && !clientId.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위가 유효한지 검증 (최대 40일)
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
if (startDate == null || endDate == null) {
|
||||
@@ -55,12 +73,13 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회 기간 일수 반환
|
||||
* 월(월별) 선택값이 YYYYMM 형식인지 검증 (가용월 여부는 서비스/드롭다운에서 제한).
|
||||
*/
|
||||
public long getDateRangeDays() {
|
||||
if (startDate == null || endDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return ChronoUnit.DAYS.between(startDate, endDate);
|
||||
public boolean isValidMonth() {
|
||||
return isYyyymm(month);
|
||||
}
|
||||
|
||||
private static boolean isYyyymm(String s) {
|
||||
return s != null && s.matches("^\\d{6}$");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-25
@@ -5,54 +5,72 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 전체 통계 요약 DTO
|
||||
* API 전체 통계 요약 DTO.
|
||||
*
|
||||
* 집계 소스: AGWAPP.API_STATS_HOUR / API_STATS_MONTH.
|
||||
* 성공/타임아웃/실패(오류)를 배타적으로 구분한다.
|
||||
* - 성공 = SUCCESS_CNT
|
||||
* - 타임아웃 = TIMEOUT_CNT
|
||||
* - 실패(오류) = SYSTEM_ERR_CNT + BIZ_ERR_CNT
|
||||
* - 호출수(total) = TOTAL_CNT
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsSummaryDto {
|
||||
|
||||
private Long totalAttempted; // 전체 호출 건
|
||||
private Long totalCompleted; // 전체 성공 건
|
||||
private Long totalFailed; // 전체 실패 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double failureRate; // 실패율 (%)
|
||||
private Long totalCount; // 전체 호출 건 (TOTAL_CNT)
|
||||
private Long successCount; // 성공 건 (SUCCESS_CNT)
|
||||
private Long timeoutCount; // 타임아웃 건 (TIMEOUT_CNT)
|
||||
private Long errorCount; // 실패(오류) 건 (SYSTEM_ERR_CNT + BIZ_ERR_CNT)
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double timeoutRate; // 타임아웃율 (%)
|
||||
private Double errorRate; // 실패(오류)율 (%)
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자 (attempted, completed만)
|
||||
* Repository 조회용 생성자 (SUM 집계값).
|
||||
*/
|
||||
public ApiStatisticsSummaryDto(Long totalAttempted, Long totalCompleted) {
|
||||
this.totalAttempted = totalAttempted != null ? totalAttempted : 0L;
|
||||
this.totalCompleted = totalCompleted != null ? totalCompleted : 0L;
|
||||
public ApiStatisticsSummaryDto(Long totalCount, Long successCount, Long timeoutCount,
|
||||
Long systemErrCount, Long bizErrCount) {
|
||||
this.totalCount = nz(totalCount);
|
||||
this.successCount = nz(successCount);
|
||||
this.timeoutCount = nz(timeoutCount);
|
||||
this.errorCount = nz(systemErrCount) + nz(bizErrCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패건, 성공률, 실패율 계산
|
||||
* 성공률/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
if (totalAttempted == null) {
|
||||
totalAttempted = 0L;
|
||||
}
|
||||
if (totalCompleted == null) {
|
||||
totalCompleted = 0L;
|
||||
}
|
||||
this.totalCount = nz(totalCount);
|
||||
this.successCount = nz(successCount);
|
||||
this.timeoutCount = nz(timeoutCount);
|
||||
this.errorCount = nz(errorCount);
|
||||
|
||||
this.totalFailed = totalAttempted - totalCompleted;
|
||||
|
||||
if (totalAttempted > 0) {
|
||||
this.successRate = Math.round((totalCompleted * 1000.0) / totalAttempted) / 10.0;
|
||||
this.failureRate = Math.round((totalFailed * 1000.0) / totalAttempted) / 10.0;
|
||||
if (totalCount > 0) {
|
||||
this.successRate = rate(successCount, totalCount);
|
||||
this.timeoutRate = rate(timeoutCount, totalCount);
|
||||
this.errorRate = rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.failureRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
static long nz(Long v) {
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
static double rate(long part, long total) {
|
||||
return Math.round((part * 1000.0) / total) / 10.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 통계 생성
|
||||
* 빈 통계 생성.
|
||||
*/
|
||||
public static ApiStatisticsSummaryDto empty() {
|
||||
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L);
|
||||
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L, 0L, 0L, 0L);
|
||||
dto.calculateRates();
|
||||
return dto;
|
||||
}
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.portal.obp.entity.ObpGwMetric;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API 통계 Repository
|
||||
* ObpGwMetric 테이블에서 통계 데이터를 조회
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface ApiStatisticsRepository extends BaseRepository<ObpGwMetric, String> {
|
||||
|
||||
/**
|
||||
* 전체 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummaryByOrgAndPeriod(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 전체 통계 조회 (특정 기간, 특정 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.clientId = :clientId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummaryByOrgAndApp(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("clientId") String clientId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 개별 API 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"m.apiId, m.apiName, " +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY m.apiId, m.apiName " +
|
||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetailByOrgAndPeriod(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 개별 API 통계 조회 (특정 기간, 특정 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"m.apiId, m.apiName, " +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.clientId = :clientId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY m.apiId, m.apiName " +
|
||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetailByOrgAndApp(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("clientId") String clientId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIRM21(스케줄러 잡) 조회 — 통계 집계 크론 추출용.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface JobInfoRepository extends JpaRepository<JobInfo, String> {
|
||||
|
||||
Optional<JobInfo> findByJobname(String jobname);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository.entity;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 스케줄러 잡 정보(EMSAPP.TSEAIRM21) — 통계 집계 크론 조회 전용.
|
||||
*
|
||||
* 안내 문구의 "매시 N분 집계" 값을 실제 크론(CRONEXP)에서 추출하기 위해 JOBNAME/CRONEXP 두 컬럼만 매핑한다.
|
||||
* (읽기 전용. 잡 실행/스케줄 변경은 eapim-admin 소관)
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIRM21")
|
||||
public class JobInfo {
|
||||
|
||||
@Id
|
||||
@Column(name = "JOBNAME")
|
||||
private String jobname;
|
||||
|
||||
@Column(name = "CRONEXP")
|
||||
private String cronexp;
|
||||
}
|
||||
+422
-64
@@ -1,21 +1,37 @@
|
||||
package com.eactive.apim.portal.apps.statistics.service;
|
||||
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsMonthRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwSvcInfoRepository;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsPeriodDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.portal.apps.statistics.repository.ApiStatisticsRepository;
|
||||
import com.eactive.apim.portal.apps.statistics.repository.JobInfoRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -23,7 +39,10 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* API 통계 Service
|
||||
* API 통계 Service.
|
||||
*
|
||||
* 데이터 소스: Gateway DB(AGWAPP) API_STATS_HOUR(일별) / API_STATS_MONTH(월별).
|
||||
* 통계 테이블에 org_id 가 없으므로 org 소속 앱의 CLIENT_ID 목록으로 필터한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -31,79 +50,419 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatisticsService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final ApiStatisticsRepository apiStatisticsRepository;
|
||||
/** 시간별 집계 스케줄러 잡명 기본값 — PTL_PROPERTY(Portal/{@value #JOB_NAME_PROP})로 재정의 가능. */
|
||||
private static final String DEFAULT_HOURLY_JOB_NAME = "ApiStatsHourlyAggregationJob";
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String JOB_NAME_PROP = "stats.hourly.aggregation.job.name";
|
||||
|
||||
private static final DateTimeFormatter LATEST_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH시");
|
||||
private static final DateTimeFormatter YYYYMM_FMT = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
/** 요일 한글 한 글자 (월요일=index 0). */
|
||||
private static final String[] WEEKDAYS = {"월", "화", "수", "목", "금", "토", "일"};
|
||||
|
||||
private final GwAuthClientRepository gwAuthClientRepository;
|
||||
private final GwSvcInfoRepository gwSvcInfoRepository;
|
||||
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||
private final ApiStatsMonthRepository apiStatsMonthRepository;
|
||||
private final JobInfoRepository jobInfoRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 로그인 사용자 법인의 앱 목록 조회 (전체)
|
||||
* 로그인 사용자 법인의 앱 목록 조회 (전체). TSEAIAU01.ORGID 기준.
|
||||
* 드롭다운 값(CLIENTID)이 API_STATS_*.CLIENT_ID 와 동일해야 하므로 게이트웨이 인증 클라이언트를 소스로 한다.
|
||||
*/
|
||||
public List<Credential> getAppListByOrg(String orgId) {
|
||||
return credentialRepository.findAllByOrgid(orgId);
|
||||
public List<GwAuthClient> getAppListByOrg(String orgId) {
|
||||
return gwAuthClientRepository.findByOrgIdOrderByClientNameAsc(orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
* 조회 대상 clientId 목록. 특정 앱 선택 시 org 소속 검증 후 해당 1건, 아니면 org 전체 앱.
|
||||
* org→clientId 는 TSEAIAU01.ORGID(=PTL_ORG.ID) 로 조회한다.
|
||||
*/
|
||||
private List<String> resolveClientIds(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||
List<String> orgClientIds = orgClientIds(orgId);
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
String clientId = searchDto.getClientId();
|
||||
return orgClientIds.contains(clientId) ? Collections.singletonList(clientId) : Collections.emptyList();
|
||||
}
|
||||
return orgClientIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회 (일별/월별 모드 분기).
|
||||
*/
|
||||
public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||
LocalDateTime startTime = searchDto.getStartDate().atStartOfDay();
|
||||
LocalDateTime endTime = searchDto.getEndDate().atTime(LocalTime.MAX);
|
||||
log.debug("[통계] 조회 시작 - orgId={}, mode={}, clientId={}, 일별[{}~{}], 월별[{}]",
|
||||
orgId, searchDto.getMode(), searchDto.getClientId(),
|
||||
searchDto.getStartDate(), searchDto.getEndDate(), searchDto.getMonth());
|
||||
|
||||
List<String> clientIds = resolveClientIds(orgId, searchDto);
|
||||
log.debug("[통계] org 스코핑 clientId {}건 - {}", clientIds.size(), clientIds);
|
||||
if (clientIds.isEmpty()) {
|
||||
log.debug("[통계] 대상 clientId 없음 - 빈 결과 반환 (orgId={})", orgId);
|
||||
return ApiStatisticsResultDto.empty();
|
||||
}
|
||||
|
||||
ApiStatisticsSummaryDto summary;
|
||||
List<ApiStatisticsDetailDto> details;
|
||||
List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
// 특정 앱 통계
|
||||
String clientId = searchDto.getClientId();
|
||||
if (searchDto.isMonthly()) {
|
||||
String month = searchDto.getMonth();
|
||||
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||
YearMonth ym = YearMonth.parse(month, YYYYMM_FMT);
|
||||
boolean isCurrent = month.equals(currentYm);
|
||||
LocalDate monthStart = ym.atDay(1);
|
||||
LocalDate monthEnd = isCurrent ? LocalDate.now() : ym.atEndOfMonth();
|
||||
|
||||
// 해당 앱이 조직 소속인지 검증
|
||||
if (!isAppBelongsToOrg(clientId, orgId)) {
|
||||
return ApiStatisticsResultDto.empty();
|
||||
if (isCurrent) {
|
||||
// 현재 월: DAY(과거일) + HOUR(오늘) 합산. 하단 일별표도 동일 소스.
|
||||
log.debug("[통계] 월별-현재월 {} → DAY+오늘(HOUR) {}~{}", month, monthStart, monthEnd);
|
||||
RangeStats rs = combineDayAndToday(clientIds, monthStart, monthEnd);
|
||||
summary = rs.summary;
|
||||
details = rs.details;
|
||||
periods = rs.periods;
|
||||
} else {
|
||||
// 과거 월: 요약/API별은 API_STATS_MONTH, 하단 일별표는 해당 월의 DAY 일자별
|
||||
log.debug("[통계] 월별-과거월 {} → API_STATS_MONTH + 하단 DAY 일별", month);
|
||||
summary = orEmpty(apiStatsMonthRepository.findSummary(clientIds, month, month));
|
||||
details = nvl(apiStatsMonthRepository.findDetail(clientIds, month, month));
|
||||
List<Object[]> monthlyDayRows = apiStatsDayRepository.findPeriodByDay(clientIds, monthStart, monthEnd);
|
||||
periods = buildDailyPeriods(monthlyDayRows, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
summary = apiStatisticsRepository.findSummaryByOrgAndApp(
|
||||
orgId, clientId, startTime, endTime);
|
||||
details = apiStatisticsRepository.findDetailByOrgAndApp(
|
||||
orgId, clientId, startTime, endTime);
|
||||
} else {
|
||||
// 전체 앱 통계
|
||||
summary = apiStatisticsRepository.findSummaryByOrgAndPeriod(
|
||||
orgId, startTime, endTime);
|
||||
details = apiStatisticsRepository.findDetailByOrgAndPeriod(
|
||||
orgId, startTime, endTime);
|
||||
// 일별 조회: DAY(과거일) + HOUR(오늘) 합산. (HOUR는 보존 짧음/오늘만, DAY는 과거 보존)
|
||||
LocalDate start = searchDto.getStartDate();
|
||||
LocalDate end = searchDto.getEndDate();
|
||||
log.debug("[통계] 일별 조회 - DAY+오늘(HOUR), 범위 {}~{}", start, end);
|
||||
RangeStats rs = combineDayAndToday(clientIds, start, end);
|
||||
summary = rs.summary;
|
||||
details = rs.details;
|
||||
periods = rs.periods;
|
||||
}
|
||||
|
||||
// null 체크 및 빈 결과 처리
|
||||
if (summary == null) {
|
||||
summary = ApiStatisticsSummaryDto.empty();
|
||||
} else {
|
||||
summary.calculateRates();
|
||||
}
|
||||
|
||||
if (details == null) {
|
||||
details = Collections.emptyList();
|
||||
} else {
|
||||
details.forEach(ApiStatisticsDetailDto::calculateRates);
|
||||
}
|
||||
if (periods == null) {
|
||||
periods = Collections.emptyList();
|
||||
} else {
|
||||
periods.forEach(ApiStatisticsPeriodDto::calculateRates);
|
||||
}
|
||||
|
||||
return new ApiStatisticsResultDto(summary, details);
|
||||
// API_NAME(인터페이스 ID) → 표시명(TSEAIHE01.EAISVCDESC) 매핑
|
||||
applyDisplayNames(details);
|
||||
|
||||
log.debug("[통계] 조회 완료 - 총호출={}, 성공={}, 타임아웃={}, 실패={} / API별 {}건, 기간별 {}건",
|
||||
summary.getTotalCount(), summary.getSuccessCount(), summary.getTimeoutCount(),
|
||||
summary.getErrorCount(), details.size(), periods.size());
|
||||
|
||||
return new ApiStatisticsResultDto(summary, details, periods);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱이 해당 조직 소속인지 검증
|
||||
* 일별 기간표 Object[] row → ApiStatisticsPeriodDto.
|
||||
* row: [0]=일자(String), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||
*/
|
||||
private boolean isAppBelongsToOrg(String clientId, String orgId) {
|
||||
return credentialRepository.findByClientidAndOrgid(clientId, orgId).isPresent();
|
||||
private static ApiStatisticsPeriodDto toPeriodDto(Object[] row) {
|
||||
return new ApiStatisticsPeriodDto(
|
||||
row[0] != null ? row[0].toString() : null,
|
||||
toLong(row[1]), toLong(row[2]), toLong(row[3]), toLong(row[4]), toLong(row[5]));
|
||||
}
|
||||
|
||||
private static Long toLong(Object o) {
|
||||
return (o instanceof Number) ? ((Number) o).longValue() : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드
|
||||
* 지정 날짜 범위 [start,end] 의 통계를 조립한다.
|
||||
* 과거일은 API_STATS_DAY, "오늘"은 아직 DAY 미집계이므로 API_STATS_HOUR 에서 합산해 더한다.
|
||||
*/
|
||||
private RangeStats combineDayAndToday(List<String> clientIds, LocalDate start, LocalDate end) {
|
||||
LocalDate today = LocalDate.now();
|
||||
boolean includesToday = !today.isBefore(start) && !today.isAfter(end);
|
||||
LocalDate dayEnd = includesToday ? today.minusDays(1) : end;
|
||||
|
||||
ApiStatisticsSummaryDto summary = ApiStatisticsSummaryDto.empty();
|
||||
List<ApiStatisticsDetailDto> details = new ArrayList<>();
|
||||
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||
|
||||
// 과거일: API_STATS_DAY [start .. dayEnd]
|
||||
if (!start.isAfter(dayEnd)) {
|
||||
summary = orEmpty(apiStatsDayRepository.findSummary(clientIds, start, dayEnd));
|
||||
details = nvl(apiStatsDayRepository.findDetail(clientIds, start, dayEnd));
|
||||
byDate = rowsToMap(apiStatsDayRepository.findPeriodByDay(clientIds, start, dayEnd));
|
||||
}
|
||||
|
||||
// 오늘: API_STATS_HOUR (익일 DAY 집계 전)
|
||||
if (includesToday) {
|
||||
LocalDateTime ts = today.atStartOfDay();
|
||||
LocalDateTime te = today.atTime(LocalTime.MAX);
|
||||
ApiStatisticsSummaryDto todaySummary = orEmpty(apiStatsHourRepository.findSummary(clientIds, ts, te));
|
||||
List<ApiStatisticsDetailDto> todayDetails = nvl(apiStatsHourRepository.findDetail(clientIds, ts, te));
|
||||
summary = mergeSummaries(summary, todaySummary);
|
||||
details = mergeDetails(details, todayDetails);
|
||||
if (nz(todaySummary.getTotalCount()) > 0) {
|
||||
ApiStatisticsPeriodDto todayRow = new ApiStatisticsPeriodDto();
|
||||
todayRow.setPeriod(today.format(DAY_FMT));
|
||||
todayRow.setTotalCount(todaySummary.getTotalCount());
|
||||
todayRow.setSuccessCount(todaySummary.getSuccessCount());
|
||||
todayRow.setTimeoutCount(todaySummary.getTimeoutCount());
|
||||
todayRow.setErrorCount(todaySummary.getErrorCount());
|
||||
todayRow.setHasData(true);
|
||||
byDate.put(todayRow.getPeriod(), todayRow);
|
||||
}
|
||||
log.debug("[통계] 오늘분 HOUR 합산 - total={}", todaySummary.getTotalCount());
|
||||
}
|
||||
|
||||
return new RangeStats(summary, details, fillDailyPeriods(byDate, start, end));
|
||||
}
|
||||
|
||||
private static Map<String, ApiStatisticsPeriodDto> rowsToMap(List<Object[]> rows) {
|
||||
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||
if (rows != null) {
|
||||
for (Object[] r : rows) {
|
||||
ApiStatisticsPeriodDto dto = toPeriodDto(r);
|
||||
byDate.put(dto.getPeriod(), dto);
|
||||
}
|
||||
}
|
||||
return byDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 기간표를 지정 날짜 범위의 '모든 일자'로 채운다(데이터 없는 날은 hasData=false → 표에 '-').
|
||||
* 각 행 라벨은 "yyyy-MM-dd (요일)".
|
||||
*/
|
||||
private static List<ApiStatisticsPeriodDto> buildDailyPeriods(List<Object[]> rows, LocalDate start, LocalDate end) {
|
||||
return fillDailyPeriods(rowsToMap(rows), start, end);
|
||||
}
|
||||
|
||||
private static List<ApiStatisticsPeriodDto> fillDailyPeriods(Map<String, ApiStatisticsPeriodDto> byDate,
|
||||
LocalDate start, LocalDate end) {
|
||||
if (start == null || end == null) {
|
||||
return new ArrayList<>(byDate.values());
|
||||
}
|
||||
List<ApiStatisticsPeriodDto> result = new ArrayList<>();
|
||||
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
|
||||
String key = d.format(DAY_FMT);
|
||||
String label = key + " (" + WEEKDAYS[d.getDayOfWeek().getValue() - 1] + ")";
|
||||
ApiStatisticsPeriodDto dto = byDate.get(key);
|
||||
if (dto == null) {
|
||||
dto = new ApiStatisticsPeriodDto();
|
||||
dto.setPeriod(label);
|
||||
dto.setTotalCount(0L);
|
||||
dto.setSuccessCount(0L);
|
||||
dto.setTimeoutCount(0L);
|
||||
dto.setErrorCount(0L);
|
||||
} else {
|
||||
dto.setPeriod(label);
|
||||
}
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <T> List<T> nvl(List<T> l) {
|
||||
return l != null ? new ArrayList<>(l) : new ArrayList<>();
|
||||
}
|
||||
|
||||
private static ApiStatisticsSummaryDto orEmpty(ApiStatisticsSummaryDto s) {
|
||||
return s != null ? s : ApiStatisticsSummaryDto.empty();
|
||||
}
|
||||
|
||||
private static long nz(Long v) {
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 요약(DAY + 오늘 HOUR)을 합산. rate 는 이후 calculateRates 에서 재계산.
|
||||
*/
|
||||
private static ApiStatisticsSummaryDto mergeSummaries(ApiStatisticsSummaryDto a, ApiStatisticsSummaryDto b) {
|
||||
ApiStatisticsSummaryDto m = new ApiStatisticsSummaryDto();
|
||||
m.setTotalCount(nz(a.getTotalCount()) + nz(b.getTotalCount()));
|
||||
m.setSuccessCount(nz(a.getSuccessCount()) + nz(b.getSuccessCount()));
|
||||
m.setTimeoutCount(nz(a.getTimeoutCount()) + nz(b.getTimeoutCount()));
|
||||
m.setErrorCount(nz(a.getErrorCount()) + nz(b.getErrorCount()));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* API별 상세를 API_NAME 기준으로 합산(DAY + 오늘 HOUR). rate 는 이후 calculateRates 에서 재계산.
|
||||
*/
|
||||
private static List<ApiStatisticsDetailDto> mergeDetails(List<ApiStatisticsDetailDto> a,
|
||||
List<ApiStatisticsDetailDto> b) {
|
||||
Map<String, ApiStatisticsDetailDto> acc = new LinkedHashMap<>();
|
||||
accumulate(acc, a);
|
||||
accumulate(acc, b);
|
||||
List<ApiStatisticsDetailDto> merged = new ArrayList<>(acc.values());
|
||||
merged.sort(Comparator.comparing(ApiStatisticsDetailDto::getTotalCount, Comparator.reverseOrder()));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void accumulate(Map<String, ApiStatisticsDetailDto> acc, List<ApiStatisticsDetailDto> list) {
|
||||
if (list == null) {
|
||||
return;
|
||||
}
|
||||
for (ApiStatisticsDetailDto d : list) {
|
||||
ApiStatisticsDetailDto cur = acc.computeIfAbsent(d.getApiName(), k -> {
|
||||
ApiStatisticsDetailDto n = new ApiStatisticsDetailDto();
|
||||
n.setApiName(k);
|
||||
n.setTotalCount(0L);
|
||||
n.setSuccessCount(0L);
|
||||
n.setTimeoutCount(0L);
|
||||
n.setErrorCount(0L);
|
||||
return n;
|
||||
});
|
||||
cur.setTotalCount(nz(cur.getTotalCount()) + nz(d.getTotalCount()));
|
||||
cur.setSuccessCount(nz(cur.getSuccessCount()) + nz(d.getSuccessCount()));
|
||||
cur.setTimeoutCount(nz(cur.getTimeoutCount()) + nz(d.getTimeoutCount()));
|
||||
cur.setErrorCount(nz(cur.getErrorCount()) + nz(d.getErrorCount()));
|
||||
}
|
||||
}
|
||||
|
||||
/** combineDayAndToday 반환 홀더. */
|
||||
private static final class RangeStats {
|
||||
final ApiStatisticsSummaryDto summary;
|
||||
final List<ApiStatisticsDetailDto> details;
|
||||
final List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
RangeStats(ApiStatisticsSummaryDto summary, List<ApiStatisticsDetailDto> details,
|
||||
List<ApiStatisticsPeriodDto> periods) {
|
||||
this.summary = summary;
|
||||
this.details = details;
|
||||
this.periods = periods;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API별 상세의 apiName(인터페이스 ID)을 TSEAIHE01.EAISVCDESC(표시명)로 치환.
|
||||
* 매핑 없으면 인터페이스 ID 를 그대로 둔다.
|
||||
*/
|
||||
private void applyDisplayNames(List<ApiStatisticsDetailDto> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> ids = details.stream()
|
||||
.map(ApiStatisticsDetailDto::getApiName)
|
||||
.filter(n -> n != null && !n.trim().isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> descMap = gwSvcInfoRepository.findBySvcNameIn(ids).stream()
|
||||
.filter(s -> s.getSvcDesc() != null && !s.getSvcDesc().trim().isEmpty())
|
||||
.collect(Collectors.toMap(GwSvcInfo::getSvcName, GwSvcInfo::getSvcDesc, (x, y) -> x));
|
||||
details.forEach(d -> {
|
||||
String desc = descMap.get(d.getApiName());
|
||||
if (desc != null && !desc.trim().isEmpty()) {
|
||||
d.setApiName(desc);
|
||||
}
|
||||
});
|
||||
log.debug("[통계] API 표시명 매핑 - 대상 {}건, 매칭 {}건", ids.size(), descMap.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 모드에서 선택 가능한 월 목록(YYYYMM, 최신순).
|
||||
*/
|
||||
public List<String> getAvailableMonths(String orgId) {
|
||||
List<String> clientIds = orgClientIds(orgId);
|
||||
List<String> months = clientIds.isEmpty()
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(apiStatsMonthRepository.findAvailableMonths(clientIds));
|
||||
|
||||
// 데이터가 없더라도 현재 년-월은 항상 선택 가능하도록 포함 (최신순 유지)
|
||||
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||
if (!months.contains(currentYm)) {
|
||||
months.add(0, currentYm);
|
||||
}
|
||||
log.debug("[통계] 선택가능 월 {}건 (현재월 {} 포함) - orgId={}", months.size(), currentYm, orgId);
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 집계 시각 안내 문자열("yyyy-MM-dd HH시"). 데이터 없으면 null.
|
||||
*/
|
||||
public String getLatestStatTime(String orgId) {
|
||||
List<String> clientIds = orgClientIds(orgId);
|
||||
if (clientIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
LocalDateTime latest = apiStatsHourRepository.findLatestStatTime(clientIds);
|
||||
String result = latest != null ? latest.format(LATEST_FMT) : null;
|
||||
log.debug("[통계] 최신 집계 시각 - {} (orgId={})", result, orgId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간별 집계 잡의 크론에서 분(minute)을 추출한다. 파싱 불가 시 null → 문구는 "매시 집계"로 폴백.
|
||||
* Quartz 크론 필드: [sec] [min] [hour] ... → 인덱스 1이 분.
|
||||
*/
|
||||
@Transactional
|
||||
public Integer getAggregationMinute() {
|
||||
try {
|
||||
// 잡명은 PTL_PROPERTY(Portal/stats.hourly.aggregation.job.name)로 지정 가능(없으면 기본값 생성)
|
||||
String jobName = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, JOB_NAME_PROP, DEFAULT_HOURLY_JOB_NAME,
|
||||
"통계 시간별 집계 스케줄러 잡명(집계 주기 안내 문구용)");
|
||||
Integer minute = jobInfoRepository.findByJobname(jobName)
|
||||
.map(JobInfoCron::minuteOf)
|
||||
.orElse(null);
|
||||
log.debug("[통계] 집계 크론 분(N) = {} (job={})", minute, jobName);
|
||||
return minute;
|
||||
} catch (Exception e) {
|
||||
log.warn("집계 크론 조회 실패 - 안내 문구 분 표기 생략", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> orgClientIds(String orgId) {
|
||||
return gwAuthClientRepository.findClientIdsByOrgId(orgId).stream()
|
||||
.filter(id -> id != null && !id.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론에서 분 필드를 추출하는 헬퍼.
|
||||
*/
|
||||
private static final class JobInfoCron {
|
||||
static Integer minuteOf(com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo job) {
|
||||
String cron = job.getCronexp();
|
||||
if (cron == null) {
|
||||
return null;
|
||||
}
|
||||
String[] fields = cron.trim().split("\\s+");
|
||||
if (fields.length >= 2 && fields[1].matches("^\\d{1,2}$")) {
|
||||
return Integer.parseInt(fields[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드.
|
||||
*
|
||||
* ※ UI에서 다운로드 버튼은 제거되었으나(요청사항), 백엔드 기능은 유지한다.
|
||||
* 외부/직접 호출로 재사용될 수 있어 엔드포인트(/statistics/api/download)와 함께 남겨둔다.
|
||||
*/
|
||||
public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||
log.debug("[통계] CSV 다운로드 - orgId={}, mode={}", orgId, searchDto.getMode());
|
||||
ApiStatisticsResultDto result = getStatistics(orgId, searchDto);
|
||||
|
||||
String fileName = String.format("API_Statistics_%s_%s.csv",
|
||||
searchDto.getStartDate().toString(),
|
||||
searchDto.getEndDate().toString());
|
||||
String rangeLabel = searchDto.isMonthly()
|
||||
? searchDto.getMonth()
|
||||
: searchDto.getStartDate() + " ~ " + searchDto.getEndDate();
|
||||
|
||||
String fileName = String.format("API_Statistics_%s.csv",
|
||||
rangeLabel.replace(" ", "").replace("~", "_"));
|
||||
|
||||
response.setContentType("text/csv; charset=UTF-8");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
|
||||
@@ -114,53 +473,52 @@ public class ApiStatisticsService {
|
||||
response.getOutputStream().write(0xBF);
|
||||
|
||||
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) {
|
||||
// 조회 조건
|
||||
writer.println("조회 기간," + searchDto.getStartDate() + " ~ " + searchDto.getEndDate());
|
||||
writer.println("조회 기간," + rangeLabel);
|
||||
|
||||
String appName = "전체 앱";
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
appName = credentialRepository.findById(searchDto.getClientId())
|
||||
.map(Credential::getClientname)
|
||||
appName = gwAuthClientRepository.findById(searchDto.getClientId())
|
||||
.map(GwAuthClient::getClientName)
|
||||
.orElse("선택된 앱");
|
||||
}
|
||||
writer.println("앱 구분," + appName);
|
||||
writer.println();
|
||||
|
||||
// 전체 통계
|
||||
ApiStatisticsSummaryDto summary = result.getSummary();
|
||||
writer.println("[전체 통계]");
|
||||
writer.println("총 호출 건," + summary.getTotalAttempted());
|
||||
writer.println("성공 건," + summary.getTotalCompleted() + "," + summary.getSuccessRate() + "%");
|
||||
writer.println("실패 건," + summary.getTotalFailed() + "," + summary.getFailureRate() + "%");
|
||||
writer.println("총 호출 건," + summary.getTotalCount());
|
||||
writer.println("성공 건," + summary.getSuccessCount() + "," + summary.getSuccessRate() + "%");
|
||||
writer.println("타임아웃 건," + summary.getTimeoutCount() + "," + summary.getTimeoutRate() + "%");
|
||||
writer.println("실패 건," + summary.getErrorCount() + "," + summary.getErrorRate() + "%");
|
||||
writer.println();
|
||||
|
||||
// API별 통계 헤더
|
||||
writer.println("[API별 통계]");
|
||||
writer.println("API명,호출 건,성공 건,성공율,실패 건,실패율");
|
||||
|
||||
// API별 통계 데이터
|
||||
writer.println("API명,호출 건,성공 건,타임아웃 건,실패 건");
|
||||
for (ApiStatisticsDetailDto detail : result.getDetails()) {
|
||||
writer.println(String.format("%s,%d,%d,%s%%,%d,%s%%",
|
||||
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||
escapeCsv(detail.getApiName()),
|
||||
detail.getAttemptedCount(),
|
||||
detail.getCompletedCount(),
|
||||
detail.getSuccessRate(),
|
||||
detail.getFailedCount(),
|
||||
detail.getFailureRate()));
|
||||
detail.getTotalCount(),
|
||||
detail.getSuccessCount(),
|
||||
detail.getTimeoutCount(),
|
||||
detail.getErrorCount()));
|
||||
}
|
||||
writer.println();
|
||||
|
||||
// 전체 합계 행
|
||||
writer.println(String.format("전체,%d,%d,%s%%,%d,%s%%",
|
||||
summary.getTotalAttempted(),
|
||||
summary.getTotalCompleted(),
|
||||
summary.getSuccessRate(),
|
||||
summary.getTotalFailed(),
|
||||
summary.getFailureRate()));
|
||||
writer.println("[기간별 통계]");
|
||||
writer.println("기간,호출 건,성공 건,타임아웃 건,실패 건");
|
||||
for (ApiStatisticsPeriodDto period : result.getPeriods()) {
|
||||
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||
escapeCsv(period.getPeriod()),
|
||||
period.getTotalCount(),
|
||||
period.getSuccessCount(),
|
||||
period.getTimeoutCount(),
|
||||
period.getErrorCount()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 값 이스케이프 (쉼표, 따옴표 처리)
|
||||
* CSV 값 이스케이프 (쉼표, 따옴표 처리).
|
||||
*/
|
||||
private String escapeCsv(String value) {
|
||||
if (value == null) return "";
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
public class GlobalControllerAdvice {
|
||||
@@ -27,6 +28,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private ClientGuardService clientGuardService;
|
||||
|
||||
@Autowired
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@@ -93,4 +97,14 @@ public class GlobalControllerAdvice {
|
||||
public boolean clientGuardDevtools() {
|
||||
return clientGuardService.isDevtoolsGuardEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
||||
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
||||
*/
|
||||
@ModelAttribute("customerCenterContact")
|
||||
public String customerCenterContact() {
|
||||
return portalPropertyService.getOrCreateProperty(
|
||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ public class BaseDatasourceConfiguration {
|
||||
|
||||
return builder
|
||||
.dataSource(dataSource)
|
||||
.packages(prop.getEntityPackage())
|
||||
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
||||
.packages(prop.getEntityPackage().split("\\s*,\\s*"))
|
||||
.persistenceUnit(persistenceUnit)
|
||||
.properties(properties)
|
||||
.build();
|
||||
|
||||
@@ -35,7 +35,7 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
|
||||
portal:
|
||||
|
||||
@@ -46,5 +46,5 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ gateway:
|
||||
|
||||
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
|
||||
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
|
||||
entity-package: com.eactive.eai.data.entity.onl
|
||||
entity-package: com.eactive.eai.data.entity.onl,com.eactive.apim.gateway.data.statistics.entity
|
||||
|
||||
portal:
|
||||
file:
|
||||
|
||||
@@ -1,18 +1,74 @@
|
||||
/* API Statistics Page Styles */
|
||||
|
||||
.api-statistics-container {
|
||||
/*max-width: 1200px;*/
|
||||
margin: 0 auto;
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -54,16 +110,13 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-search:hover {
|
||||
background: #003d99;
|
||||
}
|
||||
|
||||
.btn-search svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
/* Summary Card */
|
||||
.statistics-summary-card {
|
||||
background: #f5f8fc;
|
||||
border-radius: 16px;
|
||||
@@ -103,11 +156,9 @@
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-select:hover {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
|
||||
.app-select:focus {
|
||||
border-color: #003d99;
|
||||
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.1);
|
||||
@@ -122,7 +173,6 @@
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-total-text strong {
|
||||
font-size: 24px;
|
||||
color: #0049B4;
|
||||
@@ -140,11 +190,12 @@
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-detail-item.success .detail-icon {
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.summary-detail-item.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
.summary-detail-item.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -163,7 +214,6 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Donut Chart */
|
||||
.summary-center {
|
||||
flex: 0 0 200px;
|
||||
display: flex;
|
||||
@@ -180,7 +230,6 @@
|
||||
transition: stroke-dasharray 0.3s ease, stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
/* Rate Display */
|
||||
.summary-right {
|
||||
flex: 0 0 230px;
|
||||
display: flex;
|
||||
@@ -195,7 +244,6 @@
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.rate-display:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -205,11 +253,12 @@
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rate-indicator.success {
|
||||
background: #4A90D9;
|
||||
}
|
||||
|
||||
.rate-indicator.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
.rate-indicator.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -228,6 +277,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -237,12 +290,15 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Statistics Table Card */
|
||||
.statistics-table-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.statistics-table-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -262,7 +318,6 @@
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-title svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
@@ -279,7 +334,6 @@
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
@@ -293,7 +347,6 @@
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.statistics-table thead th {
|
||||
padding: 16px 20px;
|
||||
text-align: left;
|
||||
@@ -302,16 +355,23 @@
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.statistics-table tbody td {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.statistics-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.statistics-table tfoot .total-row {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
.statistics-table tfoot .total-row td {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
color: #0049B4;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-name {
|
||||
display: flex;
|
||||
@@ -328,20 +388,16 @@
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.api-icon svg {
|
||||
stroke: #666;
|
||||
}
|
||||
|
||||
.api-icon.total {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.api-icon.total svg {
|
||||
stroke: #0049B4;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -359,83 +415,64 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Total Row */
|
||||
.statistics-table tfoot .total-row {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.statistics-table tfoot .total-row td {
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
color: #0049B4;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.summary-content {
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.summary-left,
|
||||
.summary-center,
|
||||
.summary-right {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.summary-center {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.summary-right {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-statistics-container {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.statistics-summary-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.app-toggle-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statistics-table thead th,
|
||||
.statistics-table tbody td,
|
||||
.statistics-table tfoot td {
|
||||
padding: 12px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -19148,12 +19148,72 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -19278,6 +19338,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.summary-detail-item.success .detail-icon {
|
||||
color: #4A90D9;
|
||||
}
|
||||
.summary-detail-item.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
.summary-detail-item.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -19338,6 +19401,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.rate-indicator.success {
|
||||
background: #4A90D9;
|
||||
}
|
||||
.rate-indicator.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
.rate-indicator.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -19356,6 +19422,10 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -19370,6 +19440,10 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.statistics-table-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -19486,6 +19560,11 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,13 +11,77 @@
|
||||
padding: 48px 0px;
|
||||
}
|
||||
|
||||
// 집계 안내 문구
|
||||
.statistics-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 18px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #d6e4f5;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.statistics-notice-latest {
|
||||
color: #0049B4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Search Section
|
||||
.statistics-search-section {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
// 일별/월별 모드 토글
|
||||
.search-mode-tabs {
|
||||
display: inline-flex;
|
||||
background: #eef1f5;
|
||||
border-radius: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 22px;
|
||||
border-radius: 18px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #0049B4;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 월 선택 (월별 모드)
|
||||
.month-range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,6 +213,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
&.timeout .detail-icon {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
&.failure .detail-icon {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -214,6 +282,10 @@
|
||||
background: #4A90D9;
|
||||
}
|
||||
|
||||
&.timeout {
|
||||
background: #F5C36B;
|
||||
}
|
||||
|
||||
&.failure {
|
||||
background: #F5A3B5;
|
||||
}
|
||||
@@ -233,6 +305,10 @@
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.timeout-rate .rate-value {
|
||||
color: #F5C36B;
|
||||
}
|
||||
|
||||
.failure-rate .rate-value {
|
||||
color: #F5A3B5;
|
||||
}
|
||||
@@ -248,6 +324,11 @@
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -377,6 +458,11 @@
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-timeout {
|
||||
background: #F5C36B;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-failure {
|
||||
background: #F5A3B5;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
@@ -14,16 +14,41 @@
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
|
||||
<section class="api-statistics-container">
|
||||
|
||||
<!-- 집계 안내 문구 (집계 주기 분 + 최신 집계 시각, 서버 계산값) -->
|
||||
<div class="statistics-notice">
|
||||
<span th:if="${statsAggregationMinute != null}"
|
||||
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
|
||||
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
|
||||
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
|
||||
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
|
||||
</div>
|
||||
|
||||
<!-- Search Filter -->
|
||||
<div class="statistics-search-section">
|
||||
<div class="search-mode-tabs">
|
||||
<button type="button" class="mode-tab active" data-mode="DAILY">일별</button>
|
||||
<button type="button" class="mode-tab" data-mode="MONTHLY">월별</button>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<div class="date-range-picker">
|
||||
<!-- 일별: 날짜 범위 -->
|
||||
<div class="date-range-picker" id="dailyFilter">
|
||||
<input type="date" id="startDate" class="date-input"
|
||||
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||
<span class="date-separator">-</span>
|
||||
<input type="date" id="endDate" class="date-input"
|
||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||
</div>
|
||||
|
||||
<!-- 월별: 특정 년월 단일 선택 (가용월 목록) -->
|
||||
<div class="month-range-picker" id="monthlyFilter" style="display:none;">
|
||||
<select id="month" class="month-input">
|
||||
<option th:each="m,st : ${availableMonths}" th:value="${m}"
|
||||
th:text="${#strings.substring(m,0,4) + '-' + #strings.substring(m,4,6)}"
|
||||
th:selected="${st.index == 0}">2026-07</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-search" id="btnSearch">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
@@ -42,9 +67,9 @@
|
||||
<select id="appSelect" class="app-select">
|
||||
<option value="">전체 앱</option>
|
||||
<option th:each="app : ${appList}"
|
||||
th:value="${app.clientid}"
|
||||
th:text="${app.clientname}"
|
||||
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientid}">
|
||||
th:value="${app.clientId}"
|
||||
th:text="${app.clientName}"
|
||||
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
|
||||
앱 이름
|
||||
</option>
|
||||
</select>
|
||||
@@ -52,46 +77,55 @@
|
||||
|
||||
<div class="summary-info">
|
||||
<p class="summary-total-text">
|
||||
총 API 호출 수는 <strong id="totalAttempted" th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</strong>건입니다.
|
||||
총 API 호출 수는 <strong id="totalCount" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
|
||||
</p>
|
||||
<div class="summary-detail-list">
|
||||
<div class="summary-detail-item success">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">성공</span>
|
||||
<span class="detail-value" id="totalCompleted" th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="successRateText" th:text="${summary.successRate}">0</span>%)</span>
|
||||
<span class="detail-value" id="successCount" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
|
||||
</div>
|
||||
<div class="summary-detail-item timeout">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">타임아웃</span>
|
||||
<span class="detail-value" id="timeoutCount" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
|
||||
</div>
|
||||
<div class="summary-detail-item failure">
|
||||
<span class="detail-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
|
||||
</span>
|
||||
<span class="detail-label">실패</span>
|
||||
<span class="detail-value" id="totalFailed" th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="failureRateText" th:text="${summary.failureRate}">0</span>%)</span>
|
||||
<span class="detail-value" id="errorCount" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
|
||||
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Donut Chart (SVG) -->
|
||||
<!-- Center: Donut Chart (SVG, 3 segments) -->
|
||||
<div class="summary-center">
|
||||
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
|
||||
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24"/>
|
||||
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#4A90D9" stroke-width="24" stroke-linecap="round"
|
||||
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutFailure" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5A3B5" stroke-width="24" stroke-linecap="round"
|
||||
th:attr="stroke-dasharray=${summary.failureRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset="0" transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset='-' + ${summary.successRate * 4.398}"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none"
|
||||
stroke="#F5A3B5" stroke-width="24" stroke-linecap="butt"
|
||||
th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
|
||||
stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
|
||||
transform="rotate(-90 100 100)"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -100,13 +134,19 @@
|
||||
<div class="rate-display success-rate">
|
||||
<span class="rate-indicator success"></span>
|
||||
<span class="rate-label">성공률</span>
|
||||
<span class="rate-value" id="successRate" th:text="${summary.successRate}">0</span>
|
||||
<span class="rate-value" id="successRate" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
<div class="rate-display timeout-rate">
|
||||
<span class="rate-indicator timeout"></span>
|
||||
<span class="rate-label">타임아웃율</span>
|
||||
<span class="rate-value" id="timeoutRate" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
<div class="rate-display failure-rate">
|
||||
<span class="rate-indicator failure"></span>
|
||||
<span class="rate-label">실패률</span>
|
||||
<span class="rate-value" id="failureRate" th:text="${summary.failureRate}">0</span>
|
||||
<span class="rate-value" id="errorRate" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
|
||||
<span class="rate-percent">%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,20 +158,11 @@
|
||||
<div class="table-header">
|
||||
<h3 class="table-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="M18 9l-5 5-4-4-4 4"/>
|
||||
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
API별 통계
|
||||
</h3>
|
||||
<a th:href="@{/statistics/api/download(startDate=${searchDto.startDate},endDate=${searchDto.endDate},clientId=${searchDto.clientId})}"
|
||||
class="btn-download" id="btnDownload">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
다운로드
|
||||
</a>
|
||||
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
@@ -141,6 +172,7 @@
|
||||
<th>API명</th>
|
||||
<th>API호출수</th>
|
||||
<th>성공</th>
|
||||
<th>타임아웃</th>
|
||||
<th>실패</th>
|
||||
<th>통계</th>
|
||||
</tr>
|
||||
@@ -155,19 +187,20 @@
|
||||
</span>
|
||||
<span th:text="${detail.apiName}">API 이름</span>
|
||||
</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.attemptedCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.completedCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.failedCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
|
||||
<td class="progress-cell">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${detail.failureRate} + '%'"></div>
|
||||
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Empty State -->
|
||||
<tr th:if="${details == null or details.isEmpty()}" class="empty-row">
|
||||
<td colspan="5">데이터가 없습니다.</td>
|
||||
<td colspan="6">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="statisticsTableFoot" th:if="${details != null and !details.isEmpty()}">
|
||||
@@ -175,19 +208,20 @@
|
||||
<td class="api-name">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="M18 9l-5 5-4-4-4 4"/>
|
||||
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>전체</span>
|
||||
</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.totalAttempted, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.totalCompleted, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.totalFailed, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
|
||||
<td th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
|
||||
<td class="progress-cell">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${summary.failureRate} + '%'"></div>
|
||||
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%'"></div>
|
||||
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -196,59 +230,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기간별(일별/월별) 누적 통계 -->
|
||||
<div class="statistics-table-card">
|
||||
<div class="table-header">
|
||||
<h3 class="table-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>
|
||||
</svg>
|
||||
<span id="periodTableTitle">일별 통계</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="statistics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="periodLabel">일자</th>
|
||||
<th>API호출수</th>
|
||||
<th>성공</th>
|
||||
<th>타임아웃</th>
|
||||
<th>실패</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="periodTableBody">
|
||||
<tr th:each="p : ${periods}">
|
||||
<td th:text="${p.period}">2026-07-15 (수)</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
|
||||
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
|
||||
</tr>
|
||||
<tr th:if="${periods == null or periods.isEmpty()}" class="empty-row">
|
||||
<td colspan="5">데이터가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function() {
|
||||
// Initial data from server
|
||||
let currentData = {
|
||||
summary: {
|
||||
totalAttempted: [[${summary.totalAttempted}]],
|
||||
totalCompleted: [[${summary.totalCompleted}]],
|
||||
totalFailed: [[${summary.totalFailed}]],
|
||||
totalCount: [[${summary.totalCount}]],
|
||||
successCount: [[${summary.successCount}]],
|
||||
timeoutCount: [[${summary.timeoutCount}]],
|
||||
errorCount: [[${summary.errorCount}]],
|
||||
successRate: [[${summary.successRate}]],
|
||||
failureRate: [[${summary.failureRate}]]
|
||||
timeoutRate: [[${summary.timeoutRate}]],
|
||||
errorRate: [[${summary.errorRate}]]
|
||||
},
|
||||
details: [[${details}]]
|
||||
details: [[${details}]],
|
||||
periods: [[${periods}]]
|
||||
};
|
||||
|
||||
let selectedClientId = [[${searchDto.clientId}]] || '';
|
||||
let currentMode = 'DAILY';
|
||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||
const MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
// Update SVG Donut Chart
|
||||
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||
function updateChart() {
|
||||
var successRate = currentData.summary.successRate || 0;
|
||||
var failureRate = currentData.summary.failureRate || 0;
|
||||
|
||||
var successLength = successRate * CIRCUMFERENCE / 100;
|
||||
var failureLength = failureRate * CIRCUMFERENCE / 100;
|
||||
|
||||
$('#donutSuccess').attr('stroke-dasharray', successLength + ' ' + CIRCUMFERENCE);
|
||||
$('#donutFailure').attr('stroke-dasharray', failureLength + ' ' + CIRCUMFERENCE);
|
||||
$('#donutFailure').attr('stroke-dashoffset', -successLength);
|
||||
var s = currentData.summary.successRate || 0;
|
||||
var t = currentData.summary.timeoutRate || 0;
|
||||
var e = currentData.summary.errorRate || 0;
|
||||
var sl = s * CIRCUMFERENCE / 100;
|
||||
var tl = t * CIRCUMFERENCE / 100;
|
||||
var el = e * CIRCUMFERENCE / 100;
|
||||
$('#donutSuccess').attr('stroke-dasharray', sl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', 0);
|
||||
$('#donutTimeout').attr('stroke-dasharray', tl + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -sl);
|
||||
$('#donutError').attr('stroke-dasharray', el + ' ' + CIRCUMFERENCE).attr('stroke-dashoffset', -(sl + tl));
|
||||
}
|
||||
|
||||
// Update Summary Display
|
||||
// ── Summary ──
|
||||
function updateSummaryDisplay() {
|
||||
$('#totalAttempted').text(numberWithCommas(currentData.summary.totalAttempted));
|
||||
$('#totalCompleted').text(numberWithCommas(currentData.summary.totalCompleted));
|
||||
$('#totalFailed').text(numberWithCommas(currentData.summary.totalFailed));
|
||||
$('#successRate, #successRateText').text(currentData.summary.successRate);
|
||||
$('#failureRate, #failureRateText').text(currentData.summary.failureRate);
|
||||
var sm = currentData.summary;
|
||||
var t = sm.totalCount;
|
||||
$('#totalCount').text(numberWithCommas(sm.totalCount));
|
||||
$('#successCount').text(numberWithCommas(sm.successCount));
|
||||
$('#timeoutCount').text(numberWithCommas(sm.timeoutCount));
|
||||
$('#errorCount').text(numberWithCommas(sm.errorCount));
|
||||
$('#successRate, #successRateText').text(dashRate(t, sm.successRate));
|
||||
$('#timeoutRate, #timeoutRateText').text(dashRate(t, sm.timeoutRate));
|
||||
$('#errorRate, #errorRateText').text(dashRate(t, sm.errorRate));
|
||||
}
|
||||
|
||||
// Update Table
|
||||
// ── API별 표 ──
|
||||
function updateTable() {
|
||||
const tbody = $('#statisticsTableBody');
|
||||
const tfoot = $('#statisticsTableFoot');
|
||||
tbody.empty();
|
||||
|
||||
if (currentData.details && currentData.details.length > 0) {
|
||||
currentData.details.forEach(function(detail) {
|
||||
const row = `
|
||||
currentData.details.forEach(function(d) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td class="api-name">
|
||||
<span class="api-icon">
|
||||
@@ -256,166 +333,163 @@
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>${detail.apiName || '-'}</span>
|
||||
<span>${d.apiName || '-'}</span>
|
||||
</td>
|
||||
<td>${numberWithCommas(detail.attemptedCount)}</td>
|
||||
<td>${numberWithCommas(detail.completedCount)}</td>
|
||||
<td>${numberWithCommas(detail.failedCount)}</td>
|
||||
<td>${numberWithCommas(d.totalCount)}</td>
|
||||
<td>${numberWithCommas(d.successCount)}</td>
|
||||
<td>${numberWithCommas(d.timeoutCount)}</td>
|
||||
<td>${numberWithCommas(d.errorCount)}</td>
|
||||
<td class="progress-cell">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-success" style="width:${detail.successRate}%"></div>
|
||||
<div class="progress-failure" style="width:${detail.failureRate}%"></div>
|
||||
<div class="progress-success" style="width:${d.successRate}%"></div>
|
||||
<div class="progress-timeout" style="width:${d.timeoutRate}%"></div>
|
||||
<div class="progress-failure" style="width:${d.errorRate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.append(row);
|
||||
</tr>`);
|
||||
});
|
||||
renderTotalFooter();
|
||||
} else {
|
||||
tbody.html('<tr class="empty-row"><td colspan="6">데이터가 없습니다.</td></tr>');
|
||||
$('#statisticsTableFoot').remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update footer
|
||||
if (tfoot.length === 0) {
|
||||
const footerHtml = `
|
||||
<tfoot id="statisticsTableFoot">
|
||||
<tr class="total-row">
|
||||
<td class="api-name">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>전체</span>
|
||||
</td>
|
||||
<td>${numberWithCommas(currentData.summary.totalAttempted)}</td>
|
||||
<td>${numberWithCommas(currentData.summary.totalCompleted)}</td>
|
||||
<td>${numberWithCommas(currentData.summary.totalFailed)}</td>
|
||||
<td class="progress-cell">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-success" style="width:${currentData.summary.successRate}%"></div>
|
||||
<div class="progress-failure" style="width:${currentData.summary.failureRate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
`;
|
||||
tbody.after(footerHtml);
|
||||
} else {
|
||||
tfoot.find('td:eq(1)').text(numberWithCommas(currentData.summary.totalAttempted));
|
||||
tfoot.find('td:eq(2)').text(numberWithCommas(currentData.summary.totalCompleted));
|
||||
tfoot.find('td:eq(3)').text(numberWithCommas(currentData.summary.totalFailed));
|
||||
tfoot.find('.progress-success').css('width', currentData.summary.successRate + '%');
|
||||
tfoot.find('.progress-failure').css('width', currentData.summary.failureRate + '%');
|
||||
}
|
||||
function renderTotalFooter() {
|
||||
var sm = currentData.summary;
|
||||
var footerHtml = `
|
||||
<tfoot id="statisticsTableFoot">
|
||||
<tr class="total-row">
|
||||
<td class="api-name">
|
||||
<span class="api-icon total">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>전체</span>
|
||||
</td>
|
||||
<td>${numberWithCommas(sm.totalCount)}</td>
|
||||
<td>${numberWithCommas(sm.successCount)}</td>
|
||||
<td>${numberWithCommas(sm.timeoutCount)}</td>
|
||||
<td>${numberWithCommas(sm.errorCount)}</td>
|
||||
<td class="progress-cell">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-success" style="width:${sm.successRate}%"></div>
|
||||
<div class="progress-timeout" style="width:${sm.timeoutRate}%"></div>
|
||||
<div class="progress-failure" style="width:${sm.errorRate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>`;
|
||||
$('#statisticsTableFoot').remove();
|
||||
$('#statisticsTableBody').after(footerHtml);
|
||||
}
|
||||
|
||||
// ── 기간별(일/월) 표 ──
|
||||
function updatePeriodTable() {
|
||||
const tbody = $('#periodTableBody');
|
||||
tbody.empty();
|
||||
if (currentData.periods && currentData.periods.length > 0) {
|
||||
currentData.periods.forEach(function(p) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${p.period || '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
|
||||
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
|
||||
</tr>`);
|
||||
});
|
||||
} else {
|
||||
tbody.html('<tr class="empty-row"><td colspan="5">데이터가 없습니다.</td></tr>');
|
||||
tfoot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update Download Link
|
||||
function updateDownloadLink() {
|
||||
const startDate = $('#startDate').val();
|
||||
const endDate = $('#endDate').val();
|
||||
let downloadUrl = `/statistics/api/download?startDate=${startDate}&endDate=${endDate}`;
|
||||
if (selectedClientId) {
|
||||
downloadUrl += `&clientId=${selectedClientId}`;
|
||||
}
|
||||
$('#btnDownload').attr('href', downloadUrl);
|
||||
}
|
||||
|
||||
// Number formatting
|
||||
function numberWithCommas(x) {
|
||||
if (x === null || x === undefined) return '0';
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// 데이터 없음(해당 total 0)일 때: 건수는 0(그대로) 표기, 비율(%)만 '-' 로 표기
|
||||
function dashRate(total, val) {
|
||||
return (!total || total == 0) ? '-' : val;
|
||||
}
|
||||
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="_csrf"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
// Validate date range (max 40 days)
|
||||
function validateDateRange(startDate, endDate) {
|
||||
if (!startDate || !endDate) {
|
||||
return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
}
|
||||
var start = new Date(startDate);
|
||||
var end = new Date(endDate);
|
||||
if (start > end) {
|
||||
return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
}
|
||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
var start = new Date(startDate), end = new Date(endDate);
|
||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) {
|
||||
return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
}
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Search Statistics
|
||||
function searchStatistics() {
|
||||
const startDate = $('#startDate').val();
|
||||
const endDate = $('#endDate').val();
|
||||
function buildPayload() {
|
||||
return {
|
||||
mode: currentMode,
|
||||
startDate: $('#startDate').val(),
|
||||
endDate: $('#endDate').val(),
|
||||
month: $('#month').val() || null,
|
||||
clientId: selectedClientId || null
|
||||
};
|
||||
}
|
||||
|
||||
// Client-side validation
|
||||
var validation = validateDateRange(startDate, endDate);
|
||||
if (!validation.valid) {
|
||||
alert(validation.message);
|
||||
return;
|
||||
function searchStatistics() {
|
||||
if (currentMode === 'MONTHLY') {
|
||||
if (!$('#month').val()) { alert('조회할 월을 선택해주세요.'); return; }
|
||||
} else {
|
||||
var v = validateDateRange($('#startDate').val(), $('#endDate').val());
|
||||
if (!v.valid) { alert(v.message); return; }
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/statistics/api/search',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
beforeSend: function(xhr) {
|
||||
xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken());
|
||||
},
|
||||
data: JSON.stringify({
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
clientId: selectedClientId || null
|
||||
}),
|
||||
beforeSend: function(xhr) { xhr.setRequestHeader('X-XSRF-TOKEN', getCsrfToken()); },
|
||||
data: JSON.stringify(buildPayload()),
|
||||
success: function(data) {
|
||||
currentData = data;
|
||||
updateSummaryDisplay();
|
||||
updateChart();
|
||||
updateTable();
|
||||
updateDownloadLink();
|
||||
updatePeriodTable();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Search failed:', error);
|
||||
error: function(xhr) {
|
||||
var errorMsg = '통계 조회에 실패했습니다.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
errorMsg = xhr.responseJSON.error;
|
||||
}
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) errorMsg = xhr.responseJSON.error;
|
||||
alert(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
$('#btnSearch').on('click', function() {
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
// App selection change
|
||||
$('#appSelect').on('change', function() {
|
||||
selectedClientId = $(this).val();
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
// Enter key search
|
||||
$('#startDate, #endDate').on('keypress', function(e) {
|
||||
if (e.which === 13) {
|
||||
searchStatistics();
|
||||
// ── 모드 토글 ──
|
||||
$('.mode-tab').on('click', function() {
|
||||
currentMode = $(this).data('mode');
|
||||
$('.mode-tab').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
// 하단 기간표는 월별 조회에서도 '일별'로 표기하므로 라벨은 항상 일자 유지
|
||||
if (currentMode === 'MONTHLY') {
|
||||
$('#dailyFilter').hide();
|
||||
$('#monthlyFilter').show();
|
||||
} else {
|
||||
$('#monthlyFilter').hide();
|
||||
$('#dailyFilter').show();
|
||||
}
|
||||
});
|
||||
|
||||
// Date change auto search
|
||||
$('#startDate, #endDate').on('change', function() {
|
||||
searchStatistics();
|
||||
});
|
||||
|
||||
$('#btnSearch').on('click', searchStatistics);
|
||||
$('#appSelect').on('change', function() { selectedClientId = $(this).val(); searchStatistics(); });
|
||||
$('#startDate, #endDate').on('change', searchStatistics);
|
||||
$('#month').on('change', searchStatistics);
|
||||
$('#startDate, #endDate').on('keypress', function(e) { if (e.which === 13) searchStatistics(); });
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<option>DJBank 모바일뱅킹</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="footer-contact">고객센터 1588-3388</p>
|
||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user