Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled

This commit is contained in:
eastargh
2026-07-31 10:24:41 +09:00
9 changed files with 502 additions and 28 deletions
@@ -56,11 +56,16 @@
$("#expectEndDate").datepicker().inputmask("yyyy-mm-dd", {'autoUnmask': true});
$("#btn_app_approve").click(function () {
if (confirm("승인하시겠습니까?")) {
var isDeleteReq = (currentRequestType === 'DELETE');
var gwAction = (isDeleteReq && $('#chkGwDelete').is(':checked')) ? 'DELETE' : 'BLOCK';
var confirmMsg = isDeleteReq
? "해지 승인 시 포털 인증키(Credential)는 삭제되며,\n게이트웨이 클라이언트는 [" + (gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)") + "] 처리됩니다.\n승인하시겠습니까?"
: "승인하시겠습니까?";
if (confirm(confirmMsg)) {
$.ajax({
type: "POST",
url: url,
data: {cmd: "APPROVE", id: key},
data: {cmd: "APPROVE", id: key, gwAction: gwAction},
success: function (args) {
<%--alert("<%= localeMessage.getString("approval.approveSuccess") %>");--%>
alert("승인되었습니다.");
@@ -176,6 +181,8 @@
});
});
var currentRequestType = null; // 현재 신청 유형 (DELETE 승인 시 GW 처리방식 선택 노출용)
function detail(key) {
$.ajax({
type: "POST",
@@ -216,6 +223,21 @@
document.getElementById("btn_app_reject").style.display = "none";
}
// API 이용해지(DELETE) 건: 승인 가능 상태의 현재 승인자에게만 GW 처리방식 선택 노출
currentRequestType = data.requestType;
var inProgress = data.approvalStatus.code === 'REQUESTED' || data.approvalStatus.code === 'PROCESSING';
if (data.requestType === 'DELETE' && isCurrentApprover && inProgress) {
$("#gwActionSection").show();
} else {
$("#gwActionSection").hide();
}
// 처리 완료된 해지 건은 어떤 방식으로 실행됐는지 표시
if (data.requestType === 'DELETE' && data.gwAction) {
$("#gwActionResult").text("GW 처리방식: " + (data.gwAction === 'DELETE' ? "완전 삭제" : "차단(비활성화)")).show();
} else {
$("#gwActionResult").hide();
}
// 삭제된 승인자(USERID 없음)로 진행 불가한 건: 요청됨/진행중 상태면 승인자가 아니어도 강제 반려 허용
var hasDeletedApprover = data.approvers.some(approver =>
!approver.user || !approver.user.USERID
@@ -370,6 +392,13 @@
</tbody>
</table>
</div>
<div id="gwActionSection" style="display:none; margin: 12px 0 0 4px; color: #c0392b;">
<label style="cursor:pointer;">
<input type="checkbox" id="chkGwDelete"/>
GW 인증정보 완전 삭제 (미체크 시 차단 처리 &mdash; 인증정보 보존, 복구 가능)
</label>
</div>
<div id="gwActionResult" style="display:none; margin: 12px 0 0 4px; font-weight: bold;"></div>
<div class="popup_button">
<button type="button" class="cssbtn" id="btn_modify" level="W"><i
class="material-icons">save</i>수정
@@ -0,0 +1,9 @@
package com.eactive.eai.rms.data.entity.onl.djb.apistatus;
/**
* EAI 서비스명 조회 결과 (TSEAIHE01)
*/
public interface ApiNameRow {
String getEaisvcname();
String getEaisvcdesc();
}
@@ -1,18 +1,90 @@
package com.eactive.eai.rms.ext.djb.apistatus;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
/**
* API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다.
*
* <p>탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 초안으로 기록한다.</p>
*
* <p>점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다
* ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정).
* 따라서 CONTROL 이벤트를 다시 기록하면 순환이 되므로 기록하지 않는다.</p>
*/
@Service
@Transactional
@Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class)
public class ApiStatusDetectionService {
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
public static final String EVENT_CONTROL_START = "CONTROL_START";
public static final String EVENT_CONTROL_END = "CONTROL_END";
public static final String EVENT_ERROR_START = "ERROR_START";
public static final String EVENT_ERROR_END = "ERROR_END";
public static final String EVENT_DELAY_START = "DELAY_START";
public static final String EVENT_DELAY_END = "DELAY_END";
/** INTERFACE_ID 멱등 윈도우 (분) */
private static final int IDEMPOTENT_WINDOW_MINUTES = 5;
/** 종결 상태 - 신규 탐지 병합 대상에서 제외 */
private static final List<IncidentState> CLOSED_STATES =
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
private static final String AUTHOR_SYSTEM = "SYSTEM";
private static final String DETECTED_BY_AUTO = "AUTO";
private static final String NOTICE_TYPE_INCIDENT = "3";
private static final int NOTICE_SUBJECT_MAX_LENGTH = 255;
@Autowired
private DjbApistatusIncidentRepository incidentRepository;
@Autowired
private DjbApistatusIncidentApiRepository incidentApiRepository;
@Autowired
private DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
@Autowired
private PortalNoticeService portalNoticeService;
@Autowired
private ApiStatusRepository apiStatusRepository;
/**
* 이벤트 감지
* @param event "CONTROL_START" : 점검시작
@@ -25,17 +97,313 @@ public class ApiStatusDetectionService {
*/
public void detect(String event, List<String> apiIds) {
log.debug("이벤트 감지: {} {}", event, apiIds);
if (StringUtils.isBlank(event) || apiIds == null || apiIds.isEmpty()) {
return;
}
List<String> targets = apiIds.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.sorted()
.collect(Collectors.toList());
if (targets.isEmpty()) {
return;
}
switch (event) {
case EVENT_ERROR_START:
openIncident(event, targets, "에러율 임계 초과", "장애");
break;
case EVENT_DELAY_START:
openIncident(event, targets, "응답시간 임계 초과", "응답지연");
break;
case EVENT_ERROR_END:
case EVENT_DELAY_END:
markRecovered(event, targets);
break;
case EVENT_CONTROL_START:
case EVENT_CONTROL_END:
// 점검은 관리자 등록 공지가 원천 - 재기록하지 않음
log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets);
break;
default:
log.warn("알 수 없는 이벤트: {}", event);
}
}
// API FSM 내 아직 장애로 남아있는 API 목록
public void remainDownApiIds() {
/**
* 미종결 장애로 남아있는 API 목록 (탐지 Job 폴링용)
*/
@Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
public List<String> remainDownApiIds() {
List<DjbApistatusIncident> openIncidents =
incidentRepository.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyList();
}
List<Long> incidentIds = openIncidents.stream()
.map(DjbApistatusIncident::getIncidentId)
.collect(Collectors.toList());
return incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds).stream()
.filter(api -> api.getRecoveredAt() == null)
.map(DjbApistatusIncidentApi::getApiId)
.distinct()
.sorted()
.collect(Collectors.toList());
}
// ────────────── 장애 발생 ──────────────
// 정상 동작 - detect()에서 일괄 처리 (복구 = 점검종료, 장애종료, 지연종료)
// public void recovered(String event, String[] apiIds) {
//
// }
private void openIncident(String event, List<String> apiIds, String summary, String titleKeyword) {
LocalDateTime now = LocalDateTime.now();
String interfaceId = makeInterfaceId(event, apiIds, now);
// 1) 동일 윈도우 재탐지 - 타임라인만 남기고 종료
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
if (duplicated.isPresent()) {
appendTimeline(duplicated.get().getIncidentId(), null,
"재탐지 신호 수신 (" + event + ")", now);
return;
}
// 2) 이미 열려있는 자동 등록 장애가 해당 API 를 포함하면 영향 API 만 병합
// (관리자가 직접 작성한 장애 공지는 건드리지 않는다)
List<DjbApistatusIncident> openIncidents = incidentRepository
.findOpenByApiIds(IncidentKind.INCIDENT, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
if (!openIncidents.isEmpty()) {
mergeIntoOpenIncident(openIncidents.get(0), event, apiIds, now);
return;
}
// 3) 신규 장애 + 공지 초안 생성
Map<String, String> apiNames = resolveApiNames(apiIds);
String title = buildTitle(titleKeyword, apiIds, apiNames);
PortalNotice notice = createDraftNotice(title, summary, apiIds, apiNames, now);
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(IncidentKind.INCIDENT);
incident.setState(IncidentState.INVESTIGATING);
incident.setTitle(title);
incident.setSummary(summary);
incident.setStartedAt(now);
incident.setDetectedBy(DETECTED_BY_AUTO);
incident.setInterfaceId(interfaceId);
incident.setNoticeId(notice.getId());
incident.setDraftYn("Y");
incident.setFixYn("N");
stampAudit(incident, now);
DjbApistatusIncident saved;
try {
saved = incidentRepository.saveAndFlush(incident);
} catch (DataIntegrityViolationException e) {
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 공지 초안 정리 후 타임라인만 남긴다
log.info("장애 중복 감지 (interfaceId={}) - 기존 장애에 타임라인 추가", interfaceId, e);
portalNoticeService.deleteById(notice.getId());
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
appendTimeline(existing.getIncidentId(), null,
"재탐지 신호 수신 (" + event + ")", now));
return;
}
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now);
log.info("자동 장애 등록: incidentId={}, noticeId={}, apis={}",
saved.getIncidentId(), notice.getId(), apiIds);
}
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event,
List<String> apiIds, LocalDateTime now) {
Long incidentId = incident.getIncidentId();
Set<String> mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream()
.map(DjbApistatusIncidentApi::getApiId)
.collect(Collectors.toCollection(HashSet::new));
List<String> added = apiIds.stream()
.filter(apiId -> !mapped.contains(apiId))
.collect(Collectors.toList());
if (added.isEmpty()) {
appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now);
log.debug("기존 장애에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
return;
}
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
appendTimeline(incidentId, null,
"영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now);
log.info("기존 장애에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
}
// ────────────── 복구 ──────────────
private void markRecovered(String event, List<String> apiIds) {
LocalDateTime now = LocalDateTime.now();
List<DjbApistatusIncidentApi> candidates =
incidentApiRepository.findByApiIdInAndRecoveredAtIsNull(apiIds);
if (candidates.isEmpty()) {
log.debug("복구 대상 장애 없음: {} {}", event, apiIds);
return;
}
Map<Long, DjbApistatusIncident> openIncidents = incidentRepository
.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES).stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<Long, List<String>> recoveredByIncident = new HashMap<>();
for (DjbApistatusIncidentApi api : candidates) {
if (!openIncidents.containsKey(api.getIncidentId())) {
continue;
}
api.setRecoveredAt(now);
api.setLastModifiedBy(AUTHOR_SYSTEM);
api.setLastModifiedDate(now);
incidentApiRepository.save(api);
recoveredByIncident.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
.add(api.getApiId());
}
if (recoveredByIncident.isEmpty()) {
log.debug("복구 대상 장애 없음 (미종결 장애 미포함): {} {}", event, apiIds);
return;
}
for (Map.Entry<Long, List<String>> entry : recoveredByIncident.entrySet()) {
Long incidentId = entry.getKey();
List<String> recoveredApis = entry.getValue();
DjbApistatusIncident incident = openIncidents.get(incidentId);
boolean allRecovered = incidentApiRepository.countByIncidentIdAndRecoveredAtIsNull(incidentId) == 0;
IncidentState nextState = allRecovered ? IncidentState.RESOLVED : IncidentState.MONITORING;
if (incident.getState() != nextState) {
incident.setPreviousState(incident.getState());
incident.setState(nextState);
}
if (allRecovered) {
incident.setEndAt(now);
}
incident.setLastModifiedBy(AUTHOR_SYSTEM);
incident.setLastModifiedDate(now);
incidentRepository.save(incident);
String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n"
+ String.join(", ", recoveredApis);
appendTimeline(incidentId, nextState, body, now);
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
}
}
// ────────────── 내부 헬퍼 ──────────────
private String makeInterfaceId(String event, List<String> sortedApiIds, LocalDateTime now) {
long windowSeconds = IDEMPOTENT_WINDOW_MINUTES * 60L;
long flooredEpoch = now.atZone(ZoneId.systemDefault()).toEpochSecond()
/ windowSeconds * windowSeconds;
String joined = String.join(",", sortedApiIds);
String interfaceId = event + ":" + joined + ":" + flooredEpoch;
// INTERFACE_ID 는 VARCHAR2(200) - 초과 시 API 목록을 해시로 축약
if (interfaceId.length() > 200) {
interfaceId = event + ":#" + Integer.toHexString(joined.hashCode())
+ ":" + sortedApiIds.size() + ":" + flooredEpoch;
}
return interfaceId;
}
private Map<String, String> resolveApiNames(List<String> apiIds) {
Map<String, String> names = new HashMap<>();
try {
for (ApiNameRow row : apiStatusRepository.findApiNames(apiIds)) {
names.put(row.getEaisvcname(), row.getEaisvcdesc());
}
} catch (Exception e) {
log.warn("API 명 조회 실패 - API ID 를 그대로 사용", e);
}
return names;
}
private String apiLabel(String apiId, Map<String, String> apiNames) {
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
}
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
String first = apiLabel(apiIds.get(0), apiNames);
if (apiIds.size() > 1) {
return "[자동감지] " + first + "" + (apiIds.size() - 1) + "종 API " + titleKeyword;
}
return "[자동감지] " + first + " API " + titleKeyword;
}
private PortalNotice createDraftNotice(String title, String summary, List<String> apiIds,
Map<String, String> apiNames, LocalDateTime now) {
StringBuilder detail = new StringBuilder();
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
detail.append("<p>영향 API</p><ul>");
for (String apiId : apiIds) {
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
}
detail.append("</ul>");
PortalNotice notice = new PortalNotice();
notice.setId(null);
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
notice.setNoticeDetail(detail.toString());
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
notice.setUseYn("N");
notice.setFixYn("N");
notice.setReadCount(0L);
notice.setInquirerName(AUTHOR_SYSTEM);
notice.setCreatedBy(AUTHOR_SYSTEM);
notice.setCreatedDate(now);
portalNoticeService.save(notice);
return notice;
}
private void saveIncidentApis(Long incidentId, Collection<String> apiIds,
Map<String, String> apiNames, LocalDateTime now) {
List<DjbApistatusIncidentApi> rows = new ArrayList<>();
for (String apiId : apiIds) {
DjbApistatusIncidentApi api = new DjbApistatusIncidentApi();
api.setIncidentId(incidentId);
api.setApiId(apiId);
api.setApiName(apiNames.get(apiId));
stampAudit(api, now);
rows.add(api);
}
if (!rows.isEmpty()) {
incidentApiRepository.saveAll(rows);
}
}
private void appendTimeline(Long incidentId, IncidentState stateAfter, String body, LocalDateTime now) {
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
timeline.setIncidentId(incidentId);
timeline.setEventAt(now);
timeline.setStateAfter(stateAfter);
timeline.setBody(StringUtils.abbreviate(body, 4000));
timeline.setAuthorType(AUTHOR_SYSTEM);
timeline.setVisibleYn("Y");
stampAudit(timeline, now);
incidentTimelineRepository.save(timeline);
}
/**
* 스케줄러 스레드는 로그인 세션이 없어 AuditorAware 가 비어있다. CREATED_BY 는 NOT NULL 이므로 직접 채운다.
*/
private void stampAudit(Auditable entity, LocalDateTime now) {
entity.setCreatedBy(AUTHOR_SYSTEM);
entity.setCreatedDate(now);
}
}
@@ -4,7 +4,10 @@ import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
@@ -73,4 +76,11 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
@Param("delayRangeMinute") int delayRangeMinute,
@Param("delayAvgRespTime") int delayAvgRespTime
);
/**
* EAI 서비스명 조회. 장애/점검 영향 API 의 API_NAME 캐시용.
*/
@Query(nativeQuery = true, value =
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
}
@@ -66,8 +66,8 @@ public class PortalApprovalManController extends BaseAnnotationController {
}
@PostMapping(value = "/onl/apim/approval/portalApprovalMan.json", params = "cmd=APPROVE")
public ResponseEntity<Void> approve(String id) {
portalApprovalManService.approve(id, SessionManager.getLoginVo().getUserId());
public ResponseEntity<Void> approve(String id, @RequestParam(required = false) String gwAction) {
portalApprovalManService.approve(id, SessionManager.getLoginVo().getUserId(), gwAction);
return ResponseEntity.ok().build();
}
@@ -2,6 +2,8 @@ package com.eactive.eai.rms.onl.apim.approval;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.entity.GwAction;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.entity.ApprovalType;
@@ -56,6 +58,7 @@ public class PortalApprovalManService extends BaseService {
private static final String HARD_DELETE_FLAG_KEY = "approval.hard-delete.enabled";
private final ApprovalService approvalService;
private final PortalApprovalUIMapper portalApprovalUIMapper;
private final ApprovalStateMachine approvalStateMachine;
private final AppRequestRepository appRequestRepository;
private final PortalAppApprovalListener portalAppApprovalListener;
@@ -77,12 +80,18 @@ public class PortalApprovalManService extends BaseService {
private EntityManager entityManager;
public Page<PortalApprovalUI> selectList(Pageable pageable, PortalApprovalUISearch portalApprovalUISearch) {
// 목록 그리드는 Approval 자체 필드(제목/유형/상태/요청자/생성일/예상완료일)만 사용하므로
// 행별 상세 재조회(getDetailWithMaskOption: AppRequest + API 목록 + AGW 크로스 조회) 없이 경량 매핑한다.
Page<Approval> approval = approvalService.findAll(pageable, portalApprovalUISearch);
return approval.map(entity -> {
try {
return getDetailWithMaskOption(entity.getId(), false);
PortalApprovalUI ui = portalApprovalUIMapper.toVo(entity);
if (ui.getRequester() == null) {
ui.setRequester(createDeletedUserPlaceholder());
}
return ui;
} catch (Exception e) {
logger.error("승인 상세 조회 실패 - ID: {}, 오류: {}", entity.getId(), e.getMessage());
logger.error("승인 목록 매핑 실패 - ID: {}, 오류: {}", entity.getId(), e.getMessage());
return null;
}
});
@@ -204,6 +213,7 @@ public class PortalApprovalManService extends BaseService {
if (optRequest.isPresent()) {
AppRequest appRequest = optRequest.get();
appApprovalUI.setRequestType(appRequest.getType());
appApprovalUI.setGwAction(appRequest.getGwAction());
if (appRequest.getApiGroupList() == null || appRequest.getApiGroupList().isEmpty()) {
@@ -321,7 +331,7 @@ public class PortalApprovalManService extends BaseService {
return placeholder;
}
public void approve(String id, String approverId) {
public void approve(String id, String approverId, String gwAction) {
Map options = new HashMap<>();
Approval approval = approvalService.findById(id).orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다. ID: " + id));
@@ -329,6 +339,18 @@ public class PortalApprovalManService extends BaseService {
throw new IllegalArgumentException("아직 승인 차례가 아닙니다.");
}
// API 이용해지(DELETE) 승인이면 GW 처리방식(차단/완전삭제)을 신청서에 영속화한다.
// 같은 EMS 트랜잭션이므로 최종 승인 시 listener 가 이 값을 읽고, REDEPLOY 재처리에서도 유지된다.
// 미지정(null)은 안전 기본값인 차단(BLOCK)으로 처리.
if (approval.getApprovalType().equals(ApprovalType.APP)) {
appRequestRepository.findById(approval.getTargetId()).ifPresent(request -> {
if (AppRequestType.DELETE.equals(request.getType())) {
request.setGwAction("DELETE".equalsIgnoreCase(gwAction) ? GwAction.DELETE : GwAction.BLOCK);
appRequestRepository.save(request);
}
});
}
options.put(ApprovalConstants.APPROVER, approverId);
sendEvent(id, ApprovalEvent.APPROVE, options);
}
@@ -2,6 +2,7 @@ package com.eactive.eai.rms.onl.apim.approval.app;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.entity.GwAction;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.service.ApprovalDeployException;
@@ -72,7 +73,7 @@ public class PortalAppApprovalListener implements ApprovalListener {
// 위 메서드가 반환되면 트랜잭션이 커밋되어 Portal DB에 영구 저장됨
// 이제 APIGW 동기화 및 GW 리로드 수행
syncTargetServer(type, credentialUI);
syncTargetServer(appRequest, credentialUI);
sendApprovalResult(appRequest);
}
}
@@ -94,7 +95,12 @@ public class PortalAppApprovalListener implements ApprovalListener {
} else if (type.equals(AppRequestType.MODIFY)) {
credentialUI = modifyClientUI(appRequest);
} else if (type.equals(AppRequestType.DELETE)) {
credentialManService.delete(appRequest.getClientId());
// REDEPLOY 재처리 시 credential 이 이미 삭제되어 있을 수 있으므로 부재는 정상 통과시킨다.
try {
credentialManService.delete(appRequest.getClientId());
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
logger.warn("포털 credential 이미 삭제됨 (재처리 통과) - clientId: {}", appRequest.getClientId());
}
credentialUI = new CredentialUI();
credentialUI.setClientid(appRequest.getClientId());
}
@@ -119,7 +125,9 @@ public class PortalAppApprovalListener implements ApprovalListener {
messageSendService.sendMessage(MessageCode.APP_APPROVE, recipient, params);
}
private void syncTargetServer(AppRequestType action, CredentialUI credentialUI) throws ApprovalDeployException {
private void syncTargetServer(AppRequest appRequest, CredentialUI credentialUI) throws ApprovalDeployException {
AppRequestType action = appRequest.getType();
DataSourceType type = DataSourceTypeManager.getDataSourceType("APIGW");
DataSourceContextHolder.setDataSourceType(type);
@@ -129,7 +137,7 @@ public class PortalAppApprovalListener implements ApprovalListener {
} else if (action.equals(AppRequestType.MODIFY)) {
clientManService.update(credentialUIMapper.mapClient(credentialUI));
} else if (action.equals(AppRequestType.DELETE)) {
clientManService.delete(credentialUI.getClientid());
applyGwActionForDelete(appRequest.getGwAction(), credentialUI.getClientid());
}
CommonCommand.builder()
@@ -138,6 +146,30 @@ public class PortalAppApprovalListener implements ApprovalListener {
.build().broadcast(agentUtilService);
}
/**
* API 이용해지 승인 시 GW 클라이언트(TSEAIAU01) 처리.
* 승인 시점에 관리자가 선택해 PTL_APP_REQUEST.GW_ACTION 에 영속화된 값을 따른다.
* - DELETE: 행 완전 삭제 / - BLOCK 또는 null(미지정): 차단(appstatus='0')
* GW 에 행이 이미 없으면(선삭제·재처리) 목적이 달성된 상태이므로 통과시키고,
* 그 외 오류는 ApprovalDeployException 으로 던져 FAILED(재처리 가능) 상태를 만든다.
*/
private void applyGwActionForDelete(GwAction gwAction, String clientId) throws ApprovalDeployException {
try {
if (GwAction.DELETE.equals(gwAction)) {
clientManService.delete(clientId);
logger.info("GW 클라이언트 완전 삭제 - clientId: {}", clientId);
} else {
clientManService.blockClient(clientId);
logger.info("GW 클라이언트 차단(appstatus=0) - clientId: {}", clientId);
}
} catch (org.springframework.dao.EmptyResultDataAccessException | javax.persistence.EntityNotFoundException e) {
logger.warn("GW 클라이언트가 이미 존재하지 않아 처리 생략 (재처리 통과) - clientId: {}", clientId);
} catch (RuntimeException e) {
logger.error("GW 클라이언트 해지 처리 실패 - clientId: {}, gwAction: {}", clientId, gwAction, e);
throw new ApprovalDeployException("GW 클라이언트 해지 처리에 실패했습니다: " + e.getMessage());
}
}
private CredentialUI modifyClientUI(AppRequest appRequest) {
CredentialUI existingCredential = credentialManService.selectDetail(appRequest.getClientId());
existingCredential.getApiList().clear();
@@ -1,6 +1,7 @@
package com.eactive.eai.rms.onl.apim.approval.app;
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
import com.eactive.apim.portal.apprequest.entity.GwAction;
import com.eactive.eai.rms.onl.apim.apigroup.ui.ApiGroupUI;
import com.eactive.eai.rms.onl.apim.approval.PortalApprovalUI;
import java.util.ArrayList;
@@ -26,4 +27,7 @@ public class PortalAppApprovalUI extends PortalApprovalUI {
private List<String> prevApiGroupNameList = new ArrayList<>();
private AppRequestType requestType;
//DELETE 승인 시 선택된 GW 처리방식 (승인 완료 건 상세 표시용)
private GwAction gwAction;
}
@@ -82,9 +82,9 @@ public class MessageRequestManService extends BaseService {
return t == null ? "" : t.format(f);
}
/** 상태 검색 콤보 (PENDING/SENT/FAILED). */
/** 상태 검색 콤보 (PENDING/SENT/FAILED/SKIPPED). SKIPPED 는 행번 아닌 ID 로 발송을 건너뛴 건. */
public List<ComboVo> statusCombo() {
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED"})
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED", "SKIPPED"})
.map(s -> {
ComboVo v = new ComboVo();
v.setCode(s);