점검 공지 사전 발송 예약/결과 관리 기능 추가
eapim-admin CI / build (push) Waiting to run

- DjbApistatusIncidentPrenotice Entity/Repository 추가
- 사전 발송 시각 및 결과 관리 로직 PortalNoticeManService에 추가
- UI에 사전 발송 관련 Feature 추가 및 Validation 포함
This commit is contained in:
Rinjae(gf63)
2026-09-21 09:08:59 +09:00
parent 4e22683ddb
commit cb3390b90d
3 changed files with 237 additions and 0 deletions
@@ -15,6 +15,14 @@
<jsp:include page="/jsp/common/include/css.jsp"/> <jsp:include page="/jsp/common/include/css.jsp"/>
<link rel="stylesheet" type="text/css" href="<c:url value="/css/editor-content.css"/>"/> <link rel="stylesheet" type="text/css" href="<c:url value="/css/editor-content.css"/>"/>
<jsp:include page="/jsp/common/include/script.jsp"/> <jsp:include page="/jsp/common/include/script.jsp"/>
<style>
/* 항목별 ? 도움말 — 유량제어 화면(inflowGroupDetailStyle.jsp)의 규칙을 가져오되 hover 가 아닌 클릭 토글 */
.hint-icon { display:inline-flex; align-items:center; cursor:pointer; color:#999; position:relative; margin-left:4px; vertical-align:middle; }
.hint-icon:hover { color:rgba(0,128,128,1); }
.hint-icon i { font-size:14px; }
.hint-icon .hint-bubble { display:none; position:absolute; bottom:calc(100% + 8px); left:0; background:#333; color:#fff; padding:10px 12px; border-radius:4px; font-size:11px; width:220px; line-height:1.5; z-index:1000; box-shadow:0 2px 8px rgba(0,0,0,0.2); white-space:normal; word-break:keep-all; text-align:left; font-weight:normal; }
.hint-icon .hint-bubble::after { content:''; position:absolute; top:100%; left:14px; border:6px solid transparent; border-top-color:#333; }
</style>
<script language="javascript" > <script language="javascript" >
var url = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.json" />'; var url = '<c:url value="/onl/apim/portalnotice/portalNoticeMan.json" />';
@@ -32,6 +40,9 @@
// 영향 API 목록 (UI 상태) // 영향 API 목록 (UI 상태)
var affectedApis = []; var affectedApis = [];
// 상세 진입 시 읽어온 사전 발송 지정 시각 (변경 여부 판단용)
var loadedPrenotiAt = '';
// 장애 타임라인 (UI 상태) // 장애 타임라인 (UI 상태)
var currentIncidentId = null; var currentIncidentId = null;
var currentIncidentState = null; var currentIncidentState = null;
@@ -48,6 +59,13 @@
'CANCELED': '취소' 'CANCELED': '취소'
}; };
// PrenoticeResult 라벨 — 사전 발송 결과
var PRENOTI_LABELS = {
'PENDING': '대기',
'SUCCESS': '성공',
'FAIL': '실패'
};
function isIncidentType(t) { function isIncidentType(t) {
return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE; return t === NOTICE_TYPE_INCIDENT || t === NOTICE_TYPE_MAINTENANCE;
} }
@@ -61,6 +79,51 @@
return $('<div>').text(text == null ? '' : text).html(); return $('<div>').text(text == null ? '' : text).html();
} }
/** 현재 시각을 datetime-local 입력값과 같은 'YYYY-MM-DDTHH:mm' 로 (문자열 비교용, 로컬 타임존 기준) */
function nowAsDatetimeLocal() {
var d = new Date();
function pad(n) { return (n < 10 ? '0' : '') + n; }
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
/**
* 사전 발송 결과 영역을 그린다.
*
* 결과 코드가 없으면(= 예약 없음) 결과는 '-' 로 두고 상세 박스를 감춘다.
* 실제 발송 시각·응답은 Webhook 발송 내역에서 확인해야 하므로 여기서는 작업 시각만 보여준다.
*/
function renderPrenoticeResult(data) {
var result = data.prenotiResult;
if (!result) {
$('#prenotiResultText').text('-');
$('#prenotiDetailBox').hide();
$('#prenotiFailRow').hide();
return;
}
$('#prenotiResultText').text(PRENOTI_LABELS[result] || result);
if (result === 'PENDING') {
$('#prenotiDetailBox').hide();
$('#prenotiFailRow').hide();
return;
}
// 서버는 'yyyy-MM-ddTHH:mm' 으로 내려준다 (PortalNoticeUI.prenotiRunAt)
$('#prenotiRunAt').text((data.prenotiRunAt || '').replace('T', ' ') || '-');
$('#prenotiSentCnt').text(data.prenotiSentCnt == null ? '-' : data.prenotiSentCnt);
$('#prenotiTargetCnt').text(data.prenotiTargetCnt == null ? '-' : data.prenotiTargetCnt);
$('#prenotiDetailBox').show();
if (result === 'FAIL' && data.prenotiFailMsg) {
$('#prenotiFailMsg').text(data.prenotiFailMsg);
$('#prenotiFailRow').show();
} else {
$('#prenotiFailRow').hide();
}
}
function init() { function init() {
var key = "${param.id}"; var key = "${param.id}";
isDetail = key != "" && key != "null"; isDetail = key != "" && key != "null";
@@ -118,6 +181,13 @@
$('.incident-endat-hint').hide(); $('.incident-endat-hint').hide();
$('#endAt').prop('readonly', false); $('#endAt').prop('readonly', false);
} }
// 사전 발송은 점검 공지에만 있다 - 장애는 언제 터질지 모르니 미리 알릴 수 없다
$('.maintenance-row').toggle(t === NOTICE_TYPE_MAINTENANCE);
if (t !== NOTICE_TYPE_MAINTENANCE) {
// 유형을 바꿔 저장할 때 값이 딸려가지 않도록 비운다
$('#prenotiAt').val('');
}
// 양식(템플릿)은 장애 공지에만 쓴다 - 점검은 문구 체계가 다르다 // 양식(템플릿)은 장애 공지에만 쓴다 - 점검은 문구 체계가 다르다
$('.template-row').toggle(t === NOTICE_TYPE_INCIDENT); $('.template-row').toggle(t === NOTICE_TYPE_INCIDENT);
@@ -430,6 +500,12 @@
$('#summary').val(data.summary || ''); $('#summary').val(data.summary || '');
$('#startedAt').val(data.startedAt || ''); $('#startedAt').val(data.startedAt || '');
$('#endAt').val(data.endAt || ''); $('#endAt').val(data.endAt || '');
// 사전 발송 — 발송 작업이 이미 돈 건은 지정 시각을 잠근다
loadedPrenotiAt = data.prenotiAt || '';
$('#prenotiAt').val(loadedPrenotiAt);
$('#prenotiAt').prop('readonly', !!data.prenotiSent);
$('#prenotiLockHint').toggle(!!data.prenotiSent);
renderPrenoticeResult(data);
// 상태/이전 상태는 타임라인 게시로만 바뀐다 (직접 수정 불가) // 상태/이전 상태는 타임라인 게시로만 바뀐다 (직접 수정 불가)
$('#stateText').text(data.state ? stateLabel(data.state) : ''); $('#stateText').text(data.state ? stateLabel(data.state) : '');
$('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음'); $('#previousState').text(data.previousState ? stateLabel(data.previousState) : '없음');
@@ -469,6 +545,15 @@
$('#noticeType').on('change', toggleIncidentFields); $('#noticeType').on('change', toggleIncidentFields);
// ? 도움말 — 클릭으로 열고, 바깥을 누르면 닫는다
$(document).on('click', '.hint-icon', function (e) {
e.stopPropagation();
var bubble = $(this).find('.hint-bubble');
$('.hint-bubble').not(bubble).hide();
bubble.toggle();
});
$(document).on('click', function () { $('.hint-bubble').hide(); });
// 신규 등록에서 장애를 고르면 빈 본문에 한해 양식을 자동으로 채운다. // 신규 등록에서 장애를 고르면 빈 본문에 한해 양식을 자동으로 채운다.
// (수정 모드나 이미 쓴 본문은 건드리지 않는다 - 덮어쓰려면 '양식 적용' 버튼) // (수정 모드나 이미 쓴 본문은 건드리지 않는다 - 덮어쓰려면 '양식 적용' 버튼)
$('#noticeType').on('change', function () { $('#noticeType').on('change', function () {
@@ -527,6 +612,21 @@
} }
} }
// 사전 발송 (점검 한정) — 입력했을 때만 검사한다. 미입력이면 예약 없음.
if (noticeType === NOTICE_TYPE_MAINTENANCE && $('#prenotiAt').val()) {
if ($('#prenotiAt').val() >= $('#startedAt').val()) {
alert('사전 발송 시각은 점검 시작 시각보다 빨라야 합니다.');
return;
}
// 지난 시각으로는 새로 예약할 수 없다 - 스케줄러가 곧바로 집어가 발송해 버린다.
// 값을 바꾸지 않았다면(= 스케줄러가 아직 못 집어간 기존 예약) 통과시킨다.
if ($('#prenotiAt').val() !== loadedPrenotiAt
&& $('#prenotiAt').val() <= nowAsDatetimeLocal()) {
alert('사전 발송 시각은 현재 시각 이후여야 합니다.\n이미 지난 시각으로는 예약할 수 없습니다.');
return;
}
}
if (confirm("<%= localeMessage.getString("common.checkSave")%>") != true) return; if (confirm("<%= localeMessage.getString("common.checkSave")%>") != true) return;
var formData = new FormData($("#ajaxForm")[0]); var formData = new FormData($("#ajaxForm")[0]);
@@ -541,6 +641,7 @@
// 시작/종료 시각이 비어 있으면 전송하지 않음 (서버 파싱 오류 방지) // 시작/종료 시각이 비어 있으면 전송하지 않음 (서버 파싱 오류 방지)
if (!$('#startedAt').val()) formData.delete('startedAt'); if (!$('#startedAt').val()) formData.delete('startedAt');
if (!$('#endAt').val()) formData.delete('endAt'); if (!$('#endAt').val()) formData.delete('endAt');
if (!$('#prenotiAt').val()) formData.delete('prenotiAt');
// 영향 API → affectedApis[i].apiId/apiName 으로 전송 // 영향 API → affectedApis[i].apiId/apiName 으로 전송
$.each(affectedApis, function(i, api) { $.each(affectedApis, function(i, api) {
@@ -656,6 +757,30 @@
</div> </div>
</td> </td>
</tr> </tr>
<!-- ───── 점검 전용 - 사전 발송 ───── -->
<tr class="maintenance-row" style="display:none;">
<th>사전 발송</th>
<td>
<input type="datetime-local" id="prenotiAt" name="prenotiAt" style="width:90%" placeholder="미입력시 사전 발송 안함">
<div id="prenotiLockHint" style="display:none;color:#888;font-size:12px;margin-top:3px;">
※ 이미 발송된 건은 지정 시각을 변경할 수 없습니다.
</div>
</td>
<th>발송 결과
<span class="hint-icon"><i class="material-icons">help_outline</i>
<span class="hint-bubble">여기 표시되는 값은 발송 작업이 돌아간 시각입니다.
실제 발송 시각과 응답은 Webhook 발송 내역에서 확인하여 주십시오.</span>
</span>
</th>
<td>
<span id="prenotiResultText">-</span>
<div id="prenotiDetailBox" style="display:none;color:#666;font-size:12px;margin-top:3px;">
<div>발송 작업 : <span id="prenotiRunAt"></span></div>
<div>발송 성공 대상 : <span id="prenotiSentCnt"></span> / 대상 <span id="prenotiTargetCnt"></span> 건</div>
<div id="prenotiFailRow" style="display:none;color:#c00;">실패 사유 : <span id="prenotiFailMsg"></span></div>
</div>
</td>
</tr>
<!-- 상태/이전 상태는 타임라인 게시로만 전이한다 (직접 수정 불가) --> <!-- 상태/이전 상태는 타임라인 게시로만 전이한다 (직접 수정 불가) -->
<tr class="incident-row incident-state-row" style="display:none;"> <tr class="incident-row incident-state-row" style="display:none;">
<th>상태</th> <th>상태</th>
@@ -3,10 +3,13 @@ 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.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi; 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.DjbApistatusIncidentApiId;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentPrenotice;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline; 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.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState; import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.entity.PrenoticeResult;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository; import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentPrenoticeRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository; 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.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.file.entity.FileDetail; import com.eactive.apim.portal.file.entity.FileDetail;
@@ -53,6 +56,7 @@ public class PortalNoticeManService extends BaseService {
private final DjbApistatusIncidentRepository incidentRepository; private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository; private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository incidentTimelineRepository; private final DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
private final DjbApistatusIncidentPrenoticeRepository incidentPrenoticeRepository;
private final ApiStatusDraftTemplate draftTemplate; private final ApiStatusDraftTemplate draftTemplate;
private final ApiStatusNoticeComposer noticeComposer; private final ApiStatusNoticeComposer noticeComposer;
@@ -81,6 +85,7 @@ public class PortalNoticeManService extends BaseService {
DjbApistatusIncidentRepository incidentRepository, DjbApistatusIncidentRepository incidentRepository,
DjbApistatusIncidentApiRepository incidentApiRepository, DjbApistatusIncidentApiRepository incidentApiRepository,
DjbApistatusIncidentTimelineRepository incidentTimelineRepository, DjbApistatusIncidentTimelineRepository incidentTimelineRepository,
DjbApistatusIncidentPrenoticeRepository incidentPrenoticeRepository,
ApiStatusDraftTemplate draftTemplate, ApiStatusDraftTemplate draftTemplate,
ApiStatusNoticeComposer noticeComposer){ ApiStatusNoticeComposer noticeComposer){
this.portalNoticeService = portalNoticeService; this.portalNoticeService = portalNoticeService;
@@ -89,6 +94,7 @@ public class PortalNoticeManService extends BaseService {
this.incidentRepository = incidentRepository; this.incidentRepository = incidentRepository;
this.incidentApiRepository = incidentApiRepository; this.incidentApiRepository = incidentApiRepository;
this.incidentTimelineRepository = incidentTimelineRepository; this.incidentTimelineRepository = incidentTimelineRepository;
this.incidentPrenoticeRepository = incidentPrenoticeRepository;
this.draftTemplate = draftTemplate; this.draftTemplate = draftTemplate;
this.noticeComposer = noticeComposer; this.noticeComposer = noticeComposer;
} }
@@ -221,6 +227,21 @@ public class PortalNoticeManService extends BaseService {
return vo; return vo;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
ui.setAffectedApis(apis); ui.setAffectedApis(apis);
populatePrenotice(incident, ui);
}
/** 사전 발송 예약/결과를 화면에 실어준다. 예약이 없으면 값은 모두 비워둔다. */
private void populatePrenotice(DjbApistatusIncident incident, PortalNoticeUI ui) {
incidentPrenoticeRepository.findById(incident.getIncidentId()).ifPresent(prenotice -> {
ui.setPrenotiAt(prenotice.getPrenotiAt());
ui.setPrenotiRunAt(prenotice.getRunAt());
ui.setPrenotiResult(prenotice.getResult() == null ? null : prenotice.getResult().name());
ui.setPrenotiTargetCnt(prenotice.getTargetCnt());
ui.setPrenotiSentCnt(prenotice.getSentCnt());
ui.setPrenotiFailMsg(prenotice.getFailMsg());
ui.setPrenotiSent(isPrenoticeSent(prenotice));
});
} }
public void insert(PortalNoticeUI portalNoticeUI) throws IOException { public void insert(PortalNoticeUI portalNoticeUI) throws IOException {
@@ -326,9 +347,79 @@ public class PortalNoticeManService extends BaseService {
} }
} }
persistPrenotice(portalNoticeUI, saved);
return saved; return saved;
} }
// ───────────────────── 점검 사전발송 ─────────────────────
/**
* 사전 발송 예약을 저장한다. 행이 있다 = 예약이 있다.
*
* <p>사전 발송은 점검(MAINTENANCE)에만 있다 - 장애는 언제 터질지 모르니 미리 알릴 수 없다.
* 지정 시각을 비우고 저장하면 예약 취소로 보고 행을 지우되, <b>이미 발송된 건은 남긴다</b>
* (발송 이력을 관리자가 지울 수 있으면 안 된다).</p>
*
* <p>실제 발송은 이 코드가 하지 않는다. RUN_AT/RESULT/건수/실패사유는 향후 스케줄러가 채운다.</p>
*/
/** 발송 작업이 이미 돌았는지. RESULT 가 비어 있는 행(구 데이터)은 미발송으로 본다. */
private static boolean isPrenoticeSent(DjbApistatusIncidentPrenotice prenotice) {
return prenotice != null && prenotice.getResult() != null && prenotice.getResult().isSent();
}
private void persistPrenotice(PortalNoticeUI portalNoticeUI, DjbApistatusIncident incident) {
DjbApistatusIncidentPrenotice prenotice =
incidentPrenoticeRepository.findById(incident.getIncidentId()).orElse(null);
if (incident.getKind() != IncidentKind.MAINTENANCE) {
// 점검 → 장애로 유형이 바뀐 경우. 미발송 예약만 정리한다.
if (prenotice != null && !isPrenoticeSent(prenotice)) {
incidentPrenoticeRepository.delete(prenotice);
}
return;
}
LocalDateTime prenotiAt = portalNoticeUI.getPrenotiAt();
if (prenotiAt == null) {
if (prenotice != null && !isPrenoticeSent(prenotice)) {
incidentPrenoticeRepository.delete(prenotice);
}
return;
}
// 이미 발송된 건은 지정 시각을 못 바꾼다. 다만 값이 그대로면 제목만 고치는 저장까지 막지는 않는다.
if (isPrenoticeSent(prenotice)) {
if (!prenotiAt.equals(prenotice.getPrenotiAt())) {
throw new IllegalArgumentException("이미 발송된 사전 발송은 지정 시각을 변경할 수 없습니다.");
}
return;
}
// "사전"이므로 점검 시작보다 빨라야 한다 - 시작 이후면 예고가 아니라 사후 통보가 된다.
if (incident.getStartedAt() != null && !prenotiAt.isBefore(incident.getStartedAt())) {
throw new IllegalArgumentException("사전 발송 시각은 점검 시작 시각보다 빨라야 합니다.");
}
// 지난 시각으로는 새로 예약할 수 없다. 스케줄러는 'PENDING 이고 지정 시각이 지난' 건을 집어가므로,
// 이미 끝난 점검 공지를 수정하면서 과거 시각을 넣으면 곧바로 발송돼 버린다.
// 단, 값이 그대로면 통과시킨다 - 스케줄러가 아직 집어가지 못한 기존 예약 때문에
// 제목만 고치는 평범한 수정까지 막히면 안 된다.
boolean prenotiAtChanged = prenotice == null || !prenotiAt.equals(prenotice.getPrenotiAt());
if (prenotiAtChanged && !prenotiAt.isAfter(LocalDateTime.now())) {
throw new IllegalArgumentException("사전 발송 시각은 현재 시각 이후여야 합니다. 이미 지난 시각으로는 예약할 수 없습니다.");
}
if (prenotice == null) {
prenotice = new DjbApistatusIncidentPrenotice();
prenotice.setIncidentId(incident.getIncidentId());
prenotice.setResult(PrenoticeResult.PENDING);
}
prenotice.setPrenotiAt(prenotiAt);
incidentPrenoticeRepository.save(prenotice);
}
// ───────────────────── 장애 처리 타임라인 ───────────────────── // ───────────────────── 장애 처리 타임라인 ─────────────────────
private static final String AUTHOR_OPERATOR = "OPERATOR"; private static final String AUTHOR_OPERATOR = "OPERATOR";
@@ -478,6 +569,7 @@ public class PortalNoticeManService extends BaseService {
incidentRepository.findByNoticeId(noticeId).ifPresent(incident -> { incidentRepository.findByNoticeId(noticeId).ifPresent(incident -> {
incidentApiRepository.deleteByIncidentId(incident.getIncidentId()); incidentApiRepository.deleteByIncidentId(incident.getIncidentId());
incidentTimelineRepository.deleteByIncidentId(incident.getIncidentId()); incidentTimelineRepository.deleteByIncidentId(incident.getIncidentId());
incidentPrenoticeRepository.deleteByIncidentId(incident.getIncidentId());
incidentRepository.delete(incident); incidentRepository.delete(incident);
}); });
} }
@@ -78,4 +78,24 @@ public class PortalNoticeUI {
private String previousState; // 직전 상태 private String previousState; // 직전 상태
private List<IncidentAffectedApiUI> affectedApis; private List<IncidentAffectedApiUI> affectedApis;
// ───── 사전 발송 (점검 공지 한정, djb_apistatus_incident_prenotice) ─────
// 관리자가 입력하는 값은 prenotiAt 하나다. 나머지는 발송 스케줄러가 채우는 읽기 전용 결과.
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime prenotiAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime prenotiRunAt;
private String prenotiResult; // PENDING / SUCCESS / FAIL
private Integer prenotiTargetCnt;
private Integer prenotiSentCnt;
private String prenotiFailMsg;
/** 발송 작업이 이미 돌았는지 - 화면에서 지정 시각을 잠그는 판단에 쓴다. */
private boolean prenotiSent;
} }