- IncidentTimelineUI VO 추가 - 장애 타임라인 화면 구현 지원
eapim-admin CI / build (push) Has been cancelled

- PortalNoticeManService 장애 상태 타임라인 로직 추가
- 메인 CSS에 타임라인 스타일 추가
This commit is contained in:
Rinjae
2026-08-04 13:10:31 +09:00
parent 6604f7c9e8
commit cc0ef13d5d
5 changed files with 543 additions and 33 deletions
@@ -0,0 +1,33 @@
package com.eactive.eai.rms.onl.apim.portalnotice;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 장애 처리 타임라인 (DJB_APISTATUS_INCIDENT_TIMELINE) 화면 표시용 VO.
*/
@Data
@NoArgsConstructor
public class IncidentTimelineUI {
private Long timelineId;
private Long incidentId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
private LocalDateTime eventAt;
/** 상태 변경이 있었던 항목만 값이 있다. (INVESTIGATING/IDENTIFIED/MONITORING/RESOLVED/CANCELED) */
private String stateAfter;
private String body;
/** SYSTEM(자동 탐지) / OPERATOR(관리자 입력) */
private String authorType;
/** 개발자포탈 공개 여부 */
private String visibleYn;
}
@@ -15,6 +15,7 @@ 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 org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@@ -86,4 +87,28 @@ public class PortalNoticeManController extends BaseAnnotationController {
return ResponseEntity.ok().build();
}
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_LIST")
public ResponseEntity<List<IncidentTimelineUI>> selectTimeline(Long incidentId) {
return ResponseEntity.ok(portalNoticeManService.selectTimeline(incidentId));
}
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_ADD")
public ResponseEntity<PortalNoticeUI> appendTimeline(Long incidentId, String body,
@RequestParam(defaultValue = "false") boolean stateChange,
String nextState) {
return ResponseEntity.ok(portalNoticeManService.appendTimeline(incidentId, body, stateChange, nextState));
}
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_VISIBLE")
public ResponseEntity<String> updateTimelineVisible(Long timelineId, String visibleYn) {
portalNoticeManService.updateTimelineVisible(timelineId, visibleYn);
return ResponseEntity.ok().build();
}
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_DELETE")
public ResponseEntity<String> deleteTimeline(Long timelineId) {
portalNoticeManService.deleteTimeline(timelineId);
return ResponseEntity.ok().build();
}
}
@@ -3,6 +3,7 @@ package com.eactive.eai.rms.onl.apim.portalnotice;
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.DjbApistatusIncidentApiId;
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;
@@ -36,6 +37,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -90,12 +92,40 @@ public class PortalNoticeManService extends BaseService {
public Page<PortalNoticeUI> selectList(Pageable pageable, PortalNoticeUISearch portalNoticeUISearch) {
return portalNoticeService.findAll(pageable, portalNoticeUISearch)
Page<PortalNoticeUI> page = portalNoticeService.findAll(pageable, portalNoticeUISearch)
.map(portalNotice -> {
PortalNoticeUI ui = portalNoticeUIMapper.toVo(portalNotice);
setFileInfo(portalNotice, ui);
return ui;
});
populateIncidentSummary(page.getContent());
return page;
}
/**
* 목록에서 장애 진행 여부(종료 시각 미입력)를 표기하기 위해 incident 정보를 한 번에 붙인다.
*/
private void populateIncidentSummary(List<PortalNoticeUI> rows) {
List<String> noticeIds = rows.stream()
.filter(ui -> isIncidentKind(ui.getNoticeType()))
.map(PortalNoticeUI::getId)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toList());
if (noticeIds.isEmpty()) {
return;
}
Map<String, DjbApistatusIncident> byNoticeId = incidentRepository.findByNoticeIdIn(noticeIds).stream()
.collect(Collectors.toMap(DjbApistatusIncident::getNoticeId, incident -> incident, (a, b) -> a));
for (PortalNoticeUI ui : rows) {
DjbApistatusIncident incident = byNoticeId.get(ui.getId());
if (incident == null) continue;
ui.setIncidentId(incident.getIncidentId());
ui.setStartedAt(incident.getStartedAt());
ui.setEndAt(incident.getEndAt());
ui.setState(incident.getState() == null ? null : incident.getState().name());
}
}
private void setFileInfo(PortalNotice portalNotice, PortalNoticeUI ui) {
@@ -172,11 +202,17 @@ public class PortalNoticeManService extends BaseService {
portalNoticeService.save(portalNotice);
if (isIncidentKind(portalNoticeUI.getNoticeType())) {
persistIncident(portalNoticeUI, portalNotice, true);
DjbApistatusIncident incident = persistIncident(portalNoticeUI, portalNotice, true);
if (incident.getKind() == IncidentKind.INCIDENT) {
// 최초 발생 항목을 남겨야 개발자포탈 장애 카드가 자동 탐지 건과 같은 모양으로 보인다.
String body = StringUtils.defaultIfBlank(incident.getSummary(), incident.getTitle());
writeTimeline(incident.getIncidentId(), incident.getStartedAt(),
IncidentState.INVESTIGATING, body);
}
}
}
private void persistIncident(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice, boolean isCreate) {
private DjbApistatusIncident persistIncident(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice, boolean isCreate) {
IncidentKind kind = toKind(portalNoticeUI.getNoticeType());
DjbApistatusIncident incident = incidentRepository.findByNoticeId(portalNotice.getId())
@@ -185,6 +221,8 @@ public class PortalNoticeManService extends BaseService {
incident.setKind(kind);
incident.setTitle(portalNotice.getNoticeSubject());
incident.setSummary(portalNoticeUI.getSummary());
IncidentState stateBefore = incident.getState();
LocalDateTime endAtBefore = incident.getEndAt();
incident.setStartedAt(portalNoticeUI.getStartedAt() != null
? portalNoticeUI.getStartedAt() : LocalDateTime.now());
incident.setEndAt(portalNoticeUI.getEndAt());
@@ -194,18 +232,43 @@ public class PortalNoticeManService extends BaseService {
incident.setFixYn(StringUtils.defaultIfBlank(portalNotice.getFixYn(), "N"));
if (kind == IncidentKind.INCIDENT) {
IncidentState newState = StringUtils.isNotBlank(portalNoticeUI.getState())
? IncidentState.valueOf(portalNoticeUI.getState())
: IncidentState.INVESTIGATING;
if (!isCreate && incident.getState() != newState) {
incident.setPreviousState(incident.getState());
// 상태는 타임라인 게시로만 전이한다(화면에서 select 제거). 폼에 값이 없으면 현재 상태를 보존해야
// 저장할 때마다 INVESTIGATING 으로 되돌아가는 회귀가 생기지 않는다.
IncidentState newState;
if (StringUtils.isNotBlank(portalNoticeUI.getState())) {
newState = IncidentState.valueOf(portalNoticeUI.getState());
} else {
newState = stateBefore != null ? stateBefore : IncidentState.INVESTIGATING;
}
// 종결된 장애는 되돌릴 수 없다 — 재발은 새 장애로 등록한다 (appendTimeline 과 같은 규칙)
if (!isCreate && isTerminal(stateBefore) && newState != stateBefore) {
throw new IllegalArgumentException("이미 종결된 장애입니다. 상태를 변경할 수 없습니다.");
}
if (!isCreate && stateBefore != newState) {
incident.setPreviousState(stateBefore);
}
incident.setState(newState);
// 종료 시각은 상태를 따른다. 비종결인데 종료 시각이 남으면 개발자포탈에서
// '종료 시각이 있는데 진행 중'인 모순 카드가 된다.
// 종결된 건은 실제 복구 시각으로 손보정할 수 있어야 하므로 폼 입력을 그대로 받는다.
if (isTerminal(newState)) {
if (incident.getEndAt() == null) {
incident.setEndAt(endAtBefore != null ? endAtBefore : LocalDateTime.now());
}
} else {
incident.setEndAt(null);
}
} else {
incident.setState(null);
incident.setPreviousState(null);
}
// 장애·점검 공통 — 종료가 시작보다 빠른 구간은 가동률 집계를 음수로 만든다
if (incident.getEndAt() != null && incident.getEndAt().isBefore(incident.getStartedAt())) {
throw new IllegalArgumentException("종료 시각은 시작 시각보다 빠를 수 없습니다.");
}
DjbApistatusIncident saved = incidentRepository.save(incident);
// 영향 API 동기화 — 단순 전체 삭제 후 재삽입
@@ -225,6 +288,111 @@ public class PortalNoticeManService extends BaseService {
incidentApiRepository.saveAll(rows);
}
}
return saved;
}
// ───────────────────── 장애 처리 타임라인 ─────────────────────
private static final String AUTHOR_OPERATOR = "OPERATOR";
/** 종결 상태 — 이후 어떤 상태로도 전이할 수 없다. */
private static boolean isTerminal(IncidentState state) {
return state == IncidentState.RESOLVED || state == IncidentState.CANCELED;
}
public List<IncidentTimelineUI> selectTimeline(Long incidentId) {
if (incidentId == null) {
return Collections.emptyList();
}
return incidentTimelineRepository.findByIncidentIdOrderByEventAtAsc(incidentId).stream()
.map(entity -> {
IncidentTimelineUI ui = new IncidentTimelineUI();
ui.setTimelineId(entity.getTimelineId());
ui.setIncidentId(entity.getIncidentId());
ui.setEventAt(entity.getEventAt());
ui.setStateAfter(entity.getStateAfter() == null ? null : entity.getStateAfter().name());
ui.setBody(entity.getBody());
ui.setAuthorType(entity.getAuthorType());
ui.setVisibleYn(entity.getVisibleYn());
return ui;
}).collect(Collectors.toList());
}
/**
* 타임라인 게시. 상태 변경 여부와 무관하게 항상 새 행을 추가한다(append-only).
* 상태 변경이 있으면 장애의 STATE/PREVIOUS_STATE 를 같은 트랜잭션에서 동기화하고,
* 종결 상태(해소/취소)로 가면 종료 시각을 자동으로 채운다.
*
* @return 갱신된 상태/이전 상태/종료 시각 (화면 갱신용 — 상세를 다시 읽지 않기 위함)
*/
public PortalNoticeUI appendTimeline(Long incidentId, String body, boolean stateChange, String nextState) {
if (StringUtils.isBlank(body)) {
throw new IllegalArgumentException("타임라인 메세지를 입력하여 주십시오.");
}
DjbApistatusIncident incident = incidentRepository.findById(incidentId)
.orElseThrow(() -> new IllegalArgumentException("장애 정보를 찾을 수 없습니다. incidentId=" + incidentId));
if (incident.getKind() != IncidentKind.INCIDENT) {
// 점검은 STATE/타임라인을 쓰지 않는다 (ADR-F15)
throw new IllegalArgumentException("장애 유형만 타임라인을 등록할 수 있습니다.");
}
LocalDateTime now = LocalDateTime.now();
IncidentState after = null;
if (stateChange) {
if (StringUtils.isBlank(nextState)) {
throw new IllegalArgumentException("변경할 상태를 선택하여 주십시오.");
}
after = IncidentState.valueOf(nextState);
// 종결된 장애는 재전이 불가 — 재발은 새 장애로 등록한다 (상태 전이 설계 가드)
if (isTerminal(incident.getState())) {
throw new IllegalArgumentException("이미 종결된 장애입니다. 상태를 변경할 수 없습니다.");
}
if (incident.getState() == after) {
throw new IllegalArgumentException("현재와 동일한 상태로는 변경할 수 없습니다.");
}
incident.setPreviousState(incident.getState());
incident.setState(after);
if (after == IncidentState.RESOLVED || after == IncidentState.CANCELED) {
incident.setEndAt(now);
}
incidentRepository.save(incident);
}
writeTimeline(incidentId, now, after, body);
PortalNoticeUI result = new PortalNoticeUI();
result.setIncidentId(incident.getIncidentId());
result.setState(incident.getState() == null ? null : incident.getState().name());
result.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
result.setEndAt(incident.getEndAt());
return result;
}
public void updateTimelineVisible(Long timelineId, String visibleYn) {
DjbApistatusIncidentTimeline timeline = incidentTimelineRepository.findById(timelineId)
.orElseThrow(() -> new IllegalArgumentException("타임라인 정보를 찾을 수 없습니다. timelineId=" + timelineId));
timeline.setVisibleYn("Y".equals(visibleYn) ? "Y" : "N");
incidentTimelineRepository.save(timeline);
}
public void deleteTimeline(Long timelineId) {
incidentTimelineRepository.deleteById(timelineId);
}
/**
* 감사 컬럼(CREATED_BY/CREATED_DATE)은 EMSAuditorAware 가 채운다 — 직접 세팅하지 않는다.
*/
private void writeTimeline(Long incidentId, LocalDateTime eventAt, IncidentState stateAfter, String body) {
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
timeline.setIncidentId(incidentId);
timeline.setEventAt(eventAt != null ? eventAt : LocalDateTime.now());
timeline.setStateAfter(stateAfter);
timeline.setBody(StringUtils.abbreviate(body, 4000));
timeline.setAuthorType(AUTHOR_OPERATOR);
timeline.setVisibleYn("Y");
incidentTimelineRepository.save(timeline);
}
private void handleFileUpload(PortalNoticeUI portalNoticeUI, PortalNotice portalNotice) throws IOException {