Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f581cae23 | |||
| 923bf56e21 | |||
| 1cb9606602 | |||
| 227d4d8abd | |||
| 4395757d98 | |||
| 778d579ada | |||
| 3e403788e5 | |||
| 6c5ba6765d | |||
| a97378b84d | |||
| 9c01db9d00 |
@@ -108,3 +108,5 @@ diff
|
||||
|
||||
*rinjae*
|
||||
*gf63*
|
||||
*obsidian*
|
||||
design-backup
|
||||
@@ -30,9 +30,12 @@ dependencies {
|
||||
|
||||
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||
|
||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
||||
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
|
||||
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
|
||||
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
|
||||
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
|
||||
|
||||
implementation project(':elink-online-core-jpa')
|
||||
implementation project(':elink-portal-common')
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =============================================================================
|
||||
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
|
||||
--
|
||||
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
|
||||
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
|
||||
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
|
||||
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
|
||||
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
|
||||
--
|
||||
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
|
||||
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
|
||||
|
||||
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
|
||||
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
|
||||
|
||||
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
|
||||
|
||||
COMMIT;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TestbedSpecController {
|
||||
|
||||
@Autowired
|
||||
private ApiSpecInfoService apiSpecInfoService;
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id) {
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
try {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||||
return ResponseEntity.ok().body(content);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read default token api spec file", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
|
||||
if (!spec.isPresent()) {
|
||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
||||
|
||||
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -20,6 +21,20 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
|
||||
this.gatewayProperty = gatewayProperty;
|
||||
}
|
||||
|
||||
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
|
||||
private void applyTimeout(HttpURLConnection connection) {
|
||||
int t = gatewayProperty.timeoutMillis();
|
||||
connection.setConnectTimeout(t);
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
@@ -58,6 +73,7 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -82,6 +98,7 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("POST");
|
||||
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -125,10 +142,11 @@ public class APISender {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
URL endpoint = new URL(uri);
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.annotation.WebFilter;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -65,7 +68,23 @@ public class ApiTesterFilter implements Filter {
|
||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||
String url = httpServletRequest.getHeader("original-url");
|
||||
|
||||
if (url.contains("/api/v1/oauth/token")){
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
if (tokenRequest) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
@@ -74,82 +93,174 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
String body = sb.toString();
|
||||
|
||||
// Parse request parameters
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(tokenResponse);
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
|
||||
} else {
|
||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||
|
||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String responseType = apiSpecInfoDto.getResponseType();
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
return;
|
||||
}
|
||||
|
||||
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
}
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = url;
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
||||
String method = apiSpecInfoDto.getApiMethod();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
targetUri = apiSpecInfoDto.getMockUrl();
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
String[] parts = originalUrl.split("\\?");
|
||||
if (parts.length > 1) {
|
||||
for (String param : parts[1].split("&")) {
|
||||
String[] kv = param.split("=");
|
||||
if (kv.length > 1) {
|
||||
paramMap.put(kv[0], new String[]{kv[1]});
|
||||
}
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
|
||||
String originalUrl = headers.get("original-url");
|
||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
||||
|
||||
String[] originalUrlArr = originalUrl.split("\\?");
|
||||
if (originalUrlArr.length > 1) {
|
||||
String[] paramArr = originalUrlArr[1].split("&");
|
||||
for (String param : paramArr) {
|
||||
String[] paramKeyValue = param.split("=");
|
||||
if (paramKeyValue.length > 1) {
|
||||
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
|
||||
String responseStr = "";
|
||||
if (method.equalsIgnoreCase("post")) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
||||
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(json);
|
||||
}
|
||||
|
||||
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
|
||||
private String escapeJson(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -40,6 +40,7 @@ public class PartnershipApplicationController {
|
||||
|
||||
model.addAttribute("partnership", new PartnershipApplicationDTO());
|
||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||
model.addAttribute("recentApplications", partnershipApplicationFacade.getMyRecentApplications());
|
||||
return "apps/community/mainPartnershipForm";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.eactive.apim.portal.apps.community.partnership.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 작성폼 상단 "내가 작성한 최근 글" 아코디언에 노출하기 위한 조회 전용 DTO.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PartnershipApplicationSummaryDTO {
|
||||
|
||||
private String id;
|
||||
|
||||
private String bizSubject;
|
||||
|
||||
private String bizDetail;
|
||||
|
||||
private LocalDateTime createdDate;
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.eactive.apim.portal.apps.community.partnership.mapper;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||
import com.eactive.apim.portal.common.mapper.CommonMapper;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import java.util.List;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -16,4 +18,8 @@ import org.springframework.stereotype.Component;
|
||||
public interface PartnershipApplicationMapper {
|
||||
|
||||
PartnershipApplication toEntity(PartnershipApplicationDTO dto);
|
||||
|
||||
PartnershipApplicationSummaryDTO toSummaryDto(PartnershipApplication entity);
|
||||
|
||||
List<PartnershipApplicationSummaryDTO> toSummaryDtoList(List<PartnershipApplication> entities);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
|
||||
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -9,4 +10,10 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
public interface PartnershipApplicationRepository extends JpaRepository<PartnershipApplication, String>, JpaSpecificationExecutor<PartnershipApplication> {
|
||||
|
||||
/**
|
||||
* 특정 작성자(createdBy)가 등록한 최근 3건을 최신순으로 조회한다.
|
||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||
*/
|
||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.community.partnership.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public interface PartnershipApplicationFacade {
|
||||
|
||||
void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException;
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||
*/
|
||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.community.partnership.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationSummaryDTO;
|
||||
import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipApplicationMapper;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -15,7 +16,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@@ -60,4 +63,14 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
}
|
||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PartnershipApplicationSummaryDTO> getMyRecentApplications() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
||||
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -21,4 +22,12 @@ public class PartnershipApplicationService {
|
||||
public void createPartnershipApplication(PartnershipApplication partnershipApplication) {
|
||||
partnershipApplicationRepository.save(partnershipApplication);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작성자 id 로 최근 등록 3건 조회(최신순).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
||||
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -16,11 +17,13 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import javax.validation.Valid;
|
||||
@@ -56,6 +59,7 @@ public class InquiryController {
|
||||
model.addAttribute("inquiries", inquiries.getContent());
|
||||
model.addAttribute("page", inquiries);
|
||||
model.addAttribute("commentCounts", commentCounts);
|
||||
model.addAttribute("visibilityCeiling", inquiryFacade.getVisibilityCeiling());
|
||||
return "apps/community/mainInquiryList";
|
||||
}
|
||||
|
||||
@@ -75,13 +79,25 @@ public class InquiryController {
|
||||
return "apps/community/mainInquiryDetail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-1. 조회수 증가 (sessionStorage dedup 통과 시 JS 가 POST 호출)
|
||||
*/
|
||||
@PostMapping("/{id}/view")
|
||||
public ResponseEntity<Void> increaseViewCount(@PathVariable String id) {
|
||||
inquiryFacade.incrementViewCount(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. inquiry 등록 페이지
|
||||
*/
|
||||
@GetMapping("/new")
|
||||
public String newInquiryForm(Model model) {
|
||||
model.addAttribute("inquiry", new InquiryDTO());
|
||||
InquiryDTO inquiry = new InquiryDTO();
|
||||
inquiry.setVisibility(VisibilityScope.ORG.name()); // 기본 공개범위: 법인공개
|
||||
model.addAttribute("inquiry", inquiry);
|
||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||
addVisibilityOptions(model);
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
@@ -93,14 +109,23 @@ public class InquiryController {
|
||||
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
|
||||
model.addAttribute("inquiry", inquiry);
|
||||
model.addAttribute("isInternalUser", UserTypeUtil.isCurrentUserInternal());
|
||||
addVisibilityOptions(model);
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
/** 공개범위 상한에 따른 폼 옵션 노출 플래그. PRIVATE 는 항상 허용. */
|
||||
private void addVisibilityOptions(Model model) {
|
||||
VisibilityScope ceiling = inquiryFacade.getVisibilityCeiling();
|
||||
model.addAttribute("allowAll", ceiling.width() >= VisibilityScope.ALL.width());
|
||||
model.addAttribute("allowOrg", ceiling.width() >= VisibilityScope.ORG.width());
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. inquiry 등록 요청
|
||||
*/
|
||||
@PostMapping
|
||||
public String createInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Model model) throws IOException {
|
||||
if (bindingResult.hasErrors()) {
|
||||
@@ -108,7 +133,7 @@ public class InquiryController {
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
|
||||
inquiryFacade.createInquiry(inquiryDTO);
|
||||
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||
return REDIRECT_INQUIRY;
|
||||
@@ -119,11 +144,12 @@ public class InquiryController {
|
||||
*/
|
||||
@PostMapping("/edit")
|
||||
public String updateInquiry(@Valid @ModelAttribute InquiryDTO inquiryDTO, BindingResult bindingResult,
|
||||
@RequestParam(value = "image", required = false) MultipartFile image,
|
||||
RedirectAttributes redirectAttributes) throws IOException {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||
}
|
||||
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO);
|
||||
inquiryFacade.updateUserInquiry(SecurityUtil.getPortalAuthenticatedUser(), inquiryDTO.getId(), inquiryDTO, image);
|
||||
redirectAttributes.addFlashAttribute("success", "Q&A 수정이 완료되었습니다.");
|
||||
return "redirect:/inquiry/detail?id=" + inquiryDTO.getId();
|
||||
}
|
||||
|
||||
@@ -51,4 +51,22 @@ public class InquiryDTO {
|
||||
|
||||
private String maskedInquirerName;
|
||||
|
||||
/** 목록 작성자 표기용 법인명(이름과 별도 줄 표기). */
|
||||
private String inquirerOrgName;
|
||||
|
||||
/** 상세 작성자 표기용 "이름 (법인명)". */
|
||||
private String inquirerDisplay;
|
||||
|
||||
/** 공개범위(ALL/ORG/PRIVATE). 미지정 시 서버에서 상한으로 clamp. */
|
||||
private String visibility;
|
||||
|
||||
/** 공개범위 표기용 한글 라벨(전체공개/법인공개/비공개). */
|
||||
private String visibilityLabel;
|
||||
|
||||
/** 조회수(표시용). */
|
||||
private long viewCount;
|
||||
|
||||
/** 목록에서 비공개 게시물을 "비공개 게시물"로 흐리게 표기할지 여부(비-소유자). */
|
||||
private boolean privatePlaceholder;
|
||||
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public interface InquiryMapper {
|
||||
|
||||
// visibility(공개범위)는 facade 에서 상한 clamp 후 수동 세팅, viewCount 는 서버가 관리
|
||||
@Mapping(target = "visibility", ignore = true)
|
||||
@Mapping(target = "viewCount", ignore = true)
|
||||
Inquiry toEntity(InquiryDTO inquiry);
|
||||
|
||||
@Mapping(target = "inquirerName", source = "inquirer.userName")
|
||||
|
||||
@@ -3,23 +3,30 @@ package com.eactive.apim.portal.apps.community.qna.service;
|
||||
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import java.io.IOException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface InquiryFacade {
|
||||
|
||||
Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user);
|
||||
|
||||
/** 문의 공개범위 상한(폼 옵션 노출 제어용). */
|
||||
VisibilityScope getVisibilityCeiling();
|
||||
|
||||
InquiryDTO getUserInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
|
||||
|
||||
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
|
||||
void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||
|
||||
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
|
||||
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException;
|
||||
|
||||
void incrementViewCount(PortalAuthenticatedUser user, String id);
|
||||
|
||||
void deleteUserInquiry(PortalAuthenticatedUser user, String id);
|
||||
}
|
||||
|
||||
@@ -7,26 +7,41 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
private static final Set<String> IMAGE_EXTENSIONS =
|
||||
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif"));
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryMapper inquiryMapper;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
private final FileService fileService;
|
||||
|
||||
@Override
|
||||
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
|
||||
@@ -40,6 +55,8 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
Page<Inquiry> inquiriesPage = inquiryService.getInquiries(spec, pageable);
|
||||
|
||||
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||
|
||||
return inquiriesPage.map(inquiry -> {
|
||||
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||
if (dto != null && dto.getInquirer() != null) {
|
||||
@@ -48,8 +65,20 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
inquiry.getInquirer().getId(),
|
||||
user.getId()
|
||||
));
|
||||
PortalOrg org = inquiry.getInquirer().getPortalOrg();
|
||||
if (org != null) {
|
||||
dto.setInquirerOrgName(org.getOrgName());
|
||||
}
|
||||
dto.getInquirer().setUserName(null); // 원본 데이터 제거
|
||||
}
|
||||
// 비공개 게시물은 본인/같은 법인 관리자 외에는 "비공개 게시물"로만 노출
|
||||
VisibilityScope effective = VisibilityScope.clampTo(inquiry.getVisibility(), ceiling);
|
||||
dto.setVisibility(effective.name());
|
||||
dto.setVisibilityLabel(effective.label());
|
||||
if (effective == VisibilityScope.PRIVATE && !canReadPrivate(user, inquiry)) {
|
||||
dto.setPrivatePlaceholder(true);
|
||||
dto.setInquirySubject(null);
|
||||
}
|
||||
return dto;
|
||||
});
|
||||
}
|
||||
@@ -81,14 +110,32 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
@Override
|
||||
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
|
||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
|
||||
return inquiryMapper.map(inquiry);
|
||||
InquiryDTO dto = inquiryMapper.map(inquiry);
|
||||
if (dto != null) {
|
||||
VisibilityScope effective = inquiryService.effectiveVisibility(inquiry);
|
||||
dto.setVisibility(effective.name());
|
||||
dto.setVisibilityLabel(effective.label());
|
||||
}
|
||||
if (dto != null && inquiry.getInquirer() != null) {
|
||||
// 상세 작성자 표기: "이름 (법인명)" — 본인은 owner-bypass 로 원문
|
||||
String maskedName = StringMaskingUtil.maskName(
|
||||
inquiry.getInquirer().getUserName(),
|
||||
inquiry.getInquirer().getId(),
|
||||
user.getId());
|
||||
dto.setInquirerDisplay(withOrgName(maskedName, inquiry.getInquirer()));
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
|
||||
public void createInquiry(InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
inquiry.setInquirer(current);
|
||||
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||
if (hasFile(image)) {
|
||||
inquiry.setAttachFile(storeImage(null, image));
|
||||
}
|
||||
inquiryService.createInquiry(inquiry);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
@@ -99,17 +146,91 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
|
||||
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO, MultipartFile image) throws IOException {
|
||||
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
|
||||
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
|
||||
|
||||
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
|
||||
inquiry.setInquirer(existingInquiry.getInquirer());
|
||||
inquiry.setVisibility(clampedVisibility(inquiryDTO.getVisibility()));
|
||||
// 이미지 교체 시 기존 fileId 로 업데이트, 없으면 기존 첨부 유지
|
||||
if (hasFile(image)) {
|
||||
inquiry.setAttachFile(storeImage(existingInquiry.getAttachFile(), image));
|
||||
} else {
|
||||
inquiry.setAttachFile(existingInquiry.getAttachFile());
|
||||
}
|
||||
inquiryService.updateInquiry(id, inquiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementViewCount(PortalAuthenticatedUser user, String id) {
|
||||
// 접근 가능한 게시물만 조회수 증가
|
||||
inquiryService.getAccessibleInquiry(user, id);
|
||||
inquiryService.incrementViewCount(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VisibilityScope getVisibilityCeiling() {
|
||||
return inquiryService.getVisibilityCeiling();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUserInquiry(PortalAuthenticatedUser user, String id) {
|
||||
inquiryService.deleteMyInquiry(user, id);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// helpers
|
||||
// =====================================================================
|
||||
|
||||
/** 요청 공개범위를 기본 ORG 로 두고 property 상한으로 clamp. */
|
||||
private VisibilityScope clampedVisibility(String requested) {
|
||||
VisibilityScope ceiling = inquiryService.getVisibilityCeiling();
|
||||
VisibilityScope scope = VisibilityScope.fromString(requested, VisibilityScope.ORG);
|
||||
return VisibilityScope.clampTo(scope, ceiling);
|
||||
}
|
||||
|
||||
/** 비공개 게시물을 읽을 수 있는 사용자(작성자 본인 또는 같은 법인 관리자). */
|
||||
private boolean canReadPrivate(PortalAuthenticatedUser user, Inquiry inquiry) {
|
||||
PortalUser inquirer = inquiry.getInquirer();
|
||||
if (inquirer == null || user == null) {
|
||||
return false;
|
||||
}
|
||||
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||
&& isSameOrg(user, inquirer);
|
||||
}
|
||||
|
||||
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||
PortalOrg userOrg = user.getPortalOrg();
|
||||
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||
return userOrg != null && inquirerOrg != null
|
||||
&& userOrg.getId() != null
|
||||
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||
}
|
||||
|
||||
/** "이름 (법인명)" 조합. 법인명이 없으면 이름만. */
|
||||
private String withOrgName(String name, PortalUser inquirer) {
|
||||
PortalOrg org = inquirer.getPortalOrg();
|
||||
if (org != null && org.getOrgName() != null && !org.getOrgName().isEmpty()) {
|
||||
return name + " (" + org.getOrgName() + ")";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private boolean hasFile(MultipartFile file) {
|
||||
return file != null && !file.isEmpty();
|
||||
}
|
||||
|
||||
/** 이미지 확장자 검증 후 단일 파일 저장, fileId 반환. */
|
||||
private String storeImage(String existingFileId, MultipartFile image) throws IOException {
|
||||
String ext = FilenameUtils.getExtension(image.getOriginalFilename());
|
||||
if (ext == null || !IMAGE_EXTENSIONS.contains(ext.toLowerCase())) {
|
||||
throw new InvalidFileException("이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.");
|
||||
}
|
||||
FileInfo fileInfo = fileService.createOrUpdateSingleFile(
|
||||
existingFileId, image, image.getOriginalFilename(), true);
|
||||
return fileInfo.getFileId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -19,10 +21,19 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class InquiryService {
|
||||
|
||||
public static final String PENDING = "PENDING";
|
||||
private final InquiryRepository inquiryRepository;
|
||||
|
||||
public InquiryService(InquiryRepository inquiryRepository) {
|
||||
/** 문의 공개범위 상한/기본값 property. */
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String PROP_VISIBILITY_CEILING = "djb.inquiry.default-visibility";
|
||||
private static final String PROP_VISIBILITY_DESC = "Q&A 문의 공개범위 상한/기본값 (ALL/ORG/PRIVATE)";
|
||||
|
||||
private final InquiryRepository inquiryRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
public InquiryService(InquiryRepository inquiryRepository,
|
||||
PortalPropertyService portalPropertyService) {
|
||||
this.inquiryRepository = inquiryRepository;
|
||||
this.portalPropertyService = portalPropertyService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +86,7 @@ public class InquiryService {
|
||||
inquiry.setInquirySubject(updatedInquiry.getInquirySubject());
|
||||
inquiry.setInquiryDetail(updatedInquiry.getInquiryDetail());
|
||||
inquiry.setAttachFile(updatedInquiry.getAttachFile());
|
||||
inquiry.setVisibility(updatedInquiry.getVisibility());
|
||||
return inquiryRepository.save(inquiry);
|
||||
}
|
||||
|
||||
@@ -112,12 +124,27 @@ public class InquiryService {
|
||||
if (inquirer == null || user == null) {
|
||||
return false;
|
||||
}
|
||||
// 작성자 본인은 공개범위와 무관하게 항상 접근 가능
|
||||
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
|
||||
return true;
|
||||
}
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
return false;
|
||||
VisibilityScope effective = effectiveVisibility(inquiry);
|
||||
switch (effective) {
|
||||
case ALL:
|
||||
// 전체공개: 로그인 사용자면 접근 가능(@Secured 로 이미 인증됨)
|
||||
return true;
|
||||
case ORG:
|
||||
return isSameOrg(user, inquirer);
|
||||
case PRIVATE:
|
||||
// 비공개: 본인 외에는 같은 법인 관리자만 열람
|
||||
return isSameOrg(user, inquirer)
|
||||
&& user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSameOrg(PortalAuthenticatedUser user, PortalUser inquirer) {
|
||||
PortalOrg userOrg = user.getPortalOrg();
|
||||
PortalOrg inquirerOrg = inquirer.getPortalOrg();
|
||||
return userOrg != null && inquirerOrg != null
|
||||
@@ -125,4 +152,30 @@ public class InquiryService {
|
||||
&& userOrg.getId().equals(inquirerOrg.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물의 유효 공개범위 = 저장값을 property 상한으로 clamp 한 값.
|
||||
* (property 가 항상 우선하므로 저장값이 옛 상한이라 넓더라도 재-clamp)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public VisibilityScope effectiveVisibility(Inquiry inquiry) {
|
||||
return VisibilityScope.clampTo(inquiry.getVisibility(), getVisibilityCeiling());
|
||||
}
|
||||
|
||||
/** PTL_PROPERTY 에 설정된 공개범위 상한(없으면 ORG). */
|
||||
@Transactional(readOnly = true)
|
||||
public VisibilityScope getVisibilityCeiling() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_VISIBILITY_CEILING, VisibilityScope.ORG.name(), PROP_VISIBILITY_DESC);
|
||||
return VisibilityScope.fromString(value, VisibilityScope.ORG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회수 1 증가. sessionStorage dedup 을 통과한 POST 요청에서만 호출된다.
|
||||
*/
|
||||
public void incrementViewCount(String id) {
|
||||
Inquiry inquiry = getInquiry(id);
|
||||
inquiry.increaseViewCount();
|
||||
inquiryRepository.save(inquiry);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -43,7 +43,8 @@ public class InquiryCommentController {
|
||||
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
|
||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
||||
inquiryId, request.getContent(), request.getVisibility(), current);
|
||||
return ResponseEntity.ok(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
|
||||
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
||||
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
||||
private String content;
|
||||
|
||||
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
|
||||
private String visibility;
|
||||
}
|
||||
|
||||
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private boolean deletable;
|
||||
|
||||
/** 공개범위(ALL/PRIVATE). */
|
||||
private String visibility;
|
||||
|
||||
/** 비공개 댓글 여부 — 허용 독자에게는 "비공개" 태그로 표시. */
|
||||
private boolean privateComment;
|
||||
|
||||
/** 비공개 댓글이지만 열람 권한이 없어 "비공개 댓글"로만 노출되는지 여부. */
|
||||
private boolean privatePlaceholder;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public interface InquiryCommentFacade {
|
||||
|
||||
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
|
||||
|
||||
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
|
||||
InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current);
|
||||
|
||||
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
|
||||
|
||||
|
||||
@@ -3,15 +3,19 @@ package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
||||
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -37,6 +41,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
|
||||
private static final String ADMIN_N = "N";
|
||||
private static final String DEL_N = "N";
|
||||
private static final String UNKNOWN_WRITER = "(알 수 없음)";
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryCommentService commentService;
|
||||
@@ -51,21 +56,24 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
|
||||
inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
|
||||
Map<String, String> writerNames = resolveWriterNames(comments);
|
||||
Map<String, Writer> writers = resolveWriters(comments);
|
||||
return comments.stream()
|
||||
.map(c -> toDto(c, current, writerNames))
|
||||
.map(c -> toDto(c, current, writers))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
|
||||
public InquiryCommentDTO createUserComment(String inquiryId, String content, String visibility, PortalAuthenticatedUser current) {
|
||||
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
|
||||
permissionChecker.assertWritable(inquiry);
|
||||
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
|
||||
// 댓글은 공개(ALL)/비공개(PRIVATE) 2단계만 지원
|
||||
VisibilityScope scope = VisibilityScope.PRIVATE == VisibilityScope.fromString(visibility, VisibilityScope.ALL)
|
||||
? VisibilityScope.PRIVATE : VisibilityScope.ALL;
|
||||
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N, scope);
|
||||
publishAdminNotification(inquiry, saved, current);
|
||||
Map<String, String> writerNames = new HashMap<>();
|
||||
writerNames.put(current.getId(), current.getUserName());
|
||||
return toDto(saved, current, writerNames);
|
||||
Map<String, Writer> writers = new HashMap<>();
|
||||
writers.put(current.getId(), currentWriter(current));
|
||||
return toDto(saved, current, writers);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -102,7 +110,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
||||
}
|
||||
|
||||
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
|
||||
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (InquiryComment c : comments) {
|
||||
String createdBy = c.getCreatedBy();
|
||||
@@ -110,11 +118,11 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
ids.add(createdBy);
|
||||
}
|
||||
}
|
||||
Map<String, String> map = new HashMap<>();
|
||||
Map<String, Writer> map = new HashMap<>();
|
||||
for (String id : ids) {
|
||||
String name = lookupWriterName(id);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
map.put(id, name);
|
||||
Writer writer = lookupWriter(id);
|
||||
if (writer != null && writer.name != null && !writer.name.isEmpty()) {
|
||||
map.put(id, writer);
|
||||
} else {
|
||||
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
|
||||
}
|
||||
@@ -122,23 +130,35 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
return map;
|
||||
}
|
||||
|
||||
private String lookupWriterName(String createdBy) {
|
||||
private Writer lookupWriter(String createdBy) {
|
||||
if (isUuid(createdBy)) {
|
||||
String name = portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
|
||||
if (portalUser != null && portalUser.getUserName() != null && !portalUser.getUserName().isEmpty()) {
|
||||
return toWriter(portalUser);
|
||||
}
|
||||
return userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||
return userInfo != null ? new Writer(userInfo.getUsername(), null, null) : null;
|
||||
}
|
||||
String name = userInfoRepository.findById(createdBy)
|
||||
.map(UserInfo::getUsername).orElse(null);
|
||||
if (name != null && !name.isEmpty()) {
|
||||
return name;
|
||||
UserInfo userInfo = userInfoRepository.findById(createdBy).orElse(null);
|
||||
if (userInfo != null && userInfo.getUsername() != null && !userInfo.getUsername().isEmpty()) {
|
||||
return new Writer(userInfo.getUsername(), null, null);
|
||||
}
|
||||
return portalUserRepository.findById(createdBy)
|
||||
.map(PortalUser::getUserName).orElse(null);
|
||||
PortalUser portalUser = portalUserRepository.findById(createdBy).orElse(null);
|
||||
return portalUser != null ? toWriter(portalUser) : null;
|
||||
}
|
||||
|
||||
private Writer toWriter(PortalUser portalUser) {
|
||||
PortalOrg org = portalUser.getPortalOrg();
|
||||
return new Writer(portalUser.getUserName(),
|
||||
org != null ? org.getId() : null,
|
||||
org != null ? org.getOrgName() : null);
|
||||
}
|
||||
|
||||
private Writer currentWriter(PortalAuthenticatedUser current) {
|
||||
PortalOrg org = current.getPortalOrg();
|
||||
return new Writer(current.getUserName(),
|
||||
org != null ? org.getId() : null,
|
||||
org != null ? org.getOrgName() : null);
|
||||
}
|
||||
|
||||
private boolean isUuid(String value) {
|
||||
@@ -154,21 +174,50 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
}
|
||||
|
||||
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
|
||||
Map<String, String> writerNames) {
|
||||
Map<String, Writer> writers) {
|
||||
String writerId = entity.getCreatedBy();
|
||||
boolean deletable = writerId != null
|
||||
&& writerId.equals(current.getId())
|
||||
boolean isAdmin = "Y".equals(entity.getAdminYn());
|
||||
boolean isPrivate = entity.isPrivate();
|
||||
boolean owner = writerId != null && writerId.equals(current.getId());
|
||||
Writer writer = writerId != null ? writers.get(writerId) : null;
|
||||
|
||||
// 비공개 댓글 열람 권한: 관리자 댓글은 대상 아님, 작성자 본인 또는 같은 법인 관리자만
|
||||
boolean canSee = owner || canSeePrivate(current, writer);
|
||||
String scopeName = entity.getVisibility() != null ? entity.getVisibility().name() : VisibilityScope.ALL.name();
|
||||
|
||||
if (isPrivate && !canSee) {
|
||||
// 열람 권한 없는 비공개 댓글 → "비공개 댓글"로만 노출, 작성자 신원 숨김
|
||||
return InquiryCommentDTO.builder()
|
||||
.id(entity.getId())
|
||||
.content("비공개 댓글")
|
||||
.writerId(null)
|
||||
.writerName("비공개")
|
||||
.adminYn(entity.getAdminYn())
|
||||
.createdDate(entity.getCreatedDate())
|
||||
.deletable(false)
|
||||
.visibility(scopeName)
|
||||
.privateComment(true)
|
||||
.privatePlaceholder(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
boolean deletable = owner
|
||||
&& entity.getInquiry() != null
|
||||
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
|
||||
|
||||
String writerName;
|
||||
if ("Y".equals(entity.getAdminYn())) {
|
||||
if (isAdmin) {
|
||||
// 관리자 댓글은 실명 대신 "관리자"로만 표기 (신원 비노출)
|
||||
writerName = "관리자";
|
||||
} else {
|
||||
writerName = writerId != null ? writerNames.get(writerId) : null;
|
||||
if (writerName == null || writerName.isEmpty()) {
|
||||
writerName = "(알 수 없음)";
|
||||
String base = writer != null ? writer.name : null;
|
||||
String masked = StringMaskingUtil.maskName(base, writerId, current.getId());
|
||||
if (masked == null || masked.isEmpty()) {
|
||||
masked = UNKNOWN_WRITER;
|
||||
}
|
||||
writerName = (writer != null && writer.orgName != null && !writer.orgName.isEmpty())
|
||||
? masked + " (" + writer.orgName + ")"
|
||||
: masked;
|
||||
}
|
||||
return InquiryCommentDTO.builder()
|
||||
.id(entity.getId())
|
||||
@@ -178,6 +227,34 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
.adminYn(entity.getAdminYn())
|
||||
.createdDate(entity.getCreatedDate())
|
||||
.deletable(deletable)
|
||||
.visibility(scopeName)
|
||||
.privateComment(isPrivate)
|
||||
.privatePlaceholder(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 비공개 댓글을 볼 수 있는 같은 법인 관리자 여부. */
|
||||
private boolean canSeePrivate(PortalAuthenticatedUser current, Writer writer) {
|
||||
if (writer == null || writer.orgId == null) {
|
||||
return false;
|
||||
}
|
||||
if (current.getRoleCode() != PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||
return false;
|
||||
}
|
||||
PortalOrg org = current.getPortalOrg();
|
||||
return org != null && org.getId() != null && org.getId().equals(writer.orgId);
|
||||
}
|
||||
|
||||
/** 댓글 작성자 정보(이름/법인). */
|
||||
private static final class Writer {
|
||||
private final String name;
|
||||
private final String orgId;
|
||||
private final String orgName;
|
||||
|
||||
private Writer(String name, String orgId, String orgName) {
|
||||
this.name = name;
|
||||
this.orgId = orgId;
|
||||
this.orgName = orgName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import com.eactive.apim.portal.qna.entity.VisibilityScope;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -25,12 +26,13 @@ public class InquiryCommentService {
|
||||
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
|
||||
}
|
||||
|
||||
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
|
||||
public InquiryComment create(Inquiry inquiry, String content, String adminYn, VisibilityScope visibility) {
|
||||
InquiryComment comment = new InquiryComment();
|
||||
comment.setInquiry(inquiry);
|
||||
comment.setCommentDetail(content);
|
||||
comment.setAdminYn(adminYn);
|
||||
comment.setDelYn(DEL_N);
|
||||
comment.setVisibility(visibility != null ? visibility : VisibilityScope.ALL);
|
||||
return repository.save(comment);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.eactive.apim.portal.djb.testbed.advice;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 테스트베드 두 컨트롤러 한정 예외 → JSON 변환.
|
||||
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
|
||||
public class DjbTestbedExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "UNSUPPORTED_AUTHTYPE");
|
||||
body.put("message", ex.getMessage());
|
||||
body.put("authtype", ex.getAuthtype());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "FORBIDDEN");
|
||||
body.put("message", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.eactive.apim.portal.djb.testbed.config;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
|
||||
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시).
|
||||
*
|
||||
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
|
||||
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
|
||||
*
|
||||
* <p>비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는
|
||||
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedGatewayProperty {
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_BASE_URL = "djb.gateway.base-url";
|
||||
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
|
||||
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
|
||||
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
|
||||
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
|
||||
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
||||
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||
public static final String DEFAULT_USE_PROXY = "true";
|
||||
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
|
||||
private static final int TIMEOUT_FALLBACK_MS = 10_000;
|
||||
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
|
||||
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
|
||||
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
|
||||
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
|
||||
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
|
||||
|
||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
||||
|
||||
public String baseUrl() {
|
||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||
}
|
||||
|
||||
public String tokenPath() {
|
||||
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
|
||||
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
|
||||
}
|
||||
|
||||
public String apiKeyHeader() {
|
||||
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
|
||||
"API Key 전달 헤더명");
|
||||
}
|
||||
|
||||
public String oauthHeader() {
|
||||
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
|
||||
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트베드 API 호출 시 서버 프록시({@code /api/call-api}) 사용 여부.
|
||||
* {@code false} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
*/
|
||||
public boolean useProxy() {
|
||||
return parseBool(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
|
||||
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 시 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
|
||||
* {@code false} 이면 브라우저에서 토큰 URL 로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, 실 GW 는 직접호출로 뺄 수 있음.)
|
||||
*/
|
||||
public boolean tokenUseProxy() {
|
||||
return parseBool(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
|
||||
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
||||
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
||||
*/
|
||||
private static boolean parseBool(String v, boolean def) {
|
||||
if (v == null) {
|
||||
return def;
|
||||
}
|
||||
String s = v.trim();
|
||||
if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("Y")) {
|
||||
return true;
|
||||
}
|
||||
if (s.equalsIgnoreCase("false") || s.equalsIgnoreCase("N")) {
|
||||
return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
|
||||
public int timeoutMillis() {
|
||||
try {
|
||||
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
|
||||
"테스트베드 프록시 연결/응답 타임아웃(초)"));
|
||||
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
|
||||
} catch (Exception e) {
|
||||
return TIMEOUT_FALLBACK_MS;
|
||||
}
|
||||
}
|
||||
|
||||
public DjbGatewayMode resolveGatewayMode() {
|
||||
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 토큰 발급 URL.
|
||||
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
|
||||
* GATEWAY → {@code baseUrl + tokenPath}.
|
||||
*/
|
||||
public String resolveTokenUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
|
||||
}
|
||||
return stripTrailingSlash(baseUrl()) + tokenPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
|
||||
* PortalMock → 요청 포탈 origin(mock 필터가 프록시), GATEWAY → {@code baseUrl}.
|
||||
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 이 값을 단일 기준으로 사용한다.)
|
||||
*/
|
||||
public String resolveApiBaseUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin);
|
||||
}
|
||||
return stripTrailingSlash(baseUrl());
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
return StringUtils.hasText(value) ? value.trim() : defaultValue;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트/시크릿 REST.
|
||||
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함),
|
||||
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthController {
|
||||
|
||||
private final DjbTestbedAuthService authService;
|
||||
|
||||
@GetMapping("/context")
|
||||
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
|
||||
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
|
||||
}
|
||||
|
||||
@GetMapping("/credentials/{clientId}/secret")
|
||||
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
|
||||
@RequestParam String apiId) {
|
||||
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.header(HttpHeaders.PRAGMA, "no-cache")
|
||||
.body(secret);
|
||||
}
|
||||
|
||||
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
|
||||
private String resolveOrigin(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String host = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
|
||||
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
||||
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
|
||||
* 따른 실주소로 치환해 반환한다.
|
||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecController {
|
||||
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedAuthService authService;
|
||||
private final DjbSwaggerSpecEnricher enricher;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||
return serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialOptionDto {
|
||||
|
||||
/** PTL_CREDENTIAL.clientid */
|
||||
private String clientId;
|
||||
|
||||
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
|
||||
private String clientName;
|
||||
|
||||
/** "0": 차단, "1": 정상 */
|
||||
private String appStatus;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
|
||||
* OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialSecretDto {
|
||||
|
||||
private String clientId;
|
||||
|
||||
private String clientSecret;
|
||||
|
||||
private DjbAuthType authType;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbTestbedContextDto {
|
||||
|
||||
/** 인증 정보 제공 대상자 여부 */
|
||||
private boolean eligible;
|
||||
|
||||
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
|
||||
private String reason;
|
||||
|
||||
/** 사용자가 선택할 수 있는 앱 목록 */
|
||||
private List<DjbCredentialOptionDto> credentials;
|
||||
|
||||
/** 게이트웨이 정보(시크릿 제외) */
|
||||
private GatewayInfo gateway;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class GatewayInfo {
|
||||
private DjbGatewayMode mode;
|
||||
private String baseUrl;
|
||||
private String tokenUrl;
|
||||
private String apiKeyHeader;
|
||||
private String oauthTokenHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
|
||||
/**
|
||||
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
|
||||
* <ul>
|
||||
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
|
||||
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
|
||||
* <li>{@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)</li>
|
||||
* <li>{@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}</li>
|
||||
* </ul>
|
||||
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
|
||||
*/
|
||||
public enum DjbAuthType {
|
||||
OAUTH,
|
||||
API_KEY,
|
||||
NONE;
|
||||
|
||||
public static DjbAuthType fromAuthtype(String authtype) {
|
||||
// null(EAIMessage 미존재)은 데이터 문제 → 종전대로 예외. "none" 은 인증 없는 API.
|
||||
if (authtype == null) {
|
||||
throw new DjbUnsupportedAuthTypeException("null");
|
||||
}
|
||||
String normalized = authtype.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "oauth":
|
||||
case "oauth2":
|
||||
case "ca":
|
||||
return OAUTH;
|
||||
case "api_key":
|
||||
case "apikey":
|
||||
return API_KEY;
|
||||
case "none":
|
||||
case "no":
|
||||
case "noauth":
|
||||
return NONE;
|
||||
default:
|
||||
throw new DjbUnsupportedAuthTypeException(authtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
/**
|
||||
* 테스트베드 호출이 향하는 대상 모드.
|
||||
* <ul>
|
||||
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
|
||||
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
|
||||
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum DjbGatewayMode {
|
||||
PORTAL_MOCK,
|
||||
GATEWAY
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.testbed.exception;
|
||||
|
||||
/**
|
||||
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로
|
||||
* 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
|
||||
* 에서 400 응답으로 변환된다.
|
||||
*/
|
||||
public class DjbUnsupportedAuthTypeException extends RuntimeException {
|
||||
|
||||
private final String authtype;
|
||||
|
||||
public DjbUnsupportedAuthTypeException(String authtype) {
|
||||
super("지원하지 않는 인증 체계: " + authtype);
|
||||
this.authtype = authtype;
|
||||
}
|
||||
|
||||
public String getAuthtype() {
|
||||
return authtype;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 인증 체계를 주입한다.
|
||||
*
|
||||
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
|
||||
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
|
||||
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
|
||||
*
|
||||
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
|
||||
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
|
||||
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
|
||||
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
|
||||
*
|
||||
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다
|
||||
* (문자열 치환 금지).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbSwaggerSpecEnricher {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public static final String OAUTH_SCHEME = "djbOAuth";
|
||||
public static final String API_KEY_SCHEME = "djbApiKey";
|
||||
|
||||
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
// 예상 밖 형태면 원본 유지(멱등)
|
||||
return specJson;
|
||||
}
|
||||
ObjectNode root = (ObjectNode) parsed;
|
||||
|
||||
// NONE: 인증 없이 호출 — 글로벌 security / securityScheme 흔적을 제거해
|
||||
// Swagger UI 의 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec 에 빈 securitySchemes 가
|
||||
// 남아 있으면 UI 가 빈 인증 모달 버튼을 렌더하므로 정리한다.)
|
||||
if (authType == DjbAuthType.NONE) {
|
||||
return stripSecurity(root);
|
||||
}
|
||||
|
||||
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
|
||||
ObjectNode scheme = buildApiKeyScheme(authType);
|
||||
|
||||
boolean swagger2 = root.has("swagger") && !root.has("openapi");
|
||||
if (swagger2) {
|
||||
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
|
||||
} else {
|
||||
ObjectNode components = getOrCreateObject(root, "components");
|
||||
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
|
||||
}
|
||||
|
||||
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
|
||||
ArrayNode security = objectMapper.createArrayNode();
|
||||
ObjectNode requirement = objectMapper.createObjectNode();
|
||||
requirement.set(schemeName, objectMapper.createArrayNode());
|
||||
security.add(requirement);
|
||||
root.set("security", security);
|
||||
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
|
||||
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
|
||||
String headerName = (authType == DjbAuthType.OAUTH)
|
||||
? gatewayProperty.oauthHeader()
|
||||
: gatewayProperty.apiKeyHeader();
|
||||
ObjectNode scheme = objectMapper.createObjectNode();
|
||||
scheme.put("type", "apiKey");
|
||||
scheme.put("in", "header");
|
||||
scheme.put("name", headerName);
|
||||
return scheme;
|
||||
}
|
||||
|
||||
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
|
||||
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
|
||||
root.remove("security");
|
||||
root.remove("securityDefinitions"); // swagger 2.0
|
||||
JsonNode components = root.get("components");
|
||||
if (components != null && components.isObject()) {
|
||||
((ObjectNode) components).remove("securitySchemes");
|
||||
}
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
|
||||
JsonNode node = parent.get(field);
|
||||
if (node != null && node.isObject()) {
|
||||
return (ObjectNode) node;
|
||||
}
|
||||
ObjectNode created = objectMapper.createObjectNode();
|
||||
parent.set(field, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑.
|
||||
* 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final EAIMessageRepository eaiMessageRepository;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
private static final String APP_STATUS_ACTIVE = "1";
|
||||
|
||||
/**
|
||||
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
|
||||
* {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}.
|
||||
*/
|
||||
public DjbTestbedContextDto buildContext(String portalOrigin) {
|
||||
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
|
||||
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return notEligible("ANONYMOUS", gateway);
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
return notEligible("INDIVIDUAL_USER", gateway);
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
|
||||
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
|
||||
.map(this::toOption)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (options.isEmpty()) {
|
||||
return notEligible("NO_CREDENTIAL", gateway);
|
||||
}
|
||||
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(true)
|
||||
.reason(null)
|
||||
.credentials(options)
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환.
|
||||
* 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403).
|
||||
*/
|
||||
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
throw new AccessDeniedException("로그인이 필요합니다.");
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
|
||||
|
||||
DjbAuthType authType = resolveAuthType(apiId);
|
||||
|
||||
return DjbCredentialSecretDto.builder()
|
||||
.clientId(credential.getClientid())
|
||||
.clientSecret(credential.getClientsecret())
|
||||
.authType(authType)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑.
|
||||
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400).
|
||||
*/
|
||||
public DjbAuthType resolveAuthType(String apiId) {
|
||||
String authtype = eaiMessageRepository.findById(apiId)
|
||||
.map(EAIMessageEntity::getAuthtype)
|
||||
.orElse(null);
|
||||
return DjbAuthType.fromAuthtype(authtype);
|
||||
}
|
||||
|
||||
private DjbCredentialOptionDto toOption(Credential c) {
|
||||
return DjbCredentialOptionDto.builder()
|
||||
.clientId(c.getClientid())
|
||||
.clientName(c.getClientname())
|
||||
.appStatus(c.getAppstatus())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayInfo buildGatewayInfo(String portalOrigin) {
|
||||
return GatewayInfo.builder()
|
||||
.mode(gatewayProperty.resolveGatewayMode())
|
||||
.baseUrl(gatewayProperty.baseUrl())
|
||||
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
|
||||
.apiKeyHeader(gatewayProperty.apiKeyHeader())
|
||||
.oauthTokenHeader(gatewayProperty.oauthHeader())
|
||||
.build();
|
||||
}
|
||||
|
||||
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(false)
|
||||
.reason(reason)
|
||||
.credentials(Collections.emptyList())
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
|
||||
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
|
||||
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
|
||||
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
|
||||
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
|
||||
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
|
||||
*
|
||||
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecServerRewriter {
|
||||
|
||||
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
|
||||
public static final String SERVER_SENTINEL = "http://swagger-server-url";
|
||||
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
|
||||
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
|
||||
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||
return specJson;
|
||||
}
|
||||
String base = stripTrailingSlash(resolveBase(spec, request));
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||
}
|
||||
return specJson.replace(SERVER_SENTINEL, base);
|
||||
}
|
||||
|
||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||
public String toYaml(String specJson) {
|
||||
try {
|
||||
Object tree = objectMapper.readValue(specJson, Object.class);
|
||||
DumperOptions opts = new DumperOptions();
|
||||
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
opts.setPrettyFlow(true);
|
||||
return new Yaml(opts).dump(tree);
|
||||
} catch (Exception e) {
|
||||
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
|
||||
return specJson;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
|
||||
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
|
||||
? "sample" : spec.getResponseType().trim();
|
||||
if ("gw".equalsIgnoreCase(rt)) {
|
||||
return gatewayProperty.resolveApiBaseUrl(originOf(request));
|
||||
}
|
||||
if ("mock".equalsIgnoreCase(rt)) {
|
||||
String mock = (spec == null) ? null : spec.getMockUrl();
|
||||
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
|
||||
}
|
||||
// sample(기본): 포탈 origin
|
||||
return originOf(request);
|
||||
}
|
||||
|
||||
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
|
||||
public static String originOf(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return "";
|
||||
}
|
||||
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
|
||||
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
|
||||
if (StringUtils.hasText(host)) {
|
||||
return scheme + "://" + host.trim();
|
||||
}
|
||||
int port = request.getServerPort();
|
||||
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
|
||||
return scheme + "://" + hostPort;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
return StringUtils.hasText(a) ? a.trim() : b;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
while (t.endsWith("/")) {
|
||||
t = t.substring(0, t.length() - 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ spring:
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
show_sql: true
|
||||
format_sql: true
|
||||
show_sql: false
|
||||
format_sql: false
|
||||
use_sql_comments: true
|
||||
devtools:
|
||||
restart:
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 380 KiB |
@@ -1,3 +1,4 @@
|
||||
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 314H0V0.447266C158.076 115.873 527.685 196.879 958.538 196.999H960.462C1391.87 196.878 1761.87 115.662 1919.61 0H1920V314Z" fill="#CFF2FF"/>
|
||||
<svg width="1920" height="318" viewBox="0 0 1920 318" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 318H0V64.4473C158.076 179.873 527.685 260.879 958.538 260.999H960.462C1391.87 260.878 1761.87 179.662 1919.61 64H1920V318Z" fill="#D3EFFB"/>
|
||||
<path d="M1920 281H0V13.71C158.194 148.461 528.235 243 959.499 243C1391.68 243 1762.37 148.059 1920 12.8525V0H1920V281ZM0 0V13.71H-0.000976562V0H0Z" fill="#CFF2FF" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -12,6 +12,7 @@
|
||||
const formEl = document.getElementById('djbCommentForm');
|
||||
const inputEl = document.getElementById('djbCommentInput');
|
||||
const counterEl = document.getElementById('djbCommentCharCounter');
|
||||
const privateEl = document.getElementById('djbCommentPrivate');
|
||||
|
||||
function getCsrfToken() {
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
@@ -74,14 +75,28 @@
|
||||
}
|
||||
comments.forEach(function (c) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
|
||||
var cls = 'djb-comment-item';
|
||||
if (c.adminYn === 'Y') cls += ' djb-comment-item--admin';
|
||||
if (c.privatePlaceholder) cls += ' djb-comment-item--private-hidden';
|
||||
else if (c.privateComment) cls += ' djb-comment-item--private';
|
||||
li.className = cls;
|
||||
li.setAttribute('data-comment-id', c.id);
|
||||
|
||||
var writerHtml = (c.adminYn === 'Y')
|
||||
? '<span class="djb-comment-admin-badge">관리자</span>'
|
||||
: '<span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>';
|
||||
// 비공개 댓글(열람 권한 있는 경우)에는 "비공개" 태그 표시
|
||||
var privateTag = (c.privateComment && !c.privatePlaceholder)
|
||||
? '<span class="djb-comment-private-tag">비공개</span>' : '';
|
||||
|
||||
li.innerHTML = ''
|
||||
+ '<div class="djb-comment-meta">'
|
||||
+ ' <div class="djb-comment-meta-left">'
|
||||
+ (c.adminYn === 'Y'
|
||||
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
||||
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
||||
+ ' <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 2px; opacity: 0.5;">'
|
||||
+ ' <path d="M5 3v5a2 2 0 0 0 2 2h5 M9 7l3 3-3 3" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
+ ' </svg>'
|
||||
+ writerHtml
|
||||
+ privateTag
|
||||
+ ' </div>'
|
||||
+ ' <div class="djb-comment-meta-right">'
|
||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||
@@ -112,9 +127,10 @@
|
||||
inputEl.focus();
|
||||
return;
|
||||
}
|
||||
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
|
||||
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: content })
|
||||
body: JSON.stringify({ content: content, visibility: visibility })
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
||||
@@ -123,6 +139,7 @@
|
||||
})
|
||||
.then(function () {
|
||||
inputEl.value = '';
|
||||
if (privateEl) privateEl.checked = false;
|
||||
updateCounter();
|
||||
load();
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 조회수 비정상 증가 억제: sessionStorage 로 한 세션 내 중복 증가 방지.
|
||||
var container = document.querySelector('.inquiry-detail-container[data-inquiry-id]');
|
||||
if (!container) return;
|
||||
|
||||
var inquiryId = container.getAttribute('data-inquiry-id');
|
||||
if (!inquiryId) return;
|
||||
|
||||
var viewedKey = 'inquiry_viewed_' + inquiryId;
|
||||
if (sessionStorage.getItem(viewedKey)) {
|
||||
return; // 이미 이번 세션에서 조회 → 증가하지 않음
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
|
||||
var meta = document.querySelector('meta[name="_csrf"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
fetch('/inquiry/' + encodeURIComponent(inquiryId) + '/view', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-XSRF-TOKEN': getCsrfToken() }
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem(viewedKey, 'true');
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('[inquiry-view] increment error', err);
|
||||
});
|
||||
})();
|
||||