웹훅 발송 내역 신규
eapim-admin CI / build (push) Has been cancelled

This commit is contained in:
eastargh
2026-07-02 14:46:02 +09:00
parent 292e27e66e
commit 7b12ef2fb5
10 changed files with 730 additions and 10 deletions
@@ -258,14 +258,17 @@ public interface MonitoringContext {
// 자동 로그아웃 타임아웃 (단위: 분, 기본값: 10)
public static final String RMS_AUTO_LOGOUT_TIMEOUT = "rms.auto.logout.timeout";
// 웹훅 재전송 설정
public static final String API_WEBHOOK_RETRY_COUNT = "api.webhook.retry_count";
public static final String API_WEBHOOK_RETRY_TIME = "api.webhook.retry_time";
// 웹훅
public static final String RMS_WEBHOOK_REVERSE_PROXY_URL = "rms.webhook.reverse_proxy.url";
public static final String RMS_WEBHOOK_RETRY_COUNT = "rms.webhook.retry_count";
public static final String RMS_WEBHOOK_RETRY_TIME = "rms.webhook.retry_time";
//비밀번호 초기화 접미사
public static final String RMS_PASSWORD_INIT_SUBFIX = "rms.password.init.subfix";
public abstract String getStringProperty(String propertyName);
@@ -0,0 +1,65 @@
package com.eactive.eai.rms.ext.djb.webhook.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.eactive.eai.rms.common.base.BaseAnnotationController;
import com.eactive.eai.rms.common.combo.ComboService;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendLogUI;
import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendLogUISearch;
import com.eactive.eai.rms.ext.djb.webhook.service.WebhookSendLogManService;
import lombok.RequiredArgsConstructor;
/**
* 웹훅 발송 로그(PTL_WEBHOOK_SEND_LOG) 조회 화면 컨트롤러.
*
* <p>조회 전용 (등록/수정/삭제 없음)</p>
*/
@Controller
@RequiredArgsConstructor
public class WebhookSendLogManController extends BaseAnnotationController {
private final WebhookSendLogManService webhookSendLogManService;
private final ComboService comboService;
@GetMapping(value = "/onl/apim/webhook/webhookSendLogMan.view")
public void view() {
// 목록 view
}
@GetMapping(value = "/onl/apim/webhook/webhookSendLogMan.view", params = "cmd=DETAIL")
public String detailView() {
return "/onl/apim/webhook/webhookSendLogManDetail";
}
@PostMapping(value = "/onl/apim/webhook/webhookSendLogMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<WebhookSendLogUI>> selectList(
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
WebhookSendLogUISearch search) {
Page<WebhookSendLogUI> page = webhookSendLogManService.selectList(pageable, search);
return ResponseEntity.ok(new GridResponse<>(page));
}
@PostMapping(value = "/onl/apim/webhook/webhookSendLogMan.json", params = "cmd=LIST_INIT_COMBO")
public ResponseEntity<Map<String, Object>> initCombo() {
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("eventTypeList", comboService.getMonitoringCodeSortedBySeq(WebhookSendLogManService.CODE_GROUP_EVENT_TYPE));
return ResponseEntity.ok(resultMap);
}
@PostMapping(value = "/onl/apim/webhook/webhookSendLogMan.json", params = "cmd=DETAIL")
public ResponseEntity<WebhookSendLogUI> selectDetail(Long id) {
return ResponseEntity.ok(webhookSendLogManService.selectDetail(id));
}
}
@@ -0,0 +1,37 @@
package com.eactive.eai.rms.ext.djb.webhook.dto;
import lombok.Data;
/**
* 웹훅 발송 로그(PTL_WEBHOOK_SEND_LOG) 화면 응답 DTO.
*
* <p>목록에서는 일부 항목만 사용하고, 상세에서는 전체 항목을 사용한다.
* 날짜는 yyyyMMdd HH:mm:ss 형식 문자열로 제공한다.</p>
*/
@Data
public class WebhookSendLogUI {
private Long id;
private String orgId;
private String orgName;
private String eventType;
/** 모니터링 공통코드(EVENT_TYPE) 코드명. 상세 조회에서만 채워진다(목록은 화면에서 콤보로 변환). */
private String eventTypeName;
private String targetUrl;
private String payload;
private String signature;
private Integer statusCode;
private String responseBody;
private Boolean success;
private String errorMessage;
private Integer retryCount;
/* 등록/발송 시간 — yyyyMMdd HH:mm:ss */
private String createdAt;
private String sentAt;
}
@@ -0,0 +1,23 @@
package com.eactive.eai.rms.ext.djb.webhook.dto;
import lombok.Data;
@Data
public class WebhookSendLogUISearch {
/* 기관명 (ptl_org.org_name, LIKE 검색) */
private String searchOrgName;
/* 이벤트 유형 (모니터링 공통코드 EVENT_TYPE, 콤보 선택 값과 정확히 일치) */
private String searchEventType;
/* 수신 URL (LIKE 검색) */
private String searchTargetUrl;
/* 성공여부 (Y/N, 공백=전체) */
private String searchSuccess;
/* 등록일시 기간 검색 (yyyyMMdd, 8자리 — userLoginHistoryMan.jsp 기간검색 스타일) */
private String searchStartDate;
private String searchEndDate;
}
@@ -10,5 +10,6 @@ public class WebhookSendRequest {
private String targetUrl;
private String eventType;
private String secret;
private String reverseProxyPath;
private Object data;
}
@@ -4,17 +4,17 @@ package com.eactive.eai.rms.ext.djb.webhook.repository;
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;
import org.springframework.stereotype.Repository;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.WebhookSendLog;
@Repository
@EMSDataSource
public interface WebhookSendLogRepository extends JpaRepository<WebhookSendLog, Long> {
public interface WebhookSendLogRepository extends BaseRepository<WebhookSendLog, Long> {
// 오라클 CHAR(1) Y/N 기준 조회
@Query("SELECT l FROM WebhookSendLog l " +
@@ -0,0 +1,172 @@
package com.eactive.eai.rms.ext.djb.webhook.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.apim.portal.monitoringcode.entity.MonitoringCode;
import com.eactive.apim.portal.monitoringcode.entity.MonitoringCodeId;
import com.eactive.apim.portal.portalorg.entity.QPortalOrg;
import com.eactive.eai.rms.common.base.BaseService;
import com.eactive.eai.rms.common.util.StringUtils;
import com.eactive.eai.rms.data.entity.man.monitoringCode.repository.MonitoringCodeRepository;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookSendLog;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.WebhookSendLog;
import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendLogUI;
import com.eactive.eai.rms.ext.djb.webhook.dto.WebhookSendLogUISearch;
import com.eactive.eai.rms.ext.djb.webhook.repository.WebhookSendLogRepository;
import com.eactive.eai.rms.onl.common.exception.BizException;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
/**
* 웹훅 발송 로그(PTL_WEBHOOK_SEND_LOG) 조회 화면 서비스.
*
* <p>조회 전용. orgId 는 엔티티 연관관계가 아닌 단순 컬럼이므로,
* 기관명은 QueryDSL 로 ptl_org 를 별도 조회하여 매핑한다.</p>
*/
@Service
@RequiredArgsConstructor
@Transactional(transactionManager = "transactionManagerForEMS")
public class WebhookSendLogManService extends BaseService {
/** 모니터링 공통코드(tseairm28) 상의 웹훅 이벤트유형 코드 그룹. */
public static final String CODE_GROUP_EVENT_TYPE = "EVENT_TYPE";
private static final DateTimeFormatter FMT_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter FMT_YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd");
private final WebhookSendLogRepository webhookSendLogRepository;
private final MonitoringCodeRepository monitoringCodeRepository;
@PersistenceContext(unitName = "entityManagerFactoryForEMS")
private EntityManager entityManager;
public Page<WebhookSendLogUI> selectList(Pageable pageable, WebhookSendLogUISearch search) {
QWebhookSendLog log = QWebhookSendLog.webhookSendLog;
BooleanBuilder predicate = new BooleanBuilder();
if (StringUtils.isNotBlank(search.getSearchOrgName())) {
List<String> orgIds = findOrgIdsByName(search.getSearchOrgName());
if (orgIds.isEmpty()) {
// 일치하는 기관이 없으면 쿼리를 실행할 필요 없이 빈 결과를 바로 반환한다.
// (QueryDSL의 where false 는 이 프로젝트의 Hibernate HQL 파서에서 지원하지 않음)
return Page.empty(pageable);
}
predicate.and(log.orgId.in(orgIds));
}
if (StringUtils.isNotBlank(search.getSearchEventType())) {
predicate.and(log.eventType.eq(search.getSearchEventType()));
}
if (StringUtils.isNotBlank(search.getSearchTargetUrl())) {
predicate.and(log.targetUrl.containsIgnoreCase(search.getSearchTargetUrl()));
}
if (StringUtils.isNotBlank(search.getSearchSuccess())) {
predicate.and(log.success.eq(search.getSearchSuccess()));
}
if (StringUtils.isNotBlank(search.getSearchStartDate())) {
predicate.and(log.createdAt.goe(parseYYYYMMDD(search.getSearchStartDate()).atStartOfDay()));
}
if (StringUtils.isNotBlank(search.getSearchEndDate())) {
predicate.and(log.createdAt.loe(parseYYYYMMDD(search.getSearchEndDate()).atTime(23, 59, 59)));
}
Page<WebhookSendLog> page = webhookSendLogRepository.findAll(predicate, pageable);
Map<String, String> orgNameMap = findOrgNames(page.getContent());
// 목록의 이벤트유형 코드명은 화면에서 콤보(LIST_INIT_COMBO) 기준으로 변환하므로 서버에서는 조회하지 않는다(N+1 방지).
return page.map(e -> convertToUI(e, orgNameMap.get(e.getOrgId()), null));
}
public WebhookSendLogUI selectDetail(Long id) {
WebhookSendLog entity = webhookSendLogRepository.findById(id)
.orElseThrow(() -> new BizException("존재하지 않는 웹훅 발송 로그입니다."));
Map<String, String> orgNameMap = findOrgNames(Collections.singletonList(entity));
String eventTypeName = resolveEventTypeName(entity.getEventType());
return convertToUI(entity, orgNameMap.get(entity.getOrgId()), eventTypeName);
}
private static LocalDate parseYYYYMMDD(String yyyyMMdd) {
return LocalDate.parse(yyyyMMdd, FMT_YYYYMMDD);
}
/** 모니터링 공통코드(EVENT_TYPE)에서 코드명을 조회한다. 코드가 없으면 원본 코드를 그대로 반환한다. */
private String resolveEventTypeName(String eventType) {
if (StringUtils.isBlank(eventType)) {
return eventType;
}
return monitoringCodeRepository.findById(new MonitoringCodeId(CODE_GROUP_EVENT_TYPE, eventType))
.map(MonitoringCode::getCodeName)
.orElse(eventType);
}
/** 기관명 LIKE 검색으로 매칭되는 orgId 목록. */
private List<String> findOrgIdsByName(String orgName) {
QPortalOrg org = QPortalOrg.portalOrg;
return new JPAQueryFactory(entityManager)
.select(org.id)
.from(org)
.where(org.orgName.containsIgnoreCase(orgName))
.fetch();
}
/** 목록/상세에 필요한 orgId → orgName 매핑 (일괄 조회로 N+1 방지). */
private Map<String, String> findOrgNames(List<WebhookSendLog> logs) {
Set<String> orgIds = logs.stream()
.map(WebhookSendLog::getOrgId)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
if (orgIds.isEmpty()) {
return Collections.emptyMap();
}
QPortalOrg org = QPortalOrg.portalOrg;
return new JPAQueryFactory(entityManager)
.select(org.id, org.orgName)
.from(org)
.where(org.id.in(orgIds))
.fetch()
.stream()
.collect(Collectors.toMap(t -> t.get(org.id), t -> t.get(org.orgName)));
}
private WebhookSendLogUI convertToUI(WebhookSendLog e, String orgName, String eventTypeName) {
WebhookSendLogUI ui = new WebhookSendLogUI();
ui.setId(e.getId());
ui.setOrgId(e.getOrgId());
ui.setOrgName(orgName);
ui.setEventType(e.getEventType());
ui.setEventTypeName(eventTypeName);
ui.setTargetUrl(e.getTargetUrl());
ui.setPayload(e.getPayload());
ui.setSignature(e.getSignature());
ui.setStatusCode(e.getStatusCode());
ui.setResponseBody(e.getResponseBody());
ui.setSuccess(e.getSuccess());
ui.setErrorMessage(e.getErrorMessage());
ui.setRetryCount(e.getRetryCount());
ui.setCreatedAt(fmt(e.getCreatedAt(), FMT_DATETIME));
ui.setSentAt(fmt(e.getSentAt(), FMT_DATETIME));
return ui;
}
private static String fmt(LocalDateTime t, DateTimeFormatter f) {
return t == null ? "" : t.format(f);
}
}
@@ -22,6 +22,7 @@ import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
import com.eactive.apim.portal.portalorg.entity.QPortalOrg;
import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReq;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReqApi;
@@ -60,24 +61,28 @@ public class WebhookService {
QWebhookReq req = QWebhookReq.webhookReq;
QWebhookReqEvent evt = QWebhookReqEvent.webhookReqEvent;
QWebhookReqApi api = QWebhookReqApi.webhookReqApi;
QPortalOrg org = QPortalOrg.portalOrg;
String reverseProxyUrl = monitoringContext.getProperty(MonitoringContext.RMS_WEBHOOK_REVERSE_PROXY_URL);
List<Tuple> tuples = new JPAQueryFactory(entityManager)
.select(req.orgId, req.targetUrl, req.secret, api.id.apiId)
.select(req.orgId, req.targetUrl, req.secret, org.reverseProxyPath, api.id.apiId)
.from(req)
.join(evt).on(req.id.eq(evt.id.webhookReqId))
.join(api).on(req.id.eq(api.id.webhookReqId))
.join(org).on(req.orgId.eq(org.id))
.where(evt.id.eventType.eq(event)
.and(api.id.apiId.in(apiIds)))
.fetch();
// orgId + targetUrl + secret 기준으로 그룹핑, apiId를 data(List)로 집계
// orgId + targetUrl + secret 기준으로 그룹핑, apiIds를 data(List)로 집계
Map<String, WebhookSendRequest> resultMap = new LinkedHashMap<>();
for (Tuple t : tuples) {
String key = t.get(req.orgId) + "|" + t.get(req.targetUrl);
resultMap.computeIfAbsent(key, k -> {
WebhookSendRequest wr = new WebhookSendRequest();
wr.setOrgId(t.get(req.orgId));
wr.setTargetUrl(t.get(req.targetUrl));
wr.setTargetUrl(this.convertTargetUrl(reverseProxyUrl, t.get(org.reverseProxyPath), t.get(req.targetUrl)));
wr.setSecret(t.get(req.secret));
wr.setEventType(event);
wr.setData(new ArrayList<String>());
@@ -88,6 +93,46 @@ public class WebhookService {
return new ArrayList<>(resultMap.values());
}
//reverse proxy 서버로 발송하기 위한 url 만든다
private String convertTargetUrl(String proxyUrl, String path, String targetUrl) {
String base = stripTrailingSlash(proxyUrl);
String normalizedPath = stripSlashes(path);
// scheme://host[:port] 부분만 제거하고 이후 path/query/fragment는 그대로 유지
String targetPath = targetUrl.replaceFirst("^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+", "");
StringBuilder url = new StringBuilder(base);
if (!normalizedPath.isEmpty()) {
url.append('/').append(normalizedPath);
}
url.append(targetPath);
return url.toString();
}
private String stripTrailingSlash(String value) {
if (value == null) {
return "";
}
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') {
end--;
}
return value.substring(0, end);
}
private String stripSlashes(String value) {
if (value == null) {
return "";
}
String result = value.trim();
while (result.startsWith("/")) {
result = result.substring(1);
}
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* 웹훅 발송 메인 메서드
@@ -123,8 +168,8 @@ public class WebhookService {
headers.set("X-Webhook-Timestamp", String.valueOf(webhookPayload.getTimestamp()));
// 4. 발송 (재시도 포함)
int retryCount = monitoringContext.getIntProperty(MonitoringContext.API_WEBHOOK_RETRY_COUNT, DEFAULT_RETRY_COUNT);
int retryTime = monitoringContext.getIntProperty(MonitoringContext.API_WEBHOOK_RETRY_TIME, DEFAULT_RETRY_TIME);
int retryCount = monitoringContext.getIntProperty(MonitoringContext.RMS_WEBHOOK_RETRY_COUNT, DEFAULT_RETRY_COUNT);
int retryTime = monitoringContext.getIntProperty(MonitoringContext.RMS_WEBHOOK_RETRY_TIME, DEFAULT_RETRY_TIME);
sendLog.setSentAt(LocalDateTime.now());
HttpEntity<String> httpRequest = new HttpEntity<>(payloadJson, headers);
ResponseEntity<String> response = sendWithRetry(targetUrl, httpRequest, sendLog, retryCount, retryTime);