From 27fa79ddd9e5dd0580b7acd1a2dfb49e2777a2a6 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Wed, 5 Aug 2026 10:44:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=9E=A5=EC=95=A0=20?= =?UTF-8?q?=ED=83=90=EC=A7=80=20=EC=B4=88=EC=95=88=20=EC=96=91=EC=8B=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20apistatus-draft.yml=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20-=20SnakeYAML=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=ED=95=9C=20yml=20=ED=8C=8C=EC=8B=B1=20=EA=B5=AC=ED=98=84=20-?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=ED=83=90=EC=A7=80=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=20=EC=A0=9C=EB=AA=A9=C2=B7=EB=B3=B8=EB=AC=B8=C2=B7=ED=83=80?= =?UTF-8?q?=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EB=AC=B8=EA=B5=AC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 + .../apistatus/ApiStatusDetectionService.java | 162 ++++++++++---- .../djb/apistatus/ApiStatusDraftTemplate.java | 203 ++++++++++++++++++ .../djb/apistatus/ApiStatusRepository.java | 20 ++ src/main/resources/apistatus-draft.yml | 53 +++++ 5 files changed, 408 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDraftTemplate.java create mode 100644 src/main/resources/apistatus-draft.yml diff --git a/build.gradle b/build.gradle index d8ac632..8046b7e 100644 --- a/build.gradle +++ b/build.gradle @@ -118,6 +118,12 @@ dependencies { //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' + // 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 "org.snmp4j:snmp4j:1.10.1" implementation "com.googlecode.json-simple:json-simple:1.1.1" diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java index ad25d18..396529f 100644 --- a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDetectionService.java @@ -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 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 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 { * *

지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은 * 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.

+ * + * @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 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 apiIds, Map 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 published; + private final int hiddenCount; + + private ApiSplit(List 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 apiIds) { + if (apiIds == null || apiIds.isEmpty()) { + return new ApiSplit(Collections.emptyList(), 0); + } + Set publishedIds; + try { + publishedIds = new HashSet<>(apiStatusRepository.findPublishedApiIds(apiIds)); + } catch (Exception e) { + // 실패 시 노출하는 쪽으로 기울면 내부 인터페이스가 고객 화면에 새어 나간다 + log.warn("게시 API 판정 실패 - 전부 GW 인터페이스로 묶어 표기", e); + publishedIds = Collections.emptySet(); + } + List 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 apiIds, Map apiNames) { + ApiSplit split = splitPublished(apiIds); + List 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 apiIds, Map 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 apiIds, Map apiNames, LocalDateTime now, boolean publish) { StringBuilder detail = new StringBuilder(); - detail.append("

").append(summary).append(" 로 자동 감지된 장애입니다."); - detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다."); - detail.append("

"); - detail.append("

영향 API

    "); - for (String apiId : apiIds) { - detail.append("
  • ").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")
  • "); + detail.append(draftTemplate.text(publish + ? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH + : ApiStatusDraftTemplate.BODY_LEAD_DRAFT, + "summary", summary)); + + // 게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다. + // 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다. + ApiSplit split = splitPublished(apiIds); + detail.append("

    ") + .append(draftTemplate.text(ApiStatusDraftTemplate.BODY_AFFECTED_HEADING)) + .append("

      "); + for (String apiId : split.published) { + detail.append("
    • ").append(apiLabel(apiId, apiNames)).append("
    • "); + } + if (split.hiddenCount > 0) { + detail.append("
    • ").append(hiddenLabel(split.hiddenCount)).append("
    • "); } detail.append("
    "); - for (String[] section : NOTICE_FILL_IN_SECTIONS) { - detail.append("

    ").append(section[0]).append("

    "); + for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) { + detail.append("

    ").append(section.getTitle()).append("

    "); // 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다 - detail.append(publish - ? "

    확인 중입니다.

    " - : "

    [작성 필요] " + section[1] + "

    "); + detail.append("

    ").append(publish + ? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT) + : draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint())) + .append("

    "); } PortalNotice notice = new PortalNotice(); diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDraftTemplate.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDraftTemplate.java new file mode 100644 index 0000000..15321ac --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusDraftTemplate.java @@ -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}). + * + *

    자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다. + * admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다 + * ({@code } 는 properties 전용이고, 검수 섹션이 리스트라 + * properties 로는 인덱스 키로 흩어진다).

    + * + *

    파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로 + * 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.

    + */ +@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 DEFAULTS; + + static { + Map 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, "

    {summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.

    "); + defaults.put(BODY_LEAD_DRAFT, "

    {summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.

    "); + 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
    DEFAULT_SECTIONS = Collections.unmodifiableList(new ArrayList
    () { + private static final long serialVersionUID = 1L; + { + add(new Section("발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생")); + add(new Section("영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상")); + add(new Section("조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료")); + add(new Section("예상 복구 시간", "예) 복구 완료 시 실제 시각으로 갱신")); + } + }); + + /** 점(.) 으로 평탄화한 문구 맵 */ + private Map texts = DEFAULTS; + private List
    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 draft = asMap(asMap(root).get("draft")); + if (draft.isEmpty()) { + log.warn("{} 에 draft 섹션이 없음 - 기본 초안 양식 사용", RESOURCE); + return; + } + + Map 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
    getSections() { + return sections; + } + + // ────────────── 내부 헬퍼 ────────────── + + /** 중첩 맵을 {@code body.lead-draft} 형태의 평탄 키로 편다. 리스트(sections)는 별도로 읽는다. */ + @SuppressWarnings("unchecked") + private void flatten(String prefix, Map source, Map target) { + for (Map.Entry entry : source.entrySet()) { + String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); + Object value = entry.getValue(); + if (value instanceof Map) { + flatten(key, (Map) value, target); + } else if (value != null && !(value instanceof List)) { + target.put(key, String.valueOf(value)); + } + } + } + + private List
    readSections(Map draft) { + Object body = asMap(draft.get("body")).get("sections"); + if (!(body instanceof List)) { + return DEFAULT_SECTIONS; + } + List
    result = new ArrayList<>(); + for (Object item : (List) body) { + Map 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 asMap(Object value) { + return value instanceof Map ? (Map) value : Collections.emptyMap(); + } +} diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java index 99aa979..abeed64 100644 --- a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java +++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusRepository.java @@ -145,4 +145,24 @@ public interface ApiStatusRepository extends BaseRepository { @Query(nativeQuery = true, value = " SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)") List findApiNames(@Param("apiIds") Collection apiIds); + + /** + * 개발자포탈에 게시되는 인터페이스(= API) 만 추린다. + * + *

    GW 인터페이스는 전부 탐지 대상이지만 개발자포탈에 노출되는 것은 그 일부다. + * 자동 초안(제목·공지 본문·타임라인)에 게시되지 않은 인터페이스 ID 가 그대로 박히면 + * 고객 화면에 내부 인터페이스가 드러나므로, 여기서 걸러 나머지는 건수로만 표기한다.

    + * + *

    판정 기준은 포털 "오픈 API" 목록과 같은 두 조건이다 - 노출 중인 API 그룹에 편성돼 있고 + * PTL_API_SPEC_INFO 에 스펙이 있을 것. 역할·소속에 따른 사용자별 공개 범위는 admin 에 + * 사용자 컨텍스트가 없어 적용하지 않는다(= 포털 화면이 한 번 더 좁힌다).

    + */ + @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 findPublishedApiIds(@Param("apiIds") Collection apiIds); } \ No newline at end of file diff --git a/src/main/resources/apistatus-draft.yml b/src/main/resources/apistatus-draft.yml new file mode 100644 index 0000000..5e0d998 --- /dev/null +++ b/src/main/resources/apistatus-draft.yml @@ -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: "

    {summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.

    " + lead-draft: "

    {summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.

    " + 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}"