자동 장애 탐지 초안 양식 추가
- apistatus-draft.yml 파일 및 관련 로직 추가 - SnakeYAML 의존성 추가 및 안전한 yml 파싱 구현 - 자동 탐지 공지 제목·본문·타임라인 문구 분리 및 설정 관리
This commit is contained in:
@@ -118,6 +118,12 @@ dependencies {
|
|||||||
//implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
//implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
|
||||||
|
|
||||||
|
// apistatus-draft.yml (자동 탐지 초안 양식) 로딩용.
|
||||||
|
// admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없어 직접 파싱한다.
|
||||||
|
// 2.x 를 쓴다 - 1.x 는 CVE-2022-1471(Constructor 역직렬화 RCE) 및
|
||||||
|
// CVE-2022-25857/38749~38752(DoS) 영향. 로딩도 SafeConstructor 로 한다.
|
||||||
|
implementation 'org.yaml:snakeyaml:2.4'
|
||||||
|
|
||||||
implementation "javax.jms:javax.jms-api:2.0"
|
implementation "javax.jms:javax.jms-api:2.0"
|
||||||
implementation "org.snmp4j:snmp4j:1.10.1"
|
implementation "org.snmp4j:snmp4j:1.10.1"
|
||||||
implementation "com.googlecode.json-simple:json-simple:1.1.1"
|
implementation "com.googlecode.json-simple:json-simple:1.1.1"
|
||||||
|
|||||||
+126
-36
@@ -113,6 +113,10 @@ public class ApiStatusDetectionService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PortalPropertyService portalPropertyService;
|
private PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */
|
||||||
|
@Autowired
|
||||||
|
private ApiStatusDraftTemplate draftTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 감지
|
* 이벤트 감지
|
||||||
* @param event "CONTROL_START" : 점검시작
|
* @param event "CONTROL_START" : 점검시작
|
||||||
@@ -206,7 +210,7 @@ public class ApiStatusDetectionService {
|
|||||||
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
|
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
|
||||||
if (duplicated.isPresent()) {
|
if (duplicated.isPresent()) {
|
||||||
appendTimeline(duplicated.get().getIncidentId(), null,
|
appendTimeline(duplicated.get().getIncidentId(), null,
|
||||||
"재탐지 신호 수신 (" + event + ")", now);
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +256,15 @@ public class ApiStatusDetectionService {
|
|||||||
}
|
}
|
||||||
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
|
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
|
||||||
appendTimeline(existing.getIncidentId(), null,
|
appendTimeline(existing.getIncidentId(), null,
|
||||||
"재탐지 신호 수신 (" + event + ")", now));
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
|
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
|
||||||
|
// 타임라인도 고객 화면에 그대로 나가므로 인터페이스 ID 대신 게시 API 명 + 건수로 적는다
|
||||||
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
|
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
|
||||||
summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now);
|
draftTemplate.text(ApiStatusDraftTemplate.TL_DETECTED,
|
||||||
|
"summary", summary, "event", event, "affected", affectedSummary(apiIds, apiNames)), now);
|
||||||
|
|
||||||
log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}",
|
log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}",
|
||||||
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
|
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
|
||||||
@@ -308,7 +314,7 @@ public class ApiStatusDetectionService {
|
|||||||
Long incidentId = incident.getIncidentId();
|
Long incidentId = incident.getIncidentId();
|
||||||
|
|
||||||
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
|
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
|
||||||
escalateIfNeeded(incident, event, kind, now, autoPublish);
|
boolean escalated = escalateIfNeeded(incident, event, kind, now, autoPublish);
|
||||||
|
|
||||||
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
|
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
|
||||||
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
|
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
|
||||||
@@ -325,14 +331,22 @@ public class ApiStatusDetectionService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
if (added.isEmpty()) {
|
if (added.isEmpty()) {
|
||||||
appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now);
|
// 격상을 남긴 턴에는 같은 시각·같은 이벤트로 "재탐지 신호 수신"이 겹쳐 찍힌다.
|
||||||
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
|
// 격상 줄이 더 구체적인 정보이므로 재탐지 줄은 생략한다.
|
||||||
|
if (!escalated) {
|
||||||
|
appendTimeline(incidentId, null,
|
||||||
|
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
|
||||||
|
}
|
||||||
|
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}, escalated={}",
|
||||||
|
incidentId, apiIds, escalated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
|
Map<String, String> addedNames = resolveApiNames(added);
|
||||||
|
saveIncidentApis(incidentId, added, addedNames, now);
|
||||||
appendTimeline(incidentId, null,
|
appendTimeline(incidentId, null,
|
||||||
"영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now);
|
draftTemplate.text(ApiStatusDraftTemplate.TL_API_ADDED,
|
||||||
|
"event", event, "affected", affectedSummary(added, addedNames)), now);
|
||||||
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
|
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +355,13 @@ public class ApiStatusDetectionService {
|
|||||||
*
|
*
|
||||||
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
|
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
|
||||||
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
|
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
|
||||||
|
*
|
||||||
|
* @return 실제로 격상하여 타임라인을 남겼으면 true
|
||||||
*/
|
*/
|
||||||
private void escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
|
private boolean escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
|
||||||
LocalDateTime now, boolean autoPublish) {
|
LocalDateTime now, boolean autoPublish) {
|
||||||
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
|
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||||
@@ -370,9 +386,10 @@ public class ApiStatusDetectionService {
|
|||||||
incidentRepository.save(incident);
|
incidentRepository.save(incident);
|
||||||
|
|
||||||
appendTimeline(incident.getIncidentId(), null,
|
appendTimeline(incident.getIncidentId(), null,
|
||||||
"지연에서 장애로 격상 (" + event + ")", now);
|
draftTemplate.text(ApiStatusDraftTemplate.TL_ESCALATED, "event", event), now);
|
||||||
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
|
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
|
||||||
incident.getIncidentId(), incident.getNoticeId());
|
incident.getIncidentId(), incident.getNoticeId());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,8 +476,11 @@ public class ApiStatusDetectionService {
|
|||||||
incident.setLastModifiedDate(now);
|
incident.setLastModifiedDate(now);
|
||||||
incidentRepository.save(incident);
|
incidentRepository.save(incident);
|
||||||
|
|
||||||
String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n"
|
String body = draftTemplate.text(
|
||||||
+ String.join(", ", recoveredApis);
|
allRecovered ? ApiStatusDraftTemplate.TL_RECOVERED_ALL
|
||||||
|
: ApiStatusDraftTemplate.TL_RECOVERED_PART,
|
||||||
|
"event", event,
|
||||||
|
"affected", affectedSummary(recoveredApis, resolveApiNames(recoveredApis)));
|
||||||
appendTimeline(incidentId, nextState, body, now);
|
appendTimeline(incidentId, nextState, body, now);
|
||||||
|
|
||||||
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
|
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
|
||||||
@@ -500,21 +520,80 @@ public class ApiStatusDetectionService {
|
|||||||
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
|
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) {
|
* 게시된 것(= API)만 이름을 드러내고 나머지는 건수로만 표기한다.
|
||||||
return "[자동감지] " + first + " 외 " + (apiIds.size() - 1) + "종 API " + titleKeyword;
|
*/
|
||||||
|
private static final class ApiSplit {
|
||||||
|
private final List<String> published;
|
||||||
|
private final int hiddenCount;
|
||||||
|
|
||||||
|
private ApiSplit(List<String> published, int hiddenCount) {
|
||||||
|
this.published = published;
|
||||||
|
this.hiddenCount = hiddenCount;
|
||||||
}
|
}
|
||||||
return "[자동감지] " + first + " API " + titleKeyword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 관리자가 검수 시 채워 넣을 항목 - {제목, 작성 힌트} */
|
/** 게시 인터페이스 판정. 조회에 실패하면 전부 비게시로 보아 ID·명칭 노출을 막는다. */
|
||||||
private static final String[][] NOTICE_FILL_IN_SECTIONS = {
|
private ApiSplit splitPublished(List<String> apiIds) {
|
||||||
{"발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"},
|
if (apiIds == null || apiIds.isEmpty()) {
|
||||||
{"영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"},
|
return new ApiSplit(Collections.emptyList(), 0);
|
||||||
{"조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"},
|
}
|
||||||
{"예상 복구 시간", "예) 2026-08-03 14:30 (복구 완료 시 실제 시각으로 갱신)"}
|
Set<String> publishedIds;
|
||||||
};
|
try {
|
||||||
|
publishedIds = new HashSet<>(apiStatusRepository.findPublishedApiIds(apiIds));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 실패 시 노출하는 쪽으로 기울면 내부 인터페이스가 고객 화면에 새어 나간다
|
||||||
|
log.warn("게시 API 판정 실패 - 전부 GW 인터페이스로 묶어 표기", e);
|
||||||
|
publishedIds = Collections.emptySet();
|
||||||
|
}
|
||||||
|
List<String> published = new ArrayList<>();
|
||||||
|
for (String apiId : apiIds) {
|
||||||
|
if (publishedIds.contains(apiId)) {
|
||||||
|
published.add(apiId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ApiSplit(published, apiIds.size() - published.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */
|
||||||
|
private String hiddenLabel(int hiddenCount) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영향 인터페이스 한 줄 요약. 게시 API 는 이름으로, 나머지는 건수로 묶는다.
|
||||||
|
* 예) {@code 계좌조회, 이체 · GW 인터페이스 2건}
|
||||||
|
*/
|
||||||
|
private String affectedSummary(List<String> apiIds, Map<String, String> apiNames) {
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (String apiId : split.published) {
|
||||||
|
parts.add(apiLabel(apiId, apiNames));
|
||||||
|
}
|
||||||
|
String names = String.join(", ", parts);
|
||||||
|
if (split.hiddenCount == 0) {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
return names.isEmpty() ? hiddenLabel(split.hiddenCount) : names + " · " + hiddenLabel(split.hiddenCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공지 제목. 게시 API 가 하나도 없으면 인터페이스 명을 쓰지 않고 건수로만 적는다.
|
||||||
|
*/
|
||||||
|
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
if (split.published.isEmpty()) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_HIDDEN_ONLY,
|
||||||
|
"gw", hiddenLabel(apiIds.size()), "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
String first = apiLabel(split.published.get(0), apiNames);
|
||||||
|
if (apiIds.size() > 1) {
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MULTI,
|
||||||
|
"first", first, "rest", String.valueOf(apiIds.size() - 1), "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
return draftTemplate.text(ApiStatusDraftTemplate.TITLE_SINGLE, "first", first, "keyword", titleKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 탐지 장애 공지 생성.
|
* 자동 탐지 장애 공지 생성.
|
||||||
@@ -528,21 +607,32 @@ public class ApiStatusDetectionService {
|
|||||||
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
|
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
|
||||||
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
|
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
|
||||||
StringBuilder detail = new StringBuilder();
|
StringBuilder detail = new StringBuilder();
|
||||||
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다.");
|
detail.append(draftTemplate.text(publish
|
||||||
detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다.");
|
? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH
|
||||||
detail.append("</p>");
|
: ApiStatusDraftTemplate.BODY_LEAD_DRAFT,
|
||||||
detail.append("<p><b>영향 API</b></p><ul>");
|
"summary", summary));
|
||||||
for (String apiId : apiIds) {
|
|
||||||
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
|
// 게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다.
|
||||||
|
// 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다.
|
||||||
|
ApiSplit split = splitPublished(apiIds);
|
||||||
|
detail.append("<p><b>")
|
||||||
|
.append(draftTemplate.text(ApiStatusDraftTemplate.BODY_AFFECTED_HEADING))
|
||||||
|
.append("</b></p><ul>");
|
||||||
|
for (String apiId : split.published) {
|
||||||
|
detail.append("<li>").append(apiLabel(apiId, apiNames)).append("</li>");
|
||||||
|
}
|
||||||
|
if (split.hiddenCount > 0) {
|
||||||
|
detail.append("<li>").append(hiddenLabel(split.hiddenCount)).append("</li>");
|
||||||
}
|
}
|
||||||
detail.append("</ul>");
|
detail.append("</ul>");
|
||||||
|
|
||||||
for (String[] section : NOTICE_FILL_IN_SECTIONS) {
|
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
|
||||||
detail.append("<p><b>").append(section[0]).append("</b></p>");
|
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
|
||||||
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||||
detail.append(publish
|
detail.append("<p>").append(publish
|
||||||
? "<p>확인 중입니다.</p>"
|
? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)
|
||||||
: "<p>[작성 필요] " + section[1] + "</p>");
|
: draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()))
|
||||||
|
.append("</p>");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalNotice notice = new PortalNotice();
|
PortalNotice notice = new PortalNotice();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.eactive.eai.rms.ext.djb.apistatus;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.yaml.snakeyaml.LoaderOptions;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Status 자동 탐지 초안 양식({@code classpath:apistatus-draft.yml}).
|
||||||
|
*
|
||||||
|
* <p>자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다.
|
||||||
|
* admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다
|
||||||
|
* ({@code <context:property-placeholder>} 는 properties 전용이고, 검수 섹션이 리스트라
|
||||||
|
* properties 로는 인덱스 키로 흩어진다).</p>
|
||||||
|
*
|
||||||
|
* <p>파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로
|
||||||
|
* 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ApiStatusDraftTemplate {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiStatusDraftTemplate.class);
|
||||||
|
|
||||||
|
private static final String RESOURCE = "apistatus-draft.yml";
|
||||||
|
|
||||||
|
// ---- 키 ----
|
||||||
|
public static final String GW_LABEL = "gw-label";
|
||||||
|
|
||||||
|
public static final String TITLE_SINGLE = "title.single";
|
||||||
|
public static final String TITLE_MULTI = "title.multi";
|
||||||
|
public static final String TITLE_HIDDEN_ONLY = "title.hidden-only";
|
||||||
|
|
||||||
|
public static final String BODY_LEAD_PUBLISH = "body.lead-publish";
|
||||||
|
public static final String BODY_LEAD_DRAFT = "body.lead-draft";
|
||||||
|
public static final String BODY_AFFECTED_HEADING = "body.affected-heading";
|
||||||
|
public static final String BODY_FILLED_TEXT = "body.filled-text";
|
||||||
|
public static final String BODY_DRAFT_TEXT = "body.draft-text";
|
||||||
|
|
||||||
|
public static final String TL_DETECTED = "timeline.detected";
|
||||||
|
public static final String TL_API_ADDED = "timeline.api-added";
|
||||||
|
public static final String TL_REDETECTED = "timeline.redetected";
|
||||||
|
public static final String TL_ESCALATED = "timeline.escalated";
|
||||||
|
public static final String TL_RECOVERED_ALL = "timeline.recovered-all";
|
||||||
|
public static final String TL_RECOVERED_PART = "timeline.recovered-part";
|
||||||
|
|
||||||
|
/** yml 을 못 읽었을 때 쓰는 기본 문구 (기존 하드코딩과 동일) */
|
||||||
|
private static final Map<String, String> DEFAULTS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, String> defaults = new LinkedHashMap<>();
|
||||||
|
defaults.put(GW_LABEL, "GW 인터페이스 {count}건");
|
||||||
|
defaults.put(TITLE_SINGLE, "[자동감지] {first} API {keyword}");
|
||||||
|
defaults.put(TITLE_MULTI, "[자동감지] {first} 외 {rest}종 API {keyword}");
|
||||||
|
defaults.put(TITLE_HIDDEN_ONLY, "[자동감지] {gw} {keyword}");
|
||||||
|
defaults.put(BODY_LEAD_PUBLISH, "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>");
|
||||||
|
defaults.put(BODY_LEAD_DRAFT, "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
|
||||||
|
defaults.put(BODY_AFFECTED_HEADING, "영향 API");
|
||||||
|
defaults.put(BODY_FILLED_TEXT, "확인 중입니다.");
|
||||||
|
defaults.put(BODY_DRAFT_TEXT, "[작성 필요] {hint}");
|
||||||
|
defaults.put(TL_DETECTED, "{summary} 자동 감지 ({event})\n영향 API: {affected}");
|
||||||
|
defaults.put(TL_API_ADDED, "영향 API 추가 감지 ({event})\n{affected}");
|
||||||
|
defaults.put(TL_REDETECTED, "재탐지 신호 수신 ({event})");
|
||||||
|
defaults.put(TL_ESCALATED, "지연에서 장애로 격상 ({event})");
|
||||||
|
defaults.put(TL_RECOVERED_ALL, "전체 복구 확인 ({event})\n{affected}");
|
||||||
|
defaults.put(TL_RECOVERED_PART, "일부 복구 확인 ({event})\n{affected}");
|
||||||
|
DEFAULTS = Collections.unmodifiableMap(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자가 검수 시 채워 넣을 항목 */
|
||||||
|
public static final class Section {
|
||||||
|
private final String title;
|
||||||
|
private final String hint;
|
||||||
|
|
||||||
|
public Section(String title, String hint) {
|
||||||
|
this.title = title;
|
||||||
|
this.hint = hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHint() {
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Section> DEFAULT_SECTIONS = Collections.unmodifiableList(new ArrayList<Section>() {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
{
|
||||||
|
add(new Section("발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"));
|
||||||
|
add(new Section("영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"));
|
||||||
|
add(new Section("조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"));
|
||||||
|
add(new Section("예상 복구 시간", "예) 복구 완료 시 실제 시각으로 갱신"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 점(.) 으로 평탄화한 문구 맵 */
|
||||||
|
private Map<String, String> texts = DEFAULTS;
|
||||||
|
private List<Section> sections = DEFAULT_SECTIONS;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void load() {
|
||||||
|
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||||
|
if (in == null) {
|
||||||
|
log.info("{} 없음 - 기본 초안 양식 사용", RESOURCE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SafeConstructor - 임의 클래스 생성(CVE-2022-1471 계열)을 막고 스칼라/맵/리스트만 읽는다
|
||||||
|
Object root = new Yaml(new SafeConstructor(new LoaderOptions())).load(in);
|
||||||
|
Map<String, Object> draft = asMap(asMap(root).get("draft"));
|
||||||
|
if (draft.isEmpty()) {
|
||||||
|
log.warn("{} 에 draft 섹션이 없음 - 기본 초안 양식 사용", RESOURCE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> loaded = new LinkedHashMap<>(DEFAULTS);
|
||||||
|
flatten("", draft, loaded);
|
||||||
|
this.texts = loaded;
|
||||||
|
this.sections = readSections(draft);
|
||||||
|
log.info("초안 양식 로딩 완료: {} (섹션 {}개)", RESOURCE, sections.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 문구 설정 문제로 장애 탐지가 멈추면 안 된다
|
||||||
|
log.warn("{} 로딩 실패 - 기본 초안 양식 사용", RESOURCE, e);
|
||||||
|
this.texts = DEFAULTS;
|
||||||
|
this.sections = DEFAULT_SECTIONS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문구 조회 + 치환. {@code args} 는 {키, 값, 키, 값...} 순으로 넘긴다.
|
||||||
|
* 정의되지 않은 치환자는 그대로 남겨 어떤 키가 빠졌는지 화면에서 드러나게 한다.
|
||||||
|
*/
|
||||||
|
public String text(String key, String... args) {
|
||||||
|
String template = texts.get(key);
|
||||||
|
if (template == null) {
|
||||||
|
template = DEFAULTS.get(key);
|
||||||
|
}
|
||||||
|
if (template == null) {
|
||||||
|
log.warn("정의되지 않은 초안 문구 키: {}", key);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String result = template;
|
||||||
|
for (int i = 0; i + 1 < args.length; i += 2) {
|
||||||
|
result = result.replace("{" + args[i] + "}", args[i + 1] == null ? "" : args[i + 1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Section> getSections() {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── 내부 헬퍼 ──────────────
|
||||||
|
|
||||||
|
/** 중첩 맵을 {@code body.lead-draft} 형태의 평탄 키로 편다. 리스트(sections)는 별도로 읽는다. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void flatten(String prefix, Map<String, Object> source, Map<String, String> target) {
|
||||||
|
for (Map.Entry<String, Object> entry : source.entrySet()) {
|
||||||
|
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
|
||||||
|
Object value = entry.getValue();
|
||||||
|
if (value instanceof Map) {
|
||||||
|
flatten(key, (Map<String, Object>) value, target);
|
||||||
|
} else if (value != null && !(value instanceof List)) {
|
||||||
|
target.put(key, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Section> readSections(Map<String, Object> draft) {
|
||||||
|
Object body = asMap(draft.get("body")).get("sections");
|
||||||
|
if (!(body instanceof List)) {
|
||||||
|
return DEFAULT_SECTIONS;
|
||||||
|
}
|
||||||
|
List<Section> result = new ArrayList<>();
|
||||||
|
for (Object item : (List<?>) body) {
|
||||||
|
Map<String, Object> section = asMap(item);
|
||||||
|
Object title = section.get("title");
|
||||||
|
if (title == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object hint = section.get("hint");
|
||||||
|
result.add(new Section(String.valueOf(title), hint == null ? "" : String.valueOf(hint)));
|
||||||
|
}
|
||||||
|
return result.isEmpty() ? DEFAULT_SECTIONS : Collections.unmodifiableList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> asMap(Object value) {
|
||||||
|
return value instanceof Map ? (Map<String, Object>) value : Collections.<String, Object>emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,4 +145,24 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
|||||||
@Query(nativeQuery = true, value =
|
@Query(nativeQuery = true, value =
|
||||||
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
|
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
|
||||||
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
|
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발자포탈에 게시되는 인터페이스(= API) 만 추린다.
|
||||||
|
*
|
||||||
|
* <p>GW 인터페이스는 전부 탐지 대상이지만 개발자포탈에 노출되는 것은 그 일부다.
|
||||||
|
* 자동 초안(제목·공지 본문·타임라인)에 게시되지 않은 인터페이스 ID 가 그대로 박히면
|
||||||
|
* 고객 화면에 내부 인터페이스가 드러나므로, 여기서 걸러 나머지는 건수로만 표기한다.</p>
|
||||||
|
*
|
||||||
|
* <p>판정 기준은 포털 "오픈 API" 목록과 같은 두 조건이다 - 노출 중인 API 그룹에 편성돼 있고
|
||||||
|
* PTL_API_SPEC_INFO 에 스펙이 있을 것. 역할·소속에 따른 사용자별 공개 범위는 admin 에
|
||||||
|
* 사용자 컨텍스트가 없어 적용하지 않는다(= 포털 화면이 한 번 더 좁힌다).</p>
|
||||||
|
*/
|
||||||
|
@Query(nativeQuery = true, value =
|
||||||
|
" SELECT DISTINCT GA.API_ID"
|
||||||
|
+ " FROM API_GROUP_API GA"
|
||||||
|
+ " JOIN API_GROUP G ON G.ID = GA.API_GROUP_ID"
|
||||||
|
+ " WHERE GA.API_ID IN (:apiIds)"
|
||||||
|
+ " AND G.DISPLAY_YN = '1'"
|
||||||
|
+ " AND EXISTS (SELECT 1 FROM EMSAPP.PTL_API_SPEC_INFO S WHERE S.API_ID = GA.API_ID)")
|
||||||
|
List<String> findPublishedApiIds(@Param("apiIds") Collection<String> apiIds);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ============================================================
|
||||||
|
# API Status 자동 탐지 초안 양식
|
||||||
|
#
|
||||||
|
# ApiStatusDetectionService 가 자동 생성하는 공지 제목/본문/타임라인 문구를 여기서 관리한다.
|
||||||
|
# 환경별로 갈리는 값이 아니므로 (/WEB-INF/properties 와 달리) 단일 파일이다.
|
||||||
|
#
|
||||||
|
# - 치환자는 {name} 형식. 정의되지 않은 키는 치환되지 않고 그대로 남는다.
|
||||||
|
# - 이 파일이 없거나 파싱에 실패하면 코드에 박힌 기본 문구로 동작한다 (초안 생성은 멈추지 않는다).
|
||||||
|
# - 게시되지 않은 GW 인터페이스는 이름·ID 를 노출하지 않고 gw-label 로 묶어 표기한다.
|
||||||
|
# ============================================================
|
||||||
|
draft:
|
||||||
|
|
||||||
|
# 개발자포탈에 게시되지 않은 GW 인터페이스 묶음 라벨 {count}=건수
|
||||||
|
gw-label: "GW 인터페이스 {count}건"
|
||||||
|
|
||||||
|
# ---- 공지 제목 ----
|
||||||
|
# {first}=첫 게시 API 명, {rest}=나머지 건수, {keyword}=장애|응답지연, {gw}=gw-label 적용 결과
|
||||||
|
title:
|
||||||
|
single: "[자동감지] {first} API {keyword}"
|
||||||
|
multi: "[자동감지] {first} 외 {rest}종 API {keyword}"
|
||||||
|
# 게시된 API 가 하나도 없을 때 (인터페이스 명을 쓰지 않는다)
|
||||||
|
hidden-only: "[자동감지] {gw} {keyword}"
|
||||||
|
|
||||||
|
# ---- 공지 본문 (HTML) ----
|
||||||
|
body:
|
||||||
|
# {summary}=탐지 사유(에러율 임계 초과 등)
|
||||||
|
lead-publish: "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>"
|
||||||
|
lead-draft: "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>"
|
||||||
|
affected-heading: "영향 API"
|
||||||
|
# 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||||
|
filled-text: "확인 중입니다."
|
||||||
|
draft-text: "[작성 필요] {hint}"
|
||||||
|
# 관리자가 검수하며 채울 항목
|
||||||
|
sections:
|
||||||
|
- title: "발생 원인"
|
||||||
|
hint: "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"
|
||||||
|
- title: "영향 범위"
|
||||||
|
hint: "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"
|
||||||
|
- title: "조치 내용"
|
||||||
|
hint: "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"
|
||||||
|
- title: "예상 복구 시간"
|
||||||
|
hint: "예) 복구 완료 시 실제 시각으로 갱신"
|
||||||
|
|
||||||
|
# ---- 타임라인 ----
|
||||||
|
# {event}=ERROR_START 등, {summary}=탐지 사유
|
||||||
|
# {affected}=영향 API 요약 ("계좌조회, 이체 · GW 인터페이스 2건")
|
||||||
|
timeline:
|
||||||
|
detected: "{summary} 자동 감지 ({event})\n영향 API: {affected}"
|
||||||
|
api-added: "영향 API 추가 감지 ({event})\n{affected}"
|
||||||
|
redetected: "재탐지 신호 수신 ({event})"
|
||||||
|
escalated: "지연에서 장애로 격상 ({event})"
|
||||||
|
recovered-all: "전체 복구 확인 ({event})\n{affected}"
|
||||||
|
recovered-part: "일부 복구 확인 ({event})\n{affected}"
|
||||||
Reference in New Issue
Block a user