- PortalNoticeManService 장애 상태 타임라인 로직 추가 - 메인 CSS에 타임라인 스타일 추가
This commit is contained in:
@@ -19,16 +19,42 @@
|
||||
var url_view = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.view" />';
|
||||
var combo;
|
||||
|
||||
function formatNoticeType(cellvalue, options, rowObject) {
|
||||
if (!combo || !combo.noticeTypeList) {
|
||||
return cellvalue;
|
||||
// 코드 상수 — 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
|
||||
? '<span title="' + cellvalue + '">' + label + '</span>'
|
||||
: '<span style="color: red;" title="' + cellvalue + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
function formatNoticeType(cellvalue, options, rowObject) {
|
||||
var name = cellvalue;
|
||||
if (combo && combo.noticeTypeList) {
|
||||
for (var i = 0; i < combo.noticeTypeList.length; i++) {
|
||||
if (combo.noticeTypeList[i].CODE == cellvalue) {
|
||||
return combo.noticeTypeList[i].NAME;
|
||||
name = combo.noticeTypeList[i].NAME;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cellvalue;
|
||||
}
|
||||
// 장애인데 종료 시각이 없으면 아직 진행 중이다
|
||||
if (cellvalue == NOTICE_TYPE_INCIDENT && rowObject && !rowObject.endAt) {
|
||||
return '<span style="color: red;">' + name + '-진행중</span>';
|
||||
}
|
||||
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 },
|
||||
|
||||
@@ -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 $('<div>').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('<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>');
|
||||
return;
|
||||
}
|
||||
$.each(rows, function(_, row) {
|
||||
var author = row.authorType === 'SYSTEM' ? 'System' : 'Admin';
|
||||
var visible = row.visibleYn === 'Y';
|
||||
var $tr = $('<tr>');
|
||||
$tr.append('<td style="text-align:center;white-space:nowrap;">' + escapeHtml(row.eventAt) + '</td>');
|
||||
$tr.append('<td style="text-align:center;">' + author + '</td>');
|
||||
$tr.append('<td style="text-align:center;white-space:nowrap;">' + (row.stateAfter ? escapeHtml(stateLabel(row.stateAfter)) : '-') + '</td>');
|
||||
$tr.append('<td>' + escapeHtml(row.body).replace(/\n/g, '<br>') + '</td>');
|
||||
|
||||
// 현재 상태(라벨) + 눌렀을 때 바뀔 상태(버튼) 를 나눠 표기 — 버튼 하나면 의미가 모호하다
|
||||
var $visibleTd = $('<td style="text-align:center;white-space:nowrap;">');
|
||||
$('<span>')
|
||||
.text(visible ? '공개중' : '비공개')
|
||||
.css({color: visible ? '#1B8C4A' : '#999', 'margin-right': '5px'})
|
||||
.appendTo($visibleTd);
|
||||
$('<button type="button" class="cssbtn" level="W" status="DETAIL" style="min-width:0;padding:3px 8px;">')
|
||||
.text(visible ? '비공개로' : '공개로')
|
||||
.click(function() { toggleTimelineVisible(row.timelineId, visible ? 'N' : 'Y'); })
|
||||
.appendTo($visibleTd);
|
||||
$tr.append($visibleTd);
|
||||
|
||||
var $deleteTd = $('<td style="text-align:center;">');
|
||||
$('<button type="button" class="cssbtn smallBtn" level="W" status="DETAIL" style="min-width:0;width:90%;">')
|
||||
.text('삭제')
|
||||
.click(function() { deleteTimeline(row.timelineId); })
|
||||
.appendTo($deleteTd);
|
||||
$tr.append($deleteTd);
|
||||
|
||||
$tb.append($tr);
|
||||
});
|
||||
buttonControl(true);
|
||||
}
|
||||
|
||||
function addTimeline() {
|
||||
if (!currentIncidentId) {
|
||||
alert('장애 정보를 먼저 저장하여 주십시오.');
|
||||
return;
|
||||
}
|
||||
var body = $('#timelineBody').val();
|
||||
if (!body || !$.trim(body)) {
|
||||
alert('타임라인 메세지를 입력하여 주십시오.');
|
||||
$('#timelineBody').focus();
|
||||
return;
|
||||
}
|
||||
var stateChange = $('#timelineStateChange').is(':checked');
|
||||
var nextState = $('#timelineNextState').val();
|
||||
if (stateChange && !nextState) {
|
||||
alert('변경할 상태를 선택하여 주십시오.');
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
type: "POST", url: url, dataType: "json",
|
||||
data: {cmd: 'TIMELINE_ADD', incidentId: currentIncidentId, body: body,
|
||||
stateChange: stateChange, nextState: nextState},
|
||||
success: function (data) {
|
||||
$('#timelineBody').val('');
|
||||
$('#timelineStateChange').prop('checked', false).trigger('change');
|
||||
// 상태 전이 시 서버가 상태/이전 상태/종료 시각을 함께 갱신한다
|
||||
$('#stateText').text(data.state ? stateLabel(data.state) : '');
|
||||
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
|
||||
$('#endAt').val(data.endAt || '');
|
||||
currentIncidentState = data.state || null;
|
||||
applyStateChangeGuard();
|
||||
// 종결 전이 직후부터 종료 시각을 손볼 수 있어야 한다
|
||||
toggleIncidentFields();
|
||||
loadTimeline();
|
||||
},
|
||||
error: function (e) { alert(e.responseText); }
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTimelineVisible(timelineId, visibleYn) {
|
||||
$.ajax({
|
||||
type: "POST", url: url,
|
||||
data: {cmd: 'TIMELINE_VISIBLE', timelineId: timelineId, visibleYn: visibleYn},
|
||||
success: loadTimeline,
|
||||
error: function (e) { alert(e.responseText); }
|
||||
});
|
||||
}
|
||||
|
||||
function deleteTimeline(timelineId) {
|
||||
if (confirm("<%= localeMessage.getString("common.checkDelete")%>") != true) return;
|
||||
$.ajax({
|
||||
type: "POST", url: url,
|
||||
data: {cmd: 'TIMELINE_DELETE', timelineId: timelineId},
|
||||
success: loadTimeline,
|
||||
error: function (e) { alert(e.responseText); }
|
||||
});
|
||||
}
|
||||
|
||||
/** 장애/점검 공지는 최초 게시 후 게시유형을 바꿀 수 없다. */
|
||||
function lockNoticeType(noticeType) {
|
||||
if (!isIncidentType(noticeType)) return;
|
||||
$('#noticeType').prop('disabled', true);
|
||||
if (!$('#noticeTypeHidden').length) {
|
||||
$('#ajaxForm').append('<input type="hidden" id="noticeTypeHidden" name="noticeType">');
|
||||
}
|
||||
$('#noticeTypeHidden').val(noticeType);
|
||||
}
|
||||
|
||||
function detail(key) {
|
||||
if (!isDetail) return;
|
||||
$.ajax({
|
||||
@@ -207,17 +392,28 @@
|
||||
var decodedContent = decodeHTMLEntities(data.noticeDetail);
|
||||
$('#contents').summernote('code', decodedContent);
|
||||
|
||||
// 게시유형은 최초 저장 후 변경 불가 (disabled 는 전송되지 않아 hidden 으로 값을 유지)
|
||||
lockNoticeType(data.noticeType);
|
||||
|
||||
// 장애/점검 필드
|
||||
$('#summary').val(data.summary || '');
|
||||
$('#startedAt').val(data.startedAt || '');
|
||||
$('#endAt').val(data.endAt || '');
|
||||
$('#state').val(data.state || 'INVESTIGATING');
|
||||
$('#previousState').text(data.previousState || '없음');
|
||||
// 상태/이전 상태는 타임라인 게시로만 바뀐다 (직접 수정 불가)
|
||||
$('#stateText').text(data.state ? stateLabel(data.state) : '');
|
||||
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
|
||||
affectedApis = (data.affectedApis || []).map(function(a){
|
||||
return {apiId: a.apiId, apiName: a.apiName || ''};
|
||||
});
|
||||
renderAffectedApis();
|
||||
|
||||
currentIncidentId = data.incidentId || null;
|
||||
currentIncidentState = data.state || null;
|
||||
applyStateChangeGuard();
|
||||
toggleIncidentFields();
|
||||
if (data.noticeType === NOTICE_TYPE_INCIDENT && currentIncidentId) {
|
||||
loadTimeline();
|
||||
}
|
||||
|
||||
if (data.fileInfo) {
|
||||
fileInfo = {
|
||||
@@ -245,6 +441,13 @@
|
||||
$('#btn_addAffectedApi').click(openApiPopup);
|
||||
$('#btn_removeAffectedApi').click(removeSelectedAffectedApis);
|
||||
|
||||
$('#timelineStateChange').on('change', function() {
|
||||
var checked = $(this).is(':checked');
|
||||
$('#timelineNextState').prop('disabled', !checked);
|
||||
if (!checked) $('#timelineNextState').val('');
|
||||
});
|
||||
$('#btn_addTimeline').click(addTimeline);
|
||||
|
||||
$('#fileInput').change(function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (file) {
|
||||
@@ -273,6 +476,11 @@
|
||||
alert('점검 종료 시각을 입력해 주십시오.');
|
||||
return;
|
||||
}
|
||||
// datetime-local 값은 'YYYY-MM-DDTHH:mm' 이라 문자열 비교로 선후가 가려진다
|
||||
if ($('#endAt').val() && $('#endAt').val() < $('#startedAt').val()) {
|
||||
alert('종료 시각은 시작 시각보다 빠를 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (affectedApis.length === 0) {
|
||||
alert('영향 API를 1건 이상 선택해 주십시오.');
|
||||
return;
|
||||
@@ -394,21 +602,15 @@
|
||||
<th>종료</th>
|
||||
<td>
|
||||
<input type="datetime-local" id="endAt" name="endAt" style="width:90%" placeholder="해소시 자동 채움">
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="incident-row incident-state-row" style="display:none;">
|
||||
<th>상태</th>
|
||||
<td>
|
||||
<div class="select-style">
|
||||
<select id="state" name="state">
|
||||
<option value="INVESTIGATING">INVESTIGATING - 조사중</option>
|
||||
<option value="IDENTIFIED">IDENTIFIED - 원인 파악</option>
|
||||
<option value="MONITORING">MONITORING - 모니터링</option>
|
||||
<option value="RESOLVED">RESOLVED - 해소</option>
|
||||
<option value="CANCELED">CANCELED - 취소</option>
|
||||
</select>
|
||||
<div class="incident-endat-hint" style="display:none;color:#888;font-size:12px;margin-top:3px;">
|
||||
※ 타임라인에서 해소·취소로 전이하면 자동 입력됩니다.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 상태/이전 상태는 타임라인 게시로만 전이한다 (직접 수정 불가) -->
|
||||
<tr class="incident-row incident-state-row" style="display:none;">
|
||||
<th>상태</th>
|
||||
<td><span id="stateText"></span></td>
|
||||
<th>이전 상태</th>
|
||||
<td><span id="previousState">없음</span></td>
|
||||
</tr>
|
||||
@@ -435,6 +637,61 @@
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
|
||||
<!-- 타임라인은 [수정] 저장과 별개로 게시 즉시 반영되므로 폼 밖에 둔다 -->
|
||||
<div id="timelineSection" style="display:none;margin-top:20px;">
|
||||
<div class="title">타임라인</div>
|
||||
<table class="table_row" cellspacing="0" style="width:100%;">
|
||||
<colgroup>
|
||||
<col style="width: 130px"/>
|
||||
<col style="width: 80px"/>
|
||||
<col style="width: 140px"/>
|
||||
<col/>
|
||||
<col style="width: 160px"/>
|
||||
<col style="width: 80px"/>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>작성자</th>
|
||||
<th>상태</th>
|
||||
<th>메세지</th>
|
||||
<th>개발자포탈 공개</th>
|
||||
<th>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="timelineTbody">
|
||||
<tr><td colspan="6" style="text-align:center;color:#999;">등록된 타임라인이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;">
|
||||
<label>
|
||||
<input type="checkbox" id="timelineStateChange"> 상태 변경
|
||||
</label>
|
||||
<div class="select-style" style="width:220px;">
|
||||
<select id="timelineNextState" disabled>
|
||||
<option value="">(현재 상태 유지)</option>
|
||||
<option value="INVESTIGATING">INVESTIGATING - 조사중</option>
|
||||
<option value="IDENTIFIED">IDENTIFIED - 원인 파악</option>
|
||||
<option value="MONITORING">MONITORING - 모니터링</option>
|
||||
<option value="RESOLVED">RESOLVED - 해소</option>
|
||||
<option value="CANCELED">CANCELED - 취소</option>
|
||||
</select>
|
||||
</div>
|
||||
<span id="timelineResolveHint" style="color:#888;font-size:12px;">※ 해소(RESOLVED)·취소(CANCELED)로 전이하면 종료 시각이 자동 입력됩니다.</span>
|
||||
<span id="timelineClosedHint" style="display:none;color:#C0392B;font-size:12px;">
|
||||
※ 종결된 장애입니다. 상태 변경 불가 — 재발 시 새 장애로 등록하십시오.
|
||||
</span>
|
||||
</div>
|
||||
<div style="margin-top:5px;display:flex;gap:5px;">
|
||||
<textarea id="timelineBody" style="flex:1;height:60px;"
|
||||
placeholder="타임라인 메세지 입력 예 : 모니터링 5분간 정상 응답 확인 후 완료 상태 전이 예정"></textarea>
|
||||
<button type="button" class="cssbtn" id="btn_addTimeline" level="W" status="DETAIL">
|
||||
<i class="material-icons">send</i> 게시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+176
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user