Compare commits
32 Commits
7d6aabe122
...
design
| Author | SHA1 | Date | |
|---|---|---|---|
| 41391c8df0 | |||
| 9f4874fe44 | |||
| 43d915342e | |||
| ba00b80bfe | |||
| 87763ba325 | |||
| 5b7a4a108d | |||
| 0f581cae23 | |||
| 923bf56e21 | |||
| 9b9bcb5be4 | |||
| bdb249b74b | |||
| 1cb9606602 | |||
| 93f7c210bc | |||
| bb79b55f08 | |||
| 227d4d8abd | |||
| 23bd0c1207 | |||
| 28a9ff1a7f | |||
| 4395757d98 | |||
| 778d579ada | |||
| 3e403788e5 | |||
| 6c5ba6765d | |||
| a97378b84d | |||
| 9c01db9d00 | |||
| 08bd705dec | |||
| 99f765507b | |||
| 045f354e5c | |||
| 4f577c8c14 | |||
| cc4c1a6b53 | |||
| 551a2da717 | |||
| 1c57d348e6 | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -108,3 +108,5 @@ diff
|
||||
|
||||
*rinjae*
|
||||
*gf63*
|
||||
*obsidian*
|
||||
design-backup
|
||||
+6
-3
@@ -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')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -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; // 인터페이스 표시 명칭
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_DAY(AGWAPP) 조회 리포지토리.
|
||||
* 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
|
||||
*/
|
||||
public interface ApiStatsDayRepository extends JpaRepository<ApiStatsDay, ApiStatsDayId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (일자 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
|
||||
* 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
|
||||
* row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||
*/
|
||||
@Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
|
||||
"FROM ApiStatsDay s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
|
||||
"ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
|
||||
List<Object[]> findPeriodByDay(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHour;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHourId;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_HOUR(AGWAPP) 조회 리포지토리.
|
||||
*
|
||||
* 통계 수치는 보존기간이 긴 API_STATS_DAY 를 사용하되, "오늘"은 아직 DAY 로 집계되지 않았으므로
|
||||
* (일 집계 잡은 익일 실행) 오늘분만 HOUR 에서 합산해 DAY 결과에 더한다. 최신 집계 시각도 HOUR 로 산출.
|
||||
*/
|
||||
public interface ApiStatsHourRepository extends JpaRepository<ApiStatsHour, ApiStatsHourId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (시각 범위, 지정 clientId 집합) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계) — 오늘분 합산용.
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsHour s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 최신 집계 시각 (안내 문구용). 데이터 없으면 null.
|
||||
*/
|
||||
@Query("SELECT MAX(s.statTime) FROM ApiStatsHour s WHERE s.clientId IN :clientIds")
|
||||
LocalDateTime findLatestStatTime(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonth;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonthId;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API_STATS_MONTH(AGWAPP) 통계 조회 리포지토리 (월별 모드).
|
||||
* STAT_TIME 은 YYYYMM 문자열이므로 문자열 범위 비교로 조회한다.
|
||||
*/
|
||||
public interface ApiStatsMonthRepository extends JpaRepository<ApiStatsMonth, ApiStatsMonthId> {
|
||||
|
||||
/**
|
||||
* 전체 요약 (YYYYMM 범위, 지정 clientId 집합).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth")
|
||||
ApiStatisticsSummaryDto findSummary(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* API별 통계 (API_NAME 단위 집계).
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||
"FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds " +
|
||||
"AND s.statTime BETWEEN :startMonth AND :endMonth " +
|
||||
"GROUP BY s.apiName " +
|
||||
"ORDER BY SUM(s.totalCnt) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetail(
|
||||
@Param("clientIds") List<String> clientIds,
|
||||
@Param("startMonth") String startMonth,
|
||||
@Param("endMonth") String endMonth);
|
||||
|
||||
/**
|
||||
* 조회 가능한 월 목록 (YYYYMM, 최신순).
|
||||
*/
|
||||
@Query("SELECT DISTINCT s.statTime FROM ApiStatsMonth s " +
|
||||
"WHERE s.clientId IN :clientIds ORDER BY s.statTime DESC")
|
||||
List<String> findAvailableMonths(@Param("clientIds") List<String> clientIds);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* TSEAIAU01(AGWAPP 인증 Client) 조회 — API 통계 org 스코핑용.
|
||||
* PTL_ORG.ID == TSEAIAU01.ORGID (1:N), TSEAIAU01.CLIENTID == API_STATS_*.CLIENT_ID.
|
||||
*/
|
||||
public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, String> {
|
||||
|
||||
/**
|
||||
* org 소속 클라이언트 목록 (앱 선택 드롭다운용).
|
||||
*/
|
||||
List<GwAuthClient> findByOrgIdOrderByClientNameAsc(String orgId);
|
||||
|
||||
/**
|
||||
* org 소속 CLIENTID 목록 (통계 필터용).
|
||||
*/
|
||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIHE01(AGWAPP 인터페이스 정보) 조회 — API_NAME(인터페이스 ID) → 표시명(EAISVCDESC) 매핑용.
|
||||
*/
|
||||
public interface GwSvcInfoRepository extends JpaRepository<GwSvcInfo, String> {
|
||||
|
||||
List<GwSvcInfo> findBySvcNameIn(Collection<String> svcNames);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
+25
-15
@@ -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
|
||||
|
||||
+13
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
public class FaqController {
|
||||
@@ -47,4 +48,16 @@ public class FaqController {
|
||||
|
||||
return "apps/community/mainFaqList";
|
||||
}
|
||||
|
||||
@GetMapping("/faq_view")
|
||||
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return "redirect:/faq_list";
|
||||
}
|
||||
|
||||
FaqDTO faq = faqFacade.getFaq(id);
|
||||
model.addAttribute("faq", faq);
|
||||
|
||||
return "apps/community/mainFaqDetail";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface FaqFacade {
|
||||
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
||||
|
||||
FaqDTO getFaq(String id);
|
||||
}
|
||||
|
||||
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
|
||||
return faqPage.map(faqMapper::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaqDTO getFaq(String id) {
|
||||
return faqMapper.map(faqService.findById(id));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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;
|
||||
}
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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();
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-3
@@ -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);
|
||||
}
|
||||
|
||||
+125
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+57
-4
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ public class UserSessionService {
|
||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||
|
||||
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
|
||||
|
||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||
|
||||
@@ -133,6 +137,20 @@ public class UserSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
|
||||
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
|
||||
*/
|
||||
public boolean isKeepAliveEnabled() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP,
|
||||
KEEPALIVE_PROPERTY_NAME,
|
||||
DEFAULT_KEEPALIVE_ENABLED,
|
||||
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
|
||||
);
|
||||
return value != null && Boolean.parseBoolean(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||
*/
|
||||
|
||||
+31
-12
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
/**
|
||||
* API 통계 Controller
|
||||
* API 통계 Controller.
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@@ -31,7 +31,7 @@ public class ApiStatisticsController {
|
||||
private final ApiStatisticsService apiStatisticsService;
|
||||
|
||||
/**
|
||||
* 통계 페이지 진입
|
||||
* 통계 페이지 진입.
|
||||
*/
|
||||
@GetMapping
|
||||
public String statisticsPage(Model model) {
|
||||
@@ -41,11 +41,12 @@ public class ApiStatisticsController {
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
log.debug("[통계] 페이지 진입 - orgId={}", orgId);
|
||||
|
||||
// 앱 목록 조회 (선택용)
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (7일 전 ~ 오늘, 전체 앱)
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
@@ -54,13 +55,19 @@ public class ApiStatisticsController {
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
model.addAttribute("summary", result.getSummary());
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
model.addAttribute("statsAggregationMinute", apiStatisticsService.getAggregationMinute());
|
||||
model.addAttribute("statsLatestTime", apiStatisticsService.getLatestStatTime(orgId));
|
||||
|
||||
return "apps/statistics/apiStatistics";
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회 (AJAX)
|
||||
* 통계 조회 (AJAX).
|
||||
*/
|
||||
@PostMapping("/search")
|
||||
@ResponseBody
|
||||
@@ -70,11 +77,12 @@ public class ApiStatisticsController {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// 날짜 범위 검증 (최대 40일)
|
||||
if (!searchDto.isValidDateRange()) {
|
||||
log.debug("[통계] 검색 요청(AJAX) - orgId={}, mode={}, clientId={}",
|
||||
org.getId(), searchDto.getMode(), searchDto.getClientId());
|
||||
|
||||
if (!isValidRange(searchDto)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(java.util.Collections.singletonMap("error",
|
||||
"조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다."));
|
||||
.body(java.util.Collections.singletonMap("error", rangeErrorMessage(searchDto)));
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
@@ -83,7 +91,9 @@ public class ApiStatisticsController {
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드
|
||||
* CSV 다운로드.
|
||||
*
|
||||
* ※ UI에서 다운로드 버튼은 제거되었으나 백엔드 기능은 유지한다(직접 호출용).
|
||||
*/
|
||||
@GetMapping("/download")
|
||||
public void downloadCsv(ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||
@@ -93,9 +103,8 @@ public class ApiStatisticsController {
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜 범위 검증 (최대 40일)
|
||||
if (!searchDto.isValidDateRange()) {
|
||||
response.sendError(400, "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.");
|
||||
if (!isValidRange(searchDto)) {
|
||||
response.sendError(400, rangeErrorMessage(searchDto));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,6 +112,16 @@ public class ApiStatisticsController {
|
||||
apiStatisticsService.downloadCsv(orgId, searchDto, response);
|
||||
}
|
||||
|
||||
private boolean isValidRange(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly() ? searchDto.isValidMonth() : searchDto.isValidDateRange();
|
||||
}
|
||||
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
if (SecurityUtil.getPortalAuthenticatedUser() != null) {
|
||||
return SecurityUtil.getPortalAuthenticatedUser().getPortalOrg();
|
||||
|
||||
+27
-25
@@ -5,50 +5,52 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 개별 API 통계 상세 DTO
|
||||
* 개별 API 통계 상세 DTO (API_NAME 단위 집계).
|
||||
* 성공/타임아웃/실패(오류) 배타 구분. {@link ApiStatisticsSummaryDto} 참고.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsDetailDto {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
private Long attemptedCount; // 호출 건
|
||||
private Long completedCount; // 성공 건
|
||||
private Long failedCount; // 실패 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double failureRate; // 실패율 (%)
|
||||
private Long totalCount; // 호출 건
|
||||
private Long successCount; // 성공 건
|
||||
private Long timeoutCount; // 타임아웃 건
|
||||
private Long errorCount; // 실패(오류) 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double timeoutRate; // 타임아웃율 (%)
|
||||
private Double errorRate; // 실패(오류)율 (%)
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자
|
||||
* Repository 조회용 생성자.
|
||||
*/
|
||||
public ApiStatisticsDetailDto(String apiId, String apiName, Long attemptedCount, Long completedCount) {
|
||||
this.apiId = apiId;
|
||||
public ApiStatisticsDetailDto(String apiName, Long totalCount, Long successCount,
|
||||
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||
this.apiName = apiName;
|
||||
this.attemptedCount = attemptedCount != null ? attemptedCount : 0L;
|
||||
this.completedCount = completedCount != null ? completedCount : 0L;
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패건, 성공률, 실패율 계산
|
||||
* 성공율/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
if (attemptedCount == null) {
|
||||
attemptedCount = 0L;
|
||||
}
|
||||
if (completedCount == null) {
|
||||
completedCount = 0L;
|
||||
}
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||
|
||||
this.failedCount = attemptedCount - completedCount;
|
||||
|
||||
if (attemptedCount > 0) {
|
||||
this.successRate = Math.round((completedCount * 1000.0) / attemptedCount) / 10.0;
|
||||
this.failureRate = Math.round((failedCount * 1000.0) / attemptedCount) / 10.0;
|
||||
if (totalCount > 0) {
|
||||
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.failureRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.apps.statistics.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 기간별(일별/월별) 누적 통계 DTO — API별 통계 하단 표에 사용.
|
||||
*
|
||||
* period 는 일별 모드에서 "yyyy-MM-dd", 월별 모드에서 "yyyy-MM" 형태의 라벨.
|
||||
* 각 행은 해당 기간의 전체 앱/전체 API 합계이다.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsPeriodDto {
|
||||
|
||||
private String period; // 기간 라벨 (yyyy-MM-dd 또는 yyyy-MM)
|
||||
private Long totalCount;
|
||||
private Long successCount;
|
||||
private Long timeoutCount;
|
||||
private Long errorCount;
|
||||
private Double successRate;
|
||||
private Double timeoutRate;
|
||||
private Double errorRate;
|
||||
|
||||
/** 해당 일자에 실제 집계 데이터가 존재하는지 여부. false면 표에 '-'로 표기(0과 구분). */
|
||||
private boolean hasData;
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자 (실제 데이터 행 → hasData=true).
|
||||
*/
|
||||
public ApiStatisticsPeriodDto(String period, Long totalCount, Long successCount,
|
||||
Long timeoutCount, Long systemErrCount, Long bizErrCount) {
|
||||
this.period = period;
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(systemErrCount) + ApiStatisticsSummaryDto.nz(bizErrCount);
|
||||
this.hasData = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공율/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
this.totalCount = ApiStatisticsSummaryDto.nz(totalCount);
|
||||
this.successCount = ApiStatisticsSummaryDto.nz(successCount);
|
||||
this.timeoutCount = ApiStatisticsSummaryDto.nz(timeoutCount);
|
||||
this.errorCount = ApiStatisticsSummaryDto.nz(errorCount);
|
||||
|
||||
if (totalCount > 0) {
|
||||
this.successRate = ApiStatisticsSummaryDto.rate(successCount, totalCount);
|
||||
this.timeoutRate = ApiStatisticsSummaryDto.rate(timeoutCount, totalCount);
|
||||
this.errorRate = ApiStatisticsSummaryDto.rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -7,7 +7,8 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 조회 결과 DTO
|
||||
* API 통계 조회 결과 DTO.
|
||||
* summary: 전체 요약, details: API별, periods: 기간별(일/월) 누적.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -16,13 +17,15 @@ public class ApiStatisticsResultDto {
|
||||
|
||||
private ApiStatisticsSummaryDto summary;
|
||||
private List<ApiStatisticsDetailDto> details;
|
||||
private List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
/**
|
||||
* 빈 결과 생성
|
||||
* 빈 결과 생성.
|
||||
*/
|
||||
public static ApiStatisticsResultDto empty() {
|
||||
return new ApiStatisticsResultDto(
|
||||
ApiStatisticsSummaryDto.empty(),
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>()
|
||||
);
|
||||
}
|
||||
|
||||
+30
-11
@@ -6,16 +6,25 @@ import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
/**
|
||||
* API 통계 검색 조건 DTO
|
||||
* API 통계 검색 조건 DTO.
|
||||
*
|
||||
* mode = DAILY 이면 startDate~endDate(날짜범위, API_STATS_HOUR),
|
||||
* mode = MONTHLY 이면 month(YYYYMM 단일 선택)를 사용한다.
|
||||
*/
|
||||
@Data
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
public enum Mode {
|
||||
DAILY, MONTHLY
|
||||
}
|
||||
|
||||
private Mode mode = Mode.DAILY;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate startDate;
|
||||
|
||||
@@ -23,7 +32,12 @@ public class ApiStatisticsSearchDto {
|
||||
private LocalDate endDate;
|
||||
|
||||
/**
|
||||
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱)
|
||||
* 월별 조회 대상 월 (YYYYMM, 단일 선택).
|
||||
*/
|
||||
private String month;
|
||||
|
||||
/**
|
||||
* 선택된 앱의 clientId (null 또는 빈값이면 전체 앱).
|
||||
*/
|
||||
private String clientId;
|
||||
|
||||
@@ -33,15 +47,19 @@ public class ApiStatisticsSearchDto {
|
||||
this.clientId = null;
|
||||
}
|
||||
|
||||
public boolean isMonthly() {
|
||||
return mode == Mode.MONTHLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 앱이 선택되었는지 여부
|
||||
* 특정 앱이 선택되었는지 여부.
|
||||
*/
|
||||
public boolean hasSelectedApp() {
|
||||
return clientId != null && !clientId.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위가 유효한지 검증 (최대 40일)
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
if (startDate == null || endDate == null) {
|
||||
@@ -55,12 +73,13 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회 기간 일수 반환
|
||||
* 월(월별) 선택값이 YYYYMM 형식인지 검증 (가용월 여부는 서비스/드롭다운에서 제한).
|
||||
*/
|
||||
public long getDateRangeDays() {
|
||||
if (startDate == null || endDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return ChronoUnit.DAYS.between(startDate, endDate);
|
||||
public boolean isValidMonth() {
|
||||
return isYyyymm(month);
|
||||
}
|
||||
|
||||
private static boolean isYyyymm(String s) {
|
||||
return s != null && s.matches("^\\d{6}$");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-25
@@ -5,54 +5,72 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 전체 통계 요약 DTO
|
||||
* API 전체 통계 요약 DTO.
|
||||
*
|
||||
* 집계 소스: AGWAPP.API_STATS_HOUR / API_STATS_MONTH.
|
||||
* 성공/타임아웃/실패(오류)를 배타적으로 구분한다.
|
||||
* - 성공 = SUCCESS_CNT
|
||||
* - 타임아웃 = TIMEOUT_CNT
|
||||
* - 실패(오류) = SYSTEM_ERR_CNT + BIZ_ERR_CNT
|
||||
* - 호출수(total) = TOTAL_CNT
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatisticsSummaryDto {
|
||||
|
||||
private Long totalAttempted; // 전체 호출 건
|
||||
private Long totalCompleted; // 전체 성공 건
|
||||
private Long totalFailed; // 전체 실패 건
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double failureRate; // 실패율 (%)
|
||||
private Long totalCount; // 전체 호출 건 (TOTAL_CNT)
|
||||
private Long successCount; // 성공 건 (SUCCESS_CNT)
|
||||
private Long timeoutCount; // 타임아웃 건 (TIMEOUT_CNT)
|
||||
private Long errorCount; // 실패(오류) 건 (SYSTEM_ERR_CNT + BIZ_ERR_CNT)
|
||||
private Double successRate; // 성공율 (%)
|
||||
private Double timeoutRate; // 타임아웃율 (%)
|
||||
private Double errorRate; // 실패(오류)율 (%)
|
||||
|
||||
/**
|
||||
* Repository 조회용 생성자 (attempted, completed만)
|
||||
* Repository 조회용 생성자 (SUM 집계값).
|
||||
*/
|
||||
public ApiStatisticsSummaryDto(Long totalAttempted, Long totalCompleted) {
|
||||
this.totalAttempted = totalAttempted != null ? totalAttempted : 0L;
|
||||
this.totalCompleted = totalCompleted != null ? totalCompleted : 0L;
|
||||
public ApiStatisticsSummaryDto(Long totalCount, Long successCount, Long timeoutCount,
|
||||
Long systemErrCount, Long bizErrCount) {
|
||||
this.totalCount = nz(totalCount);
|
||||
this.successCount = nz(successCount);
|
||||
this.timeoutCount = nz(timeoutCount);
|
||||
this.errorCount = nz(systemErrCount) + nz(bizErrCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패건, 성공률, 실패율 계산
|
||||
* 성공률/타임아웃율/실패율 계산.
|
||||
*/
|
||||
public void calculateRates() {
|
||||
if (totalAttempted == null) {
|
||||
totalAttempted = 0L;
|
||||
}
|
||||
if (totalCompleted == null) {
|
||||
totalCompleted = 0L;
|
||||
}
|
||||
this.totalCount = nz(totalCount);
|
||||
this.successCount = nz(successCount);
|
||||
this.timeoutCount = nz(timeoutCount);
|
||||
this.errorCount = nz(errorCount);
|
||||
|
||||
this.totalFailed = totalAttempted - totalCompleted;
|
||||
|
||||
if (totalAttempted > 0) {
|
||||
this.successRate = Math.round((totalCompleted * 1000.0) / totalAttempted) / 10.0;
|
||||
this.failureRate = Math.round((totalFailed * 1000.0) / totalAttempted) / 10.0;
|
||||
if (totalCount > 0) {
|
||||
this.successRate = rate(successCount, totalCount);
|
||||
this.timeoutRate = rate(timeoutCount, totalCount);
|
||||
this.errorRate = rate(errorCount, totalCount);
|
||||
} else {
|
||||
this.successRate = 0.0;
|
||||
this.failureRate = 0.0;
|
||||
this.timeoutRate = 0.0;
|
||||
this.errorRate = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
static long nz(Long v) {
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
static double rate(long part, long total) {
|
||||
return Math.round((part * 1000.0) / total) / 10.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 통계 생성
|
||||
* 빈 통계 생성.
|
||||
*/
|
||||
public static ApiStatisticsSummaryDto empty() {
|
||||
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L);
|
||||
ApiStatisticsSummaryDto dto = new ApiStatisticsSummaryDto(0L, 0L, 0L, 0L, 0L);
|
||||
dto.calculateRates();
|
||||
return dto;
|
||||
}
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.portal.obp.entity.ObpGwMetric;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/**
|
||||
* API 통계 Repository
|
||||
* ObpGwMetric 테이블에서 통계 데이터를 조회
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface ApiStatisticsRepository extends BaseRepository<ObpGwMetric, String> {
|
||||
|
||||
/**
|
||||
* 전체 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummaryByOrgAndPeriod(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 전체 통계 조회 (특정 기간, 특정 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.clientId = :clientId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime")
|
||||
ApiStatisticsSummaryDto findSummaryByOrgAndApp(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("clientId") String clientId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 개별 API 통계 조회 (특정 기간, 특정 조직의 모든 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"m.apiId, m.apiName, " +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY m.apiId, m.apiName " +
|
||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetailByOrgAndPeriod(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 개별 API 통계 조회 (특정 기간, 특정 앱)
|
||||
*/
|
||||
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||
"m.apiId, m.apiName, " +
|
||||
"COALESCE(SUM(m.attemptedCount), 0), COALESCE(SUM(m.completedCount), 0)) " +
|
||||
"FROM ObpGwMetric m " +
|
||||
"WHERE m.orgId = :orgId " +
|
||||
"AND m.clientId = :clientId " +
|
||||
"AND m.timeslice BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY m.apiId, m.apiName " +
|
||||
"ORDER BY SUM(m.attemptedCount) DESC")
|
||||
List<ApiStatisticsDetailDto> findDetailByOrgAndApp(
|
||||
@Param("orgId") String orgId,
|
||||
@Param("clientId") String clientId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIRM21(스케줄러 잡) 조회 — 통계 집계 크론 추출용.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface JobInfoRepository extends JpaRepository<JobInfo, String> {
|
||||
|
||||
Optional<JobInfo> findByJobname(String jobname);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.statistics.repository.entity;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 스케줄러 잡 정보(EMSAPP.TSEAIRM21) — 통계 집계 크론 조회 전용.
|
||||
*
|
||||
* 안내 문구의 "매시 N분 집계" 값을 실제 크론(CRONEXP)에서 추출하기 위해 JOBNAME/CRONEXP 두 컬럼만 매핑한다.
|
||||
* (읽기 전용. 잡 실행/스케줄 변경은 eapim-admin 소관)
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIRM21")
|
||||
public class JobInfo {
|
||||
|
||||
@Id
|
||||
@Column(name = "JOBNAME")
|
||||
private String jobname;
|
||||
|
||||
@Column(name = "CRONEXP")
|
||||
private String cronexp;
|
||||
}
|
||||
+422
-64
@@ -1,21 +1,37 @@
|
||||
package com.eactive.apim.portal.apps.statistics.service;
|
||||
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsMonthRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||
import com.eactive.apim.gateway.data.statistics.repository.GwSvcInfoRepository;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsPeriodDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSearchDto;
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||
import com.eactive.apim.portal.apps.statistics.repository.ApiStatisticsRepository;
|
||||
import com.eactive.apim.portal.apps.statistics.repository.JobInfoRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -23,7 +39,10 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* API 통계 Service
|
||||
* API 통계 Service.
|
||||
*
|
||||
* 데이터 소스: Gateway DB(AGWAPP) API_STATS_HOUR(일별) / API_STATS_MONTH(월별).
|
||||
* 통계 테이블에 org_id 가 없으므로 org 소속 앱의 CLIENT_ID 목록으로 필터한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -31,79 +50,419 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiStatisticsService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final ApiStatisticsRepository apiStatisticsRepository;
|
||||
/** 시간별 집계 스케줄러 잡명 기본값 — PTL_PROPERTY(Portal/{@value #JOB_NAME_PROP})로 재정의 가능. */
|
||||
private static final String DEFAULT_HOURLY_JOB_NAME = "ApiStatsHourlyAggregationJob";
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String JOB_NAME_PROP = "stats.hourly.aggregation.job.name";
|
||||
|
||||
private static final DateTimeFormatter LATEST_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH시");
|
||||
private static final DateTimeFormatter YYYYMM_FMT = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
/** 요일 한글 한 글자 (월요일=index 0). */
|
||||
private static final String[] WEEKDAYS = {"월", "화", "수", "목", "금", "토", "일"};
|
||||
|
||||
private final GwAuthClientRepository gwAuthClientRepository;
|
||||
private final GwSvcInfoRepository gwSvcInfoRepository;
|
||||
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||
private final ApiStatsMonthRepository apiStatsMonthRepository;
|
||||
private final JobInfoRepository jobInfoRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 로그인 사용자 법인의 앱 목록 조회 (전체)
|
||||
* 로그인 사용자 법인의 앱 목록 조회 (전체). TSEAIAU01.ORGID 기준.
|
||||
* 드롭다운 값(CLIENTID)이 API_STATS_*.CLIENT_ID 와 동일해야 하므로 게이트웨이 인증 클라이언트를 소스로 한다.
|
||||
*/
|
||||
public List<Credential> getAppListByOrg(String orgId) {
|
||||
return credentialRepository.findAllByOrgid(orgId);
|
||||
public List<GwAuthClient> getAppListByOrg(String orgId) {
|
||||
return gwAuthClientRepository.findByOrgIdOrderByClientNameAsc(orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
* 조회 대상 clientId 목록. 특정 앱 선택 시 org 소속 검증 후 해당 1건, 아니면 org 전체 앱.
|
||||
* org→clientId 는 TSEAIAU01.ORGID(=PTL_ORG.ID) 로 조회한다.
|
||||
*/
|
||||
private List<String> resolveClientIds(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||
List<String> orgClientIds = orgClientIds(orgId);
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
String clientId = searchDto.getClientId();
|
||||
return orgClientIds.contains(clientId) ? Collections.singletonList(clientId) : Collections.emptyList();
|
||||
}
|
||||
return orgClientIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회 (일별/월별 모드 분기).
|
||||
*/
|
||||
public ApiStatisticsResultDto getStatistics(String orgId, ApiStatisticsSearchDto searchDto) {
|
||||
LocalDateTime startTime = searchDto.getStartDate().atStartOfDay();
|
||||
LocalDateTime endTime = searchDto.getEndDate().atTime(LocalTime.MAX);
|
||||
log.debug("[통계] 조회 시작 - orgId={}, mode={}, clientId={}, 일별[{}~{}], 월별[{}]",
|
||||
orgId, searchDto.getMode(), searchDto.getClientId(),
|
||||
searchDto.getStartDate(), searchDto.getEndDate(), searchDto.getMonth());
|
||||
|
||||
List<String> clientIds = resolveClientIds(orgId, searchDto);
|
||||
log.debug("[통계] org 스코핑 clientId {}건 - {}", clientIds.size(), clientIds);
|
||||
if (clientIds.isEmpty()) {
|
||||
log.debug("[통계] 대상 clientId 없음 - 빈 결과 반환 (orgId={})", orgId);
|
||||
return ApiStatisticsResultDto.empty();
|
||||
}
|
||||
|
||||
ApiStatisticsSummaryDto summary;
|
||||
List<ApiStatisticsDetailDto> details;
|
||||
List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
// 특정 앱 통계
|
||||
String clientId = searchDto.getClientId();
|
||||
if (searchDto.isMonthly()) {
|
||||
String month = searchDto.getMonth();
|
||||
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||
YearMonth ym = YearMonth.parse(month, YYYYMM_FMT);
|
||||
boolean isCurrent = month.equals(currentYm);
|
||||
LocalDate monthStart = ym.atDay(1);
|
||||
LocalDate monthEnd = isCurrent ? LocalDate.now() : ym.atEndOfMonth();
|
||||
|
||||
// 해당 앱이 조직 소속인지 검증
|
||||
if (!isAppBelongsToOrg(clientId, orgId)) {
|
||||
return ApiStatisticsResultDto.empty();
|
||||
if (isCurrent) {
|
||||
// 현재 월: DAY(과거일) + HOUR(오늘) 합산. 하단 일별표도 동일 소스.
|
||||
log.debug("[통계] 월별-현재월 {} → DAY+오늘(HOUR) {}~{}", month, monthStart, monthEnd);
|
||||
RangeStats rs = combineDayAndToday(clientIds, monthStart, monthEnd);
|
||||
summary = rs.summary;
|
||||
details = rs.details;
|
||||
periods = rs.periods;
|
||||
} else {
|
||||
// 과거 월: 요약/API별은 API_STATS_MONTH, 하단 일별표는 해당 월의 DAY 일자별
|
||||
log.debug("[통계] 월별-과거월 {} → API_STATS_MONTH + 하단 DAY 일별", month);
|
||||
summary = orEmpty(apiStatsMonthRepository.findSummary(clientIds, month, month));
|
||||
details = nvl(apiStatsMonthRepository.findDetail(clientIds, month, month));
|
||||
List<Object[]> monthlyDayRows = apiStatsDayRepository.findPeriodByDay(clientIds, monthStart, monthEnd);
|
||||
periods = buildDailyPeriods(monthlyDayRows, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
summary = apiStatisticsRepository.findSummaryByOrgAndApp(
|
||||
orgId, clientId, startTime, endTime);
|
||||
details = apiStatisticsRepository.findDetailByOrgAndApp(
|
||||
orgId, clientId, startTime, endTime);
|
||||
} else {
|
||||
// 전체 앱 통계
|
||||
summary = apiStatisticsRepository.findSummaryByOrgAndPeriod(
|
||||
orgId, startTime, endTime);
|
||||
details = apiStatisticsRepository.findDetailByOrgAndPeriod(
|
||||
orgId, startTime, endTime);
|
||||
// 일별 조회: DAY(과거일) + HOUR(오늘) 합산. (HOUR는 보존 짧음/오늘만, DAY는 과거 보존)
|
||||
LocalDate start = searchDto.getStartDate();
|
||||
LocalDate end = searchDto.getEndDate();
|
||||
log.debug("[통계] 일별 조회 - DAY+오늘(HOUR), 범위 {}~{}", start, end);
|
||||
RangeStats rs = combineDayAndToday(clientIds, start, end);
|
||||
summary = rs.summary;
|
||||
details = rs.details;
|
||||
periods = rs.periods;
|
||||
}
|
||||
|
||||
// null 체크 및 빈 결과 처리
|
||||
if (summary == null) {
|
||||
summary = ApiStatisticsSummaryDto.empty();
|
||||
} else {
|
||||
summary.calculateRates();
|
||||
}
|
||||
|
||||
if (details == null) {
|
||||
details = Collections.emptyList();
|
||||
} else {
|
||||
details.forEach(ApiStatisticsDetailDto::calculateRates);
|
||||
}
|
||||
if (periods == null) {
|
||||
periods = Collections.emptyList();
|
||||
} else {
|
||||
periods.forEach(ApiStatisticsPeriodDto::calculateRates);
|
||||
}
|
||||
|
||||
return new ApiStatisticsResultDto(summary, details);
|
||||
// API_NAME(인터페이스 ID) → 표시명(TSEAIHE01.EAISVCDESC) 매핑
|
||||
applyDisplayNames(details);
|
||||
|
||||
log.debug("[통계] 조회 완료 - 총호출={}, 성공={}, 타임아웃={}, 실패={} / API별 {}건, 기간별 {}건",
|
||||
summary.getTotalCount(), summary.getSuccessCount(), summary.getTimeoutCount(),
|
||||
summary.getErrorCount(), details.size(), periods.size());
|
||||
|
||||
return new ApiStatisticsResultDto(summary, details, periods);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱이 해당 조직 소속인지 검증
|
||||
* 일별 기간표 Object[] row → ApiStatisticsPeriodDto.
|
||||
* row: [0]=일자(String), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||
*/
|
||||
private boolean isAppBelongsToOrg(String clientId, String orgId) {
|
||||
return credentialRepository.findByClientidAndOrgid(clientId, orgId).isPresent();
|
||||
private static ApiStatisticsPeriodDto toPeriodDto(Object[] row) {
|
||||
return new ApiStatisticsPeriodDto(
|
||||
row[0] != null ? row[0].toString() : null,
|
||||
toLong(row[1]), toLong(row[2]), toLong(row[3]), toLong(row[4]), toLong(row[5]));
|
||||
}
|
||||
|
||||
private static Long toLong(Object o) {
|
||||
return (o instanceof Number) ? ((Number) o).longValue() : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드
|
||||
* 지정 날짜 범위 [start,end] 의 통계를 조립한다.
|
||||
* 과거일은 API_STATS_DAY, "오늘"은 아직 DAY 미집계이므로 API_STATS_HOUR 에서 합산해 더한다.
|
||||
*/
|
||||
private RangeStats combineDayAndToday(List<String> clientIds, LocalDate start, LocalDate end) {
|
||||
LocalDate today = LocalDate.now();
|
||||
boolean includesToday = !today.isBefore(start) && !today.isAfter(end);
|
||||
LocalDate dayEnd = includesToday ? today.minusDays(1) : end;
|
||||
|
||||
ApiStatisticsSummaryDto summary = ApiStatisticsSummaryDto.empty();
|
||||
List<ApiStatisticsDetailDto> details = new ArrayList<>();
|
||||
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||
|
||||
// 과거일: API_STATS_DAY [start .. dayEnd]
|
||||
if (!start.isAfter(dayEnd)) {
|
||||
summary = orEmpty(apiStatsDayRepository.findSummary(clientIds, start, dayEnd));
|
||||
details = nvl(apiStatsDayRepository.findDetail(clientIds, start, dayEnd));
|
||||
byDate = rowsToMap(apiStatsDayRepository.findPeriodByDay(clientIds, start, dayEnd));
|
||||
}
|
||||
|
||||
// 오늘: API_STATS_HOUR (익일 DAY 집계 전)
|
||||
if (includesToday) {
|
||||
LocalDateTime ts = today.atStartOfDay();
|
||||
LocalDateTime te = today.atTime(LocalTime.MAX);
|
||||
ApiStatisticsSummaryDto todaySummary = orEmpty(apiStatsHourRepository.findSummary(clientIds, ts, te));
|
||||
List<ApiStatisticsDetailDto> todayDetails = nvl(apiStatsHourRepository.findDetail(clientIds, ts, te));
|
||||
summary = mergeSummaries(summary, todaySummary);
|
||||
details = mergeDetails(details, todayDetails);
|
||||
if (nz(todaySummary.getTotalCount()) > 0) {
|
||||
ApiStatisticsPeriodDto todayRow = new ApiStatisticsPeriodDto();
|
||||
todayRow.setPeriod(today.format(DAY_FMT));
|
||||
todayRow.setTotalCount(todaySummary.getTotalCount());
|
||||
todayRow.setSuccessCount(todaySummary.getSuccessCount());
|
||||
todayRow.setTimeoutCount(todaySummary.getTimeoutCount());
|
||||
todayRow.setErrorCount(todaySummary.getErrorCount());
|
||||
todayRow.setHasData(true);
|
||||
byDate.put(todayRow.getPeriod(), todayRow);
|
||||
}
|
||||
log.debug("[통계] 오늘분 HOUR 합산 - total={}", todaySummary.getTotalCount());
|
||||
}
|
||||
|
||||
return new RangeStats(summary, details, fillDailyPeriods(byDate, start, end));
|
||||
}
|
||||
|
||||
private static Map<String, ApiStatisticsPeriodDto> rowsToMap(List<Object[]> rows) {
|
||||
Map<String, ApiStatisticsPeriodDto> byDate = new LinkedHashMap<>();
|
||||
if (rows != null) {
|
||||
for (Object[] r : rows) {
|
||||
ApiStatisticsPeriodDto dto = toPeriodDto(r);
|
||||
byDate.put(dto.getPeriod(), dto);
|
||||
}
|
||||
}
|
||||
return byDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 기간표를 지정 날짜 범위의 '모든 일자'로 채운다(데이터 없는 날은 hasData=false → 표에 '-').
|
||||
* 각 행 라벨은 "yyyy-MM-dd (요일)".
|
||||
*/
|
||||
private static List<ApiStatisticsPeriodDto> buildDailyPeriods(List<Object[]> rows, LocalDate start, LocalDate end) {
|
||||
return fillDailyPeriods(rowsToMap(rows), start, end);
|
||||
}
|
||||
|
||||
private static List<ApiStatisticsPeriodDto> fillDailyPeriods(Map<String, ApiStatisticsPeriodDto> byDate,
|
||||
LocalDate start, LocalDate end) {
|
||||
if (start == null || end == null) {
|
||||
return new ArrayList<>(byDate.values());
|
||||
}
|
||||
List<ApiStatisticsPeriodDto> result = new ArrayList<>();
|
||||
for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
|
||||
String key = d.format(DAY_FMT);
|
||||
String label = key + " (" + WEEKDAYS[d.getDayOfWeek().getValue() - 1] + ")";
|
||||
ApiStatisticsPeriodDto dto = byDate.get(key);
|
||||
if (dto == null) {
|
||||
dto = new ApiStatisticsPeriodDto();
|
||||
dto.setPeriod(label);
|
||||
dto.setTotalCount(0L);
|
||||
dto.setSuccessCount(0L);
|
||||
dto.setTimeoutCount(0L);
|
||||
dto.setErrorCount(0L);
|
||||
} else {
|
||||
dto.setPeriod(label);
|
||||
}
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <T> List<T> nvl(List<T> l) {
|
||||
return l != null ? new ArrayList<>(l) : new ArrayList<>();
|
||||
}
|
||||
|
||||
private static ApiStatisticsSummaryDto orEmpty(ApiStatisticsSummaryDto s) {
|
||||
return s != null ? s : ApiStatisticsSummaryDto.empty();
|
||||
}
|
||||
|
||||
private static long nz(Long v) {
|
||||
return v != null ? v : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 요약(DAY + 오늘 HOUR)을 합산. rate 는 이후 calculateRates 에서 재계산.
|
||||
*/
|
||||
private static ApiStatisticsSummaryDto mergeSummaries(ApiStatisticsSummaryDto a, ApiStatisticsSummaryDto b) {
|
||||
ApiStatisticsSummaryDto m = new ApiStatisticsSummaryDto();
|
||||
m.setTotalCount(nz(a.getTotalCount()) + nz(b.getTotalCount()));
|
||||
m.setSuccessCount(nz(a.getSuccessCount()) + nz(b.getSuccessCount()));
|
||||
m.setTimeoutCount(nz(a.getTimeoutCount()) + nz(b.getTimeoutCount()));
|
||||
m.setErrorCount(nz(a.getErrorCount()) + nz(b.getErrorCount()));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* API별 상세를 API_NAME 기준으로 합산(DAY + 오늘 HOUR). rate 는 이후 calculateRates 에서 재계산.
|
||||
*/
|
||||
private static List<ApiStatisticsDetailDto> mergeDetails(List<ApiStatisticsDetailDto> a,
|
||||
List<ApiStatisticsDetailDto> b) {
|
||||
Map<String, ApiStatisticsDetailDto> acc = new LinkedHashMap<>();
|
||||
accumulate(acc, a);
|
||||
accumulate(acc, b);
|
||||
List<ApiStatisticsDetailDto> merged = new ArrayList<>(acc.values());
|
||||
merged.sort(Comparator.comparing(ApiStatisticsDetailDto::getTotalCount, Comparator.reverseOrder()));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void accumulate(Map<String, ApiStatisticsDetailDto> acc, List<ApiStatisticsDetailDto> list) {
|
||||
if (list == null) {
|
||||
return;
|
||||
}
|
||||
for (ApiStatisticsDetailDto d : list) {
|
||||
ApiStatisticsDetailDto cur = acc.computeIfAbsent(d.getApiName(), k -> {
|
||||
ApiStatisticsDetailDto n = new ApiStatisticsDetailDto();
|
||||
n.setApiName(k);
|
||||
n.setTotalCount(0L);
|
||||
n.setSuccessCount(0L);
|
||||
n.setTimeoutCount(0L);
|
||||
n.setErrorCount(0L);
|
||||
return n;
|
||||
});
|
||||
cur.setTotalCount(nz(cur.getTotalCount()) + nz(d.getTotalCount()));
|
||||
cur.setSuccessCount(nz(cur.getSuccessCount()) + nz(d.getSuccessCount()));
|
||||
cur.setTimeoutCount(nz(cur.getTimeoutCount()) + nz(d.getTimeoutCount()));
|
||||
cur.setErrorCount(nz(cur.getErrorCount()) + nz(d.getErrorCount()));
|
||||
}
|
||||
}
|
||||
|
||||
/** combineDayAndToday 반환 홀더. */
|
||||
private static final class RangeStats {
|
||||
final ApiStatisticsSummaryDto summary;
|
||||
final List<ApiStatisticsDetailDto> details;
|
||||
final List<ApiStatisticsPeriodDto> periods;
|
||||
|
||||
RangeStats(ApiStatisticsSummaryDto summary, List<ApiStatisticsDetailDto> details,
|
||||
List<ApiStatisticsPeriodDto> periods) {
|
||||
this.summary = summary;
|
||||
this.details = details;
|
||||
this.periods = periods;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API별 상세의 apiName(인터페이스 ID)을 TSEAIHE01.EAISVCDESC(표시명)로 치환.
|
||||
* 매핑 없으면 인터페이스 ID 를 그대로 둔다.
|
||||
*/
|
||||
private void applyDisplayNames(List<ApiStatisticsDetailDto> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> ids = details.stream()
|
||||
.map(ApiStatisticsDetailDto::getApiName)
|
||||
.filter(n -> n != null && !n.trim().isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> descMap = gwSvcInfoRepository.findBySvcNameIn(ids).stream()
|
||||
.filter(s -> s.getSvcDesc() != null && !s.getSvcDesc().trim().isEmpty())
|
||||
.collect(Collectors.toMap(GwSvcInfo::getSvcName, GwSvcInfo::getSvcDesc, (x, y) -> x));
|
||||
details.forEach(d -> {
|
||||
String desc = descMap.get(d.getApiName());
|
||||
if (desc != null && !desc.trim().isEmpty()) {
|
||||
d.setApiName(desc);
|
||||
}
|
||||
});
|
||||
log.debug("[통계] API 표시명 매핑 - 대상 {}건, 매칭 {}건", ids.size(), descMap.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 모드에서 선택 가능한 월 목록(YYYYMM, 최신순).
|
||||
*/
|
||||
public List<String> getAvailableMonths(String orgId) {
|
||||
List<String> clientIds = orgClientIds(orgId);
|
||||
List<String> months = clientIds.isEmpty()
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(apiStatsMonthRepository.findAvailableMonths(clientIds));
|
||||
|
||||
// 데이터가 없더라도 현재 년-월은 항상 선택 가능하도록 포함 (최신순 유지)
|
||||
String currentYm = YearMonth.now().format(YYYYMM_FMT);
|
||||
if (!months.contains(currentYm)) {
|
||||
months.add(0, currentYm);
|
||||
}
|
||||
log.debug("[통계] 선택가능 월 {}건 (현재월 {} 포함) - orgId={}", months.size(), currentYm, orgId);
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 집계 시각 안내 문자열("yyyy-MM-dd HH시"). 데이터 없으면 null.
|
||||
*/
|
||||
public String getLatestStatTime(String orgId) {
|
||||
List<String> clientIds = orgClientIds(orgId);
|
||||
if (clientIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
LocalDateTime latest = apiStatsHourRepository.findLatestStatTime(clientIds);
|
||||
String result = latest != null ? latest.format(LATEST_FMT) : null;
|
||||
log.debug("[통계] 최신 집계 시각 - {} (orgId={})", result, orgId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간별 집계 잡의 크론에서 분(minute)을 추출한다. 파싱 불가 시 null → 문구는 "매시 집계"로 폴백.
|
||||
* Quartz 크론 필드: [sec] [min] [hour] ... → 인덱스 1이 분.
|
||||
*/
|
||||
@Transactional
|
||||
public Integer getAggregationMinute() {
|
||||
try {
|
||||
// 잡명은 PTL_PROPERTY(Portal/stats.hourly.aggregation.job.name)로 지정 가능(없으면 기본값 생성)
|
||||
String jobName = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, JOB_NAME_PROP, DEFAULT_HOURLY_JOB_NAME,
|
||||
"통계 시간별 집계 스케줄러 잡명(집계 주기 안내 문구용)");
|
||||
Integer minute = jobInfoRepository.findByJobname(jobName)
|
||||
.map(JobInfoCron::minuteOf)
|
||||
.orElse(null);
|
||||
log.debug("[통계] 집계 크론 분(N) = {} (job={})", minute, jobName);
|
||||
return minute;
|
||||
} catch (Exception e) {
|
||||
log.warn("집계 크론 조회 실패 - 안내 문구 분 표기 생략", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> orgClientIds(String orgId) {
|
||||
return gwAuthClientRepository.findClientIdsByOrgId(orgId).stream()
|
||||
.filter(id -> id != null && !id.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론에서 분 필드를 추출하는 헬퍼.
|
||||
*/
|
||||
private static final class JobInfoCron {
|
||||
static Integer minuteOf(com.eactive.apim.portal.apps.statistics.repository.entity.JobInfo job) {
|
||||
String cron = job.getCronexp();
|
||||
if (cron == null) {
|
||||
return null;
|
||||
}
|
||||
String[] fields = cron.trim().split("\\s+");
|
||||
if (fields.length >= 2 && fields[1].matches("^\\d{1,2}$")) {
|
||||
return Integer.parseInt(fields[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 다운로드.
|
||||
*
|
||||
* ※ UI에서 다운로드 버튼은 제거되었으나(요청사항), 백엔드 기능은 유지한다.
|
||||
* 외부/직접 호출로 재사용될 수 있어 엔드포인트(/statistics/api/download)와 함께 남겨둔다.
|
||||
*/
|
||||
public void downloadCsv(String orgId, ApiStatisticsSearchDto searchDto, HttpServletResponse response) throws IOException {
|
||||
log.debug("[통계] CSV 다운로드 - orgId={}, mode={}", orgId, searchDto.getMode());
|
||||
ApiStatisticsResultDto result = getStatistics(orgId, searchDto);
|
||||
|
||||
String fileName = String.format("API_Statistics_%s_%s.csv",
|
||||
searchDto.getStartDate().toString(),
|
||||
searchDto.getEndDate().toString());
|
||||
String rangeLabel = searchDto.isMonthly()
|
||||
? searchDto.getMonth()
|
||||
: searchDto.getStartDate() + " ~ " + searchDto.getEndDate();
|
||||
|
||||
String fileName = String.format("API_Statistics_%s.csv",
|
||||
rangeLabel.replace(" ", "").replace("~", "_"));
|
||||
|
||||
response.setContentType("text/csv; charset=UTF-8");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + "\"");
|
||||
@@ -114,53 +473,52 @@ public class ApiStatisticsService {
|
||||
response.getOutputStream().write(0xBF);
|
||||
|
||||
try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), true)) {
|
||||
// 조회 조건
|
||||
writer.println("조회 기간," + searchDto.getStartDate() + " ~ " + searchDto.getEndDate());
|
||||
writer.println("조회 기간," + rangeLabel);
|
||||
|
||||
String appName = "전체 앱";
|
||||
if (searchDto.hasSelectedApp()) {
|
||||
appName = credentialRepository.findById(searchDto.getClientId())
|
||||
.map(Credential::getClientname)
|
||||
appName = gwAuthClientRepository.findById(searchDto.getClientId())
|
||||
.map(GwAuthClient::getClientName)
|
||||
.orElse("선택된 앱");
|
||||
}
|
||||
writer.println("앱 구분," + appName);
|
||||
writer.println();
|
||||
|
||||
// 전체 통계
|
||||
ApiStatisticsSummaryDto summary = result.getSummary();
|
||||
writer.println("[전체 통계]");
|
||||
writer.println("총 호출 건," + summary.getTotalAttempted());
|
||||
writer.println("성공 건," + summary.getTotalCompleted() + "," + summary.getSuccessRate() + "%");
|
||||
writer.println("실패 건," + summary.getTotalFailed() + "," + summary.getFailureRate() + "%");
|
||||
writer.println("총 호출 건," + summary.getTotalCount());
|
||||
writer.println("성공 건," + summary.getSuccessCount() + "," + summary.getSuccessRate() + "%");
|
||||
writer.println("타임아웃 건," + summary.getTimeoutCount() + "," + summary.getTimeoutRate() + "%");
|
||||
writer.println("실패 건," + summary.getErrorCount() + "," + summary.getErrorRate() + "%");
|
||||
writer.println();
|
||||
|
||||
// API별 통계 헤더
|
||||
writer.println("[API별 통계]");
|
||||
writer.println("API명,호출 건,성공 건,성공율,실패 건,실패율");
|
||||
|
||||
// API별 통계 데이터
|
||||
writer.println("API명,호출 건,성공 건,타임아웃 건,실패 건");
|
||||
for (ApiStatisticsDetailDto detail : result.getDetails()) {
|
||||
writer.println(String.format("%s,%d,%d,%s%%,%d,%s%%",
|
||||
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||
escapeCsv(detail.getApiName()),
|
||||
detail.getAttemptedCount(),
|
||||
detail.getCompletedCount(),
|
||||
detail.getSuccessRate(),
|
||||
detail.getFailedCount(),
|
||||
detail.getFailureRate()));
|
||||
detail.getTotalCount(),
|
||||
detail.getSuccessCount(),
|
||||
detail.getTimeoutCount(),
|
||||
detail.getErrorCount()));
|
||||
}
|
||||
writer.println();
|
||||
|
||||
// 전체 합계 행
|
||||
writer.println(String.format("전체,%d,%d,%s%%,%d,%s%%",
|
||||
summary.getTotalAttempted(),
|
||||
summary.getTotalCompleted(),
|
||||
summary.getSuccessRate(),
|
||||
summary.getTotalFailed(),
|
||||
summary.getFailureRate()));
|
||||
writer.println("[기간별 통계]");
|
||||
writer.println("기간,호출 건,성공 건,타임아웃 건,실패 건");
|
||||
for (ApiStatisticsPeriodDto period : result.getPeriods()) {
|
||||
writer.println(String.format("%s,%d,%d,%d,%d",
|
||||
escapeCsv(period.getPeriod()),
|
||||
period.getTotalCount(),
|
||||
period.getSuccessCount(),
|
||||
period.getTimeoutCount(),
|
||||
period.getErrorCount()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 값 이스케이프 (쉼표, 따옴표 처리)
|
||||
* CSV 값 이스케이프 (쉼표, 따옴표 처리).
|
||||
*/
|
||||
private String escapeCsv(String value) {
|
||||
if (value == null) return "";
|
||||
|
||||
@@ -4,11 +4,14 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
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 {
|
||||
@@ -25,6 +28,12 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private ClientGuardService clientGuardService;
|
||||
|
||||
@Autowired
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -60,6 +69,17 @@ public class GlobalControllerAdvice {
|
||||
return userSessionService.getSessionTimeoutMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
|
||||
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
|
||||
*/
|
||||
@ModelAttribute("sessionKeepAliveAllowed")
|
||||
public boolean sessionKeepAliveAllowed() {
|
||||
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
|
||||
return !prod && userSessionService.isKeepAliveEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
|
||||
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
|
||||
@@ -77,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", "고객센터 연락처");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -11,4 +11,7 @@ public class InquiryCommentCreateRequest {
|
||||
@NotBlank(message = "댓글 내용을 입력해주세요.")
|
||||
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
|
||||
private String content;
|
||||
|
||||
/** 공개범위(ALL=공개, PRIVATE=비공개). 미지정 시 공개. */
|
||||
private String visibility;
|
||||
}
|
||||
|
||||
+9
@@ -26,4 +26,13 @@ public class InquiryCommentDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private boolean deletable;
|
||||
|
||||
/** 공개범위(ALL/PRIVATE). */
|
||||
private String visibility;
|
||||
|
||||
/** 비공개 댓글 여부 — 허용 독자에게는 "비공개" 태그로 표시. */
|
||||
private boolean privateComment;
|
||||
|
||||
/** 비공개 댓글이지만 열람 권한이 없어 "비공개 댓글"로만 노출되는지 여부. */
|
||||
private boolean privatePlaceholder;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+109
-32
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+157
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+106
@@ -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();
|
||||
}
|
||||
}
|
||||
+110
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package com.eactive.apim.portal.djb.webhook.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.SessionAttributes;
|
||||
import org.springframework.web.bind.support.SessionStatus;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
|
||||
*
|
||||
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
|
||||
* 참조: {@code apps/app/controller/MyAppController}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/webhook")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||
public class WebhookController {
|
||||
// 클래스 기본: 법인 관리자(ROLE_API_KEY_REQUEST)만 신청/수정/삭제/Secret 관리 가능.
|
||||
// 조회(index)만 메서드 레벨에서 ROLE_APP 으로 완화(같은 기관 일반 사용자도 열람).
|
||||
|
||||
private static final int TOTAL_STEPS = 3;
|
||||
|
||||
private final WebhookService webhookService;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
@ModelAttribute("webhookRegistration")
|
||||
public WebhookRegistrationDTO webhookRegistration() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
@ModelAttribute("webhookModification")
|
||||
public WebhookRegistrationDTO webhookModification() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
// ============================ 조회 ============================
|
||||
|
||||
@Secured("ROLE_APP")
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
String orgId = currentOrgId();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
|
||||
if (webhook.isPresent()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
|
||||
mav.addObject("webhook", webhook.get());
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("apps/webhook/webhookEmpty");
|
||||
}
|
||||
|
||||
// ======================= 신규 신청 플로우 =======================
|
||||
|
||||
@GetMapping("/register/step1")
|
||||
public ModelAndView registerStep1(
|
||||
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
Model model) {
|
||||
|
||||
if (webhookService.existsByOrg(currentOrgId())) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
if (clear) {
|
||||
sessionStatus.setComplete();
|
||||
registration = new WebhookRegistrationDTO();
|
||||
model.addAttribute("webhookRegistration", registration);
|
||||
}
|
||||
registration.setRequestType("NEW");
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/register/step1")
|
||||
public ModelAndView processStep1(
|
||||
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(registration, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/register/step2")
|
||||
public ModelAndView registerStep2(
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (!registration.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
|
||||
@PostMapping("/register/step2/save")
|
||||
public ModelAndView saveStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/register/step2")
|
||||
public ModelAndView processStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
registration.setSelectedApis(selectedApis);
|
||||
if (!registration.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
try {
|
||||
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
|
||||
webhookService.create(registration, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/register/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/register/step3")
|
||||
public ModelAndView registerStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/register/cancel")
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ========================= 수정 플로우 =========================
|
||||
|
||||
@GetMapping("/modify/step1")
|
||||
public ModelAndView modifyStep1(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
Model model) {
|
||||
|
||||
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
|
||||
if (!current.isPresent()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
|
||||
}
|
||||
WebhookDTO webhook = current.get();
|
||||
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
|
||||
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
|
||||
modification.setId(webhook.getId());
|
||||
modification.setRequestType("MODIFY");
|
||||
modification.setTargetUrl(webhook.getTargetUrl());
|
||||
modification.setEventTypes(webhook.getEventTypes().stream()
|
||||
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
|
||||
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
|
||||
model.addAttribute("webhookModification", modification);
|
||||
}
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step1")
|
||||
public ModelAndView processModifyStep1(
|
||||
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(modification, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step2")
|
||||
public ModelAndView modifyStep2(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
|
||||
if (modification.getId() == null || !modification.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
|
||||
@PostMapping("/modify/step2/save")
|
||||
public ModelAndView saveModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step2")
|
||||
public ModelAndView processModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
modification.setSelectedApis(selectedApis);
|
||||
if (modification.getId() == null || !modification.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
try {
|
||||
webhookService.update(modification.getId(), modification, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step3")
|
||||
public ModelAndView modifyStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/modify/cancel")
|
||||
public String cancelModification(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ==================== AJAX (비밀번호 재인증) ====================
|
||||
|
||||
@PostMapping("/verify-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> verifySecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
result.put("success", true);
|
||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/regenerate-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
result.put("secret", secret);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ResponseBody
|
||||
public Map<String, Object> delete(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
webhookService.delete(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ helper ============================
|
||||
|
||||
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
|
||||
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||
if (!bindingResult.hasFieldErrors("targetUrl")
|
||||
&& !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
bindingResult.rejectValue("targetUrl", "invalid.url",
|
||||
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||
}
|
||||
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
|
||||
"EventType을 1개 이상 선택해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyPassword(String password) {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return appServiceFacade.verifyUserPassword(user, password);
|
||||
}
|
||||
|
||||
private String currentOrgId() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||
}
|
||||
|
||||
private void addStepModel(ModelAndView mav, int currentStep) {
|
||||
mav.addObject("currentStep", currentStep);
|
||||
mav.addObject("totalSteps", TOTAL_STEPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookCreatedResult {
|
||||
|
||||
private final Long id;
|
||||
private final String secret;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String targetUrl;
|
||||
private String secretMasked;
|
||||
private String createdDate;
|
||||
|
||||
/** 구독 API ID 목록. */
|
||||
private List<String> apiIds = new ArrayList<>();
|
||||
|
||||
/** 구독 EventType(코드+한글명) 목록. */
|
||||
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
|
||||
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WebhookEventTypeDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String code;
|
||||
private String name;
|
||||
private boolean selected;
|
||||
|
||||
public WebhookEventTypeDTO(String code, String name) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
|
||||
*
|
||||
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookRegistrationDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
|
||||
private Long id;
|
||||
|
||||
/** "NEW" 또는 "MODIFY" */
|
||||
private String requestType;
|
||||
|
||||
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
|
||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||
private String targetUrl;
|
||||
|
||||
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||
private List<String> eventTypes = new ArrayList<>();
|
||||
|
||||
/** Step2: 알림 대상 API ID 목록. */
|
||||
private List<String> selectedApis = new ArrayList<>();
|
||||
|
||||
public boolean isStep1Complete() {
|
||||
return targetUrl != null && !targetUrl.trim().isEmpty()
|
||||
&& eventTypes != null && !eventTypes.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isStep2Complete() {
|
||||
return selectedApis != null && !selectedApis.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
|
||||
*/
|
||||
public class WebhookAlreadyExistsException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookAlreadyExistsException(String orgId) {
|
||||
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* 대상 Webhook 신청을 찾을 수 없음.
|
||||
*/
|
||||
public class WebhookNotFoundException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookNotFoundException(Long id) {
|
||||
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.eactive.apim.portal.djb.webhook.mapper;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/**
|
||||
* WebhookRequest → WebhookDTO 기본 필드 매핑.
|
||||
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface WebhookMapper {
|
||||
|
||||
@Mapping(target = "secretMasked", ignore = true)
|
||||
@Mapping(target = "apiIds", ignore = true)
|
||||
@Mapping(target = "eventTypes", ignore = true)
|
||||
WebhookDTO toDto(WebhookRequest entity);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
|
||||
|
||||
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
|
||||
|
||||
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
|
||||
|
||||
Optional<WebhookRequest> findByOrgId(String orgId);
|
||||
|
||||
boolean existsByOrgId(String orgId);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.SequenceGenerator;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
|
||||
*
|
||||
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
|
||||
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
|
||||
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
|
||||
* {@link #onCreate()} 에서 직접 세팅한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ")
|
||||
public class WebhookRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
|
||||
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
|
||||
@Column(name = "ID")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "ORG_ID", length = 36)
|
||||
private String orgId;
|
||||
|
||||
@Column(name = "TARGET_URL", length = 255)
|
||||
private String targetUrl;
|
||||
|
||||
@Column(name = "SECRET", length = 500)
|
||||
private String secret;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
|
||||
* 복합키(WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_API")
|
||||
@IdClass(WebhookRequestApiId.class)
|
||||
public class WebhookRequestApi implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_ID", length = 30)
|
||||
private String apiId;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestApi() {
|
||||
}
|
||||
|
||||
public WebhookRequestApi(Long webhookReqId, String apiId) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.apiId = apiId;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class WebhookRequestApiId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long webhookReqId;
|
||||
private String apiId;
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
|
||||
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
|
||||
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
|
||||
@IdClass(WebhookRequestEventId.class)
|
||||
public class WebhookRequestEvent implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "EVENT_TYPE", length = 36)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestEvent() {
|
||||
}
|
||||
|
||||
public WebhookRequestEvent(Long webhookReqId, String eventType) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user