자동 장애 탐지 초안 양식 추가
- apistatus-draft.yml 파일 및 관련 로직 추가 - SnakeYAML 의존성 추가 및 안전한 yml 파싱 구현 - 자동 탐지 공지 제목·본문·타임라인 문구 분리 및 설정 관리
This commit is contained in:
+126
-36
@@ -113,6 +113,10 @@ public class ApiStatusDetectionService {
|
||||
@Autowired
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */
|
||||
@Autowired
|
||||
private ApiStatusDraftTemplate draftTemplate;
|
||||
|
||||
/**
|
||||
* 이벤트 감지
|
||||
* @param event "CONTROL_START" : 점검시작
|
||||
@@ -206,7 +210,7 @@ public class ApiStatusDetectionService {
|
||||
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
|
||||
if (duplicated.isPresent()) {
|
||||
appendTimeline(duplicated.get().getIncidentId(), null,
|
||||
"재탐지 신호 수신 (" + event + ")", now);
|
||||
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,13 +256,15 @@ public class ApiStatusDetectionService {
|
||||
}
|
||||
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
|
||||
appendTimeline(existing.getIncidentId(), null,
|
||||
"재탐지 신호 수신 (" + event + ")", now));
|
||||
draftTemplate.text(ApiStatusDraftTemplate.TL_REDETECTED, "event", event), now));
|
||||
return;
|
||||
}
|
||||
|
||||
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
|
||||
// 타임라인도 고객 화면에 그대로 나가므로 인터페이스 ID 대신 게시 API 명 + 건수로 적는다
|
||||
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={}",
|
||||
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
|
||||
@@ -308,7 +314,7 @@ public class ApiStatusDetectionService {
|
||||
Long incidentId = incident.getIncidentId();
|
||||
|
||||
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
|
||||
escalateIfNeeded(incident, event, kind, now, autoPublish);
|
||||
boolean escalated = escalateIfNeeded(incident, event, kind, now, autoPublish);
|
||||
|
||||
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
|
||||
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
|
||||
@@ -325,14 +331,22 @@ public class ApiStatusDetectionService {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
|
||||
Map<String, String> addedNames = resolveApiNames(added);
|
||||
saveIncidentApis(incidentId, added, addedNames, now);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -341,11 +355,13 @@ public class ApiStatusDetectionService {
|
||||
*
|
||||
* <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) {
|
||||
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||
@@ -370,9 +386,10 @@ public class ApiStatusDetectionService {
|
||||
incidentRepository.save(incident);
|
||||
|
||||
appendTimeline(incident.getIncidentId(), null,
|
||||
"지연에서 장애로 격상 (" + event + ")", now);
|
||||
draftTemplate.text(ApiStatusDraftTemplate.TL_ESCALATED, "event", event), now);
|
||||
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
|
||||
incident.getIncidentId(), incident.getNoticeId());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,8 +476,11 @@ public class ApiStatusDetectionService {
|
||||
incident.setLastModifiedDate(now);
|
||||
incidentRepository.save(incident);
|
||||
|
||||
String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n"
|
||||
+ String.join(", ", recoveredApis);
|
||||
String body = draftTemplate.text(
|
||||
allRecovered ? ApiStatusDraftTemplate.TL_RECOVERED_ALL
|
||||
: ApiStatusDraftTemplate.TL_RECOVERED_PART,
|
||||
"event", event,
|
||||
"affected", affectedSummary(recoveredApis, resolveApiNames(recoveredApis)));
|
||||
appendTimeline(incidentId, nextState, body, now);
|
||||
|
||||
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
|
||||
@@ -500,21 +520,80 @@ public class ApiStatusDetectionService {
|
||||
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) {
|
||||
return "[자동감지] " + first + " 외 " + (apiIds.size() - 1) + "종 API " + titleKeyword;
|
||||
/**
|
||||
* 영향 인터페이스를 개발자포탈 게시 여부로 가른 결과.
|
||||
* 게시된 것(= API)만 이름을 드러내고 나머지는 건수로만 표기한다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** 관리자가 검수 시 채워 넣을 항목 - {제목, 작성 힌트} */
|
||||
private static final String[][] NOTICE_FILL_IN_SECTIONS = {
|
||||
{"발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"},
|
||||
{"영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"},
|
||||
{"조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"},
|
||||
{"예상 복구 시간", "예) 2026-08-03 14:30 (복구 완료 시 실제 시각으로 갱신)"}
|
||||
};
|
||||
/** 게시 인터페이스 판정. 조회에 실패하면 전부 비게시로 보아 ID·명칭 노출을 막는다. */
|
||||
private ApiSplit splitPublished(List<String> apiIds) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return new ApiSplit(Collections.emptyList(), 0);
|
||||
}
|
||||
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,
|
||||
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
|
||||
StringBuilder detail = new StringBuilder();
|
||||
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다.");
|
||||
detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다.");
|
||||
detail.append("</p>");
|
||||
detail.append("<p><b>영향 API</b></p><ul>");
|
||||
for (String apiId : apiIds) {
|
||||
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
|
||||
detail.append(draftTemplate.text(publish
|
||||
? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH
|
||||
: ApiStatusDraftTemplate.BODY_LEAD_DRAFT,
|
||||
"summary", summary));
|
||||
|
||||
// 게시된 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>");
|
||||
|
||||
for (String[] section : NOTICE_FILL_IN_SECTIONS) {
|
||||
detail.append("<p><b>").append(section[0]).append("</b></p>");
|
||||
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
|
||||
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
|
||||
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||
detail.append(publish
|
||||
? "<p>확인 중입니다.</p>"
|
||||
: "<p>[작성 필요] " + section[1] + "</p>");
|
||||
detail.append("<p>").append(publish
|
||||
? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)
|
||||
: draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()))
|
||||
.append("</p>");
|
||||
}
|
||||
|
||||
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 =
|
||||
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (: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