From cc0ef13d5d4b6b0fd4deae9f57588362f0639990 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 4 Aug 2026 13:10:31 +0900 Subject: [PATCH] =?UTF-8?q?-=20IncidentTimelineUI=20VO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=9E=A5=EC=95=A0=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20-=20PortalNoticeManService=20=EC=9E=A5?= =?UTF-8?q?=EC=95=A0=20=EC=83=81=ED=83=9C=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20CSS=EC=97=90=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onl/apim/portalnotice/portalNoticeMan.jsp | 47 ++- .../portalnotice/portalNoticeManDetail.jsp | 287 +++++++++++++++++- .../apim/portalnotice/IncidentTimelineUI.java | 33 ++ .../PortalNoticeManController.java | 25 ++ .../portalnotice/PortalNoticeManService.java | 184 ++++++++++- 5 files changed, 543 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java diff --git a/WebContent/jsp/onl/apim/portalnotice/portalNoticeMan.jsp b/WebContent/jsp/onl/apim/portalnotice/portalNoticeMan.jsp index a53fa07..088273f 100644 --- a/WebContent/jsp/onl/apim/portalnotice/portalNoticeMan.jsp +++ b/WebContent/jsp/onl/apim/portalnotice/portalNoticeMan.jsp @@ -18,17 +18,43 @@ var url = ''; var url_view = ''; var combo; - + + // 코드 상수 — PortalNoticeUI 와 동기화 + var NOTICE_TYPE_INCIDENT = '3'; + + // IncidentState 라벨 — 상세 화면과 동일하게 유지 + var STATE_LABELS = { + 'INVESTIGATING': '조사중', + 'IDENTIFIED': '원인 파악', + 'MONITORING': '모니터링', + 'RESOLVED': '해소', + 'CANCELED': '취소' + }; + + function formatIncidentState(cellvalue, options, rowObject) { + if (!cellvalue) return ''; + var label = STATE_LABELS[cellvalue] || cellvalue; + var closed = (cellvalue === 'RESOLVED' || cellvalue === 'CANCELED'); + return closed + ? '' + label + '' + : '' + label + ''; + } + function formatNoticeType(cellvalue, options, rowObject) { - if (!combo || !combo.noticeTypeList) { - return cellvalue; - } - for (var i = 0; i < combo.noticeTypeList.length; i++) { - if (combo.noticeTypeList[i].CODE == cellvalue) { - return combo.noticeTypeList[i].NAME; + var name = cellvalue; + if (combo && combo.noticeTypeList) { + for (var i = 0; i < combo.noticeTypeList.length; i++) { + if (combo.noticeTypeList[i].CODE == cellvalue) { + name = combo.noticeTypeList[i].NAME; + break; + } } } - return cellvalue; + // 장애인데 종료 시각이 없으면 아직 진행 중이다 + if (cellvalue == NOTICE_TYPE_INCIDENT && rowObject && !rowObject.endAt) { + return '' + name + '-진행중'; + } + return name; } function formatuseYn(cellvalue, options, rowObject) { @@ -115,14 +141,15 @@ searchNoticeSubject: $('input[name=searchNoticeSubject]').val(), searchNoticeDetail: $('input[name=searchNoticeDetail]').val() }, - colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '마지막 수정일', '등록일', '등록자', '조회수'], + colNames:['id', 'No.', '게시유형', '제목', '고정여부', '사용', '장애상태', '마지막 수정일', '등록일', '등록자', '조회수'], colModel:[ { name : 'id' , align:'center', key:true, hidden:true}, { name : 'rowNum' , align:'center', width:60 }, - { name : 'noticeType' , align:'center', width:60, formatter: formatNoticeType }, + { name : 'noticeType' , align:'center', width:100, formatter: formatNoticeType }, { name : 'noticeSubject' , align:'left' , width:300, formatter: formatFile }, { name : 'fixYn' , align:'center', width:60, formatter: formatFixYn }, { name : 'useYn' , align:'center', width:60, formatter: formatuseYn }, + { name : 'state' , align:'center', width:90, sortable:false, formatter: formatIncidentState }, { name : 'lastModifiedDate', align:'center', width:120 , formatter: timeStampFormat}, { name : 'createdDate' , align:'center', width:120, formatter: timeStampFormat }, { name : 'inquirerName' , align:'center', width:100 }, diff --git a/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp b/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp index 34ca6be..6bee1a8 100644 --- a/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp +++ b/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp @@ -32,10 +32,35 @@ // 영향 API 목록 (UI 상태) var affectedApis = []; + // 장애 타임라인 (UI 상태) + var currentIncidentId = null; + var currentIncidentState = null; + + // 종결 상태 — 재전이 불가 (재발은 새 장애로 등록) + var TERMINAL_STATES = ['RESOLVED', 'CANCELED']; + + // IncidentState 라벨 — 상태 콤보 문구와 동일하게 유지 + var STATE_LABELS = { + 'INVESTIGATING': '조사중', + 'IDENTIFIED': '원인 파악', + 'MONITORING': '모니터링', + 'RESOLVED': '해소', + 'CANCELED': '취소' + }; + function isIncidentType(t) { return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE; } + function stateLabel(code) { + if (!code) return ''; + return (STATE_LABELS[code] || code) + ' (' + code + ')'; + } + + function escapeHtml(text) { + return $('
').text(text == null ? '' : text).html(); + } + function init() { var key = "${param.id}"; isDetail = key != "" && key != "null"; @@ -76,12 +101,28 @@ // INCIDENT 만 상태 영역 표시 if (t === NOTICE_TYPE_INCIDENT) { $('.incident-state-row').show(); + $('.incident-endat-hint').show(); } else { $('.incident-state-row').hide(); + $('.incident-endat-hint').hide(); } + // 장애는 종결(해소·취소) 후에만 종료 시각을 손볼 수 있다. 진행 중에는 상태를 따른다. + var closed = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0; + $('#endAt').prop('readonly', t === NOTICE_TYPE_INCIDENT && !closed); + $('.incident-endat-hint').text(closed + ? '※ 종결된 장애입니다. 실제 복구 시각으로 수정할 수 있습니다.' + : '※ 타임라인에서 해소·취소로 전이하면 자동 입력됩니다.'); } else { $('.incident-row').hide(); $('.incident-state-row').hide(); + $('.incident-endat-hint').hide(); + $('#endAt').prop('readonly', false); + } + // 타임라인은 등록된 장애(수정 모드)에서만 다룬다 + if (t === NOTICE_TYPE_INCIDENT && isDetail && currentIncidentId) { + $('#timelineSection').show(); + } else { + $('#timelineSection').hide(); } } @@ -192,6 +233,150 @@ renderAffectedApis(); } + // ───────────────────── 장애 처리 타임라인 ───────────────────── + + /** 현재 상태 기준으로 전이 가능한 선택지만 남긴다. 실제 차단은 서버가 한다. */ + function applyStateChangeGuard() { + var terminal = $.inArray(currentIncidentState, TERMINAL_STATES) >= 0; + + $('#timelineStateChange').prop('disabled', terminal); + if (terminal) { + $('#timelineStateChange').prop('checked', false); + $('#timelineNextState').prop('disabled', true).val(''); + } + $('#timelineClosedHint').toggle(terminal); + $('#timelineResolveHint').toggle(!terminal); + + // 현재와 동일한 상태로는 전이할 수 없다 + $('#timelineNextState option').each(function() { + var v = $(this).val(); + $(this).prop('disabled', v !== '' && v === currentIncidentState); + }); + if ($('#timelineNextState').val() === currentIncidentState) { + $('#timelineNextState').val(''); + } + } + + function loadTimeline() { + if (!currentIncidentId) { + renderTimeline([]); + return; + } + $.ajax({ + type: "POST", url: url, dataType: "json", + data: {cmd: 'TIMELINE_LIST', incidentId: currentIncidentId}, + success: function (rows) { renderTimeline(rows || []); }, + error: function (e) { alert(e.responseText); } + }); + } + + function renderTimeline(rows) { + var $tb = $('#timelineTbody'); + $tb.empty(); + if (!rows.length) { + $tb.append('등록된 타임라인이 없습니다.'); + return; + } + $.each(rows, function(_, row) { + var author = row.authorType === 'SYSTEM' ? 'System' : 'Admin'; + var visible = row.visibleYn === 'Y'; + var $tr = $(''); + $tr.append('' + escapeHtml(row.eventAt) + ''); + $tr.append('' + author + ''); + $tr.append('' + (row.stateAfter ? escapeHtml(stateLabel(row.stateAfter)) : '-') + ''); + $tr.append('' + escapeHtml(row.body).replace(/\n/g, '
') + ''); + + // 현재 상태(라벨) + 눌렀을 때 바뀔 상태(버튼) 를 나눠 표기 — 버튼 하나면 의미가 모호하다 + var $visibleTd = $(''); + $('') + .text(visible ? '공개중' : '비공개') + .css({color: visible ? '#1B8C4A' : '#999', 'margin-right': '5px'}) + .appendTo($visibleTd); + $(' +
+ diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java new file mode 100644 index 0000000..b4fdb12 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/IncidentTimelineUI.java @@ -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; +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java index c02bb8c..b119554 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java @@ -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> selectTimeline(Long incidentId) { + return ResponseEntity.ok(portalNoticeManService.selectTimeline(incidentId)); + } + + @PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_ADD") + public ResponseEntity 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 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 deleteTimeline(Long timelineId) { + portalNoticeManService.deleteTimeline(timelineId); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java index e432b14..7389dc6 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java @@ -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 selectList(Pageable pageable, PortalNoticeUISearch portalNoticeUISearch) { - return portalNoticeService.findAll(pageable, portalNoticeUISearch) + Page 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 rows) { + List noticeIds = rows.stream() + .filter(ui -> isIncidentKind(ui.getNoticeType())) + .map(PortalNoticeUI::getId) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toList()); + if (noticeIds.isEmpty()) { + return; + } + + Map 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 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 {