API 통계 도메인 구조 개편 및 조회 기능 확장:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- API 통계 엔티티/리포지토리 다층 나눔 (분/시/일/월)
- API 상세/요약 통계 및 기간별 누적 통계 DTO 추가
- 고객센터 연락처 조회 로직 추가 (@ModelAttribute) - 글로벌 푸터 적용
- @EMSDataSource 지원 엔티티-패키지 다중 검색 적용
- 기존 ApiStatisticsRepository 삭제, 분리된 구성으로 대체
This commit is contained in:
Rinjae
2026-07-16 11:20:44 +09:00
parent 227d4d8abd
commit 1cb9606602
34 changed files with 1775 additions and 462 deletions
@@ -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; // 인터페이스 표시 명칭
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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();
@@ -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;
}
}
}
@@ -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<>()
);
}
@@ -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}$");
}
}
@@ -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;
}
@@ -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);
}
@@ -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;
}
@@ -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();
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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;
}
+80 -1
View File
@@ -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>