");
- }
+ String detail = noticeComposer.buildBody(
+ lead, labelsOf(split.published, apiNames), split.hiddenCount, !publish);
PortalNotice notice = new PortalNotice();
notice.setId(null);
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
- notice.setNoticeDetail(detail.toString());
+ notice.setNoticeDetail(detail);
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
notice.setUseYn(publish ? "Y" : "N");
notice.setFixYn("N");
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
index 15321ac..8cf0aee 100644
--- 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
@@ -11,21 +11,32 @@ import javax.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
+import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
+
/**
- * API Status 자동 탐지 초안 양식({@code classpath:apistatus-draft.yml}).
+ * API Status 공지 양식. 자동 탐지 초안과 관리자 수동 등록이 같은 문구를 쓴다.
*
- *
자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다.
- * admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다
- * ({@code } 는 properties 전용이고, 검수 섹션이 리스트라
- * properties 로는 인덱스 키로 흩어진다).
+ *
문구 출처는 우선순위 3단이다.
+ *
+ *
{@code PTL_PROPERTY} (그룹 {@link #PROPERTY_GROUP}, 키 {@link #PROPERTY_PREFIX} + 문구키)
+ * - 운영 중 포탈 프로퍼티 관리 화면에서 재배포 없이 고칠 수 있다.
파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로
- * 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.
+ *
yml 을 SnakeYAML 로 직접 읽는 이유: admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없고
+ * ({@code } 는 properties 전용), 검수 섹션이 리스트라 properties 로는
+ * 인덱스 키로 흩어진다.
+ *
+ *
DB 조회는 60초 스냅샷으로 캐시한다 - 공지 한 건을 만드는 데 {@link #text} 가 수십 번 불리므로
+ * 매번 조회하면 안 된다. 조회에 실패하거나 개별 키가 비어 있으면 그 아래 단계 문구로 떨어진다.
+ * 문구 설정 문제로 장애 탐지 자체가 멈추면 안 되기 때문이다.
*/
@Component
public class ApiStatusDraftTemplate {
@@ -34,6 +45,15 @@ public class ApiStatusDraftTemplate {
private static final String RESOURCE = "apistatus-draft.yml";
+ /** 문구 프로퍼티 그룹 (개발자포탈과 동일 그룹을 쓴다) */
+ public static final String PROPERTY_GROUP = "Portal";
+
+ /** 문구 프로퍼티 키 접두어. 접두어 뒤는 아래 문구 키와 같다 */
+ public static final String PROPERTY_PREFIX = "djb.apistatus.draft.";
+
+ /** DB 스냅샷 유효 시간 (ms) */
+ private static final long REFRESH_INTERVAL_MS = 60_000L;
+
// ---- 키 ----
public static final String GW_LABEL = "gw-label";
@@ -41,12 +61,21 @@ public class ApiStatusDraftTemplate {
public static final String TITLE_MULTI = "title.multi";
public static final String TITLE_HIDDEN_ONLY = "title.hidden-only";
+ /** 관리자 수동 등록 제목 - 자동 탐지 표기([자동감지]·GW 인터페이스)를 쓰지 않는다 */
+ public static final String TITLE_MANUAL_SINGLE = "title.manual-single";
+ public static final String TITLE_MANUAL_MULTI = "title.manual-multi";
+ public static final String TITLE_MANUAL_EMPTY = "title.manual-empty";
+
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_LEAD_MANUAL = "body.lead-manual";
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";
+ /** 검수 항목. 프로퍼티에서는 {@code 제목|힌트} 를 {@code ;;} 로 이어 붙인 문자열 한 건이다 */
+ public static final String BODY_SECTIONS = "body.sections";
+
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";
@@ -54,17 +83,30 @@ public class ApiStatusDraftTemplate {
public static final String TL_RECOVERED_ALL = "timeline.recovered-all";
public static final String TL_RECOVERED_PART = "timeline.recovered-part";
+ /** 검수 항목 프로퍼티 구분자 - 항목 사이 */
+ private static final String SECTION_DELIMITER = ";;";
+
+ /** 검수 항목 프로퍼티 구분자 - 제목과 힌트 사이 */
+ private static final String SECTION_FIELD_DELIMITER = "|";
+
/** yml 을 못 읽었을 때 쓰는 기본 문구 (기존 하드코딩과 동일) */
private static final Map DEFAULTS;
+ /** 프로퍼티 최초 등록 시 함께 적는 설명 (관리 화면에서 치환자 의미를 보여준다) */
+ private static final Map DESCRIPTIONS;
+
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(TITLE_MANUAL_SINGLE, "{first} API {keyword}");
+ defaults.put(TITLE_MANUAL_MULTI, "{first} 외 {rest}종 API {keyword}");
+ defaults.put(TITLE_MANUAL_EMPTY, "API {keyword}");
defaults.put(BODY_LEAD_PUBLISH, "
{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.
");
defaults.put(BODY_LEAD_DRAFT, "
{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.
");
+ defaults.put(BODY_LEAD_MANUAL, "
[작성 필요] 장애 개요를 입력해 주십시오.
");
defaults.put(BODY_AFFECTED_HEADING, "영향 API");
defaults.put(BODY_FILLED_TEXT, "확인 중입니다.");
defaults.put(BODY_DRAFT_TEXT, "[작성 필요] {hint}");
@@ -75,6 +117,29 @@ public class ApiStatusDraftTemplate {
defaults.put(TL_RECOVERED_ALL, "전체 복구 확인 ({event})\n{affected}");
defaults.put(TL_RECOVERED_PART, "일부 복구 확인 ({event})\n{affected}");
DEFAULTS = Collections.unmodifiableMap(defaults);
+
+ Map descriptions = new LinkedHashMap<>();
+ descriptions.put(GW_LABEL, "개발자포탈 미게시 인터페이스 묶음 라벨. {count}=건수");
+ descriptions.put(TITLE_SINGLE, "공지 제목(영향 API 1건). {first}=API명, {keyword}=장애|응답지연");
+ descriptions.put(TITLE_MULTI, "공지 제목(영향 API 2건 이상). {first}=첫 API명, {rest}=나머지 건수, {keyword}=장애|응답지연");
+ descriptions.put(TITLE_HIDDEN_ONLY, "게시 API 가 하나도 없을 때 공지 제목. {gw}=gw-label 적용 결과, {keyword}=장애|응답지연");
+ descriptions.put(TITLE_MANUAL_SINGLE, "수동 등록 공지 제목(영향 API 1건). {first}=API명, {keyword}=장애");
+ descriptions.put(TITLE_MANUAL_MULTI, "수동 등록 공지 제목(영향 API 2건 이상). {first}=첫 API명, {rest}=나머지 건수, {keyword}=장애");
+ descriptions.put(TITLE_MANUAL_EMPTY, "수동 등록 공지 제목(영향 API 미선택). {keyword}=장애");
+ descriptions.put(BODY_LEAD_PUBLISH, "본문 머리말(자동 탐지 즉시 게시). {summary}=탐지 사유");
+ descriptions.put(BODY_LEAD_DRAFT, "본문 머리말(자동 탐지 초안). {summary}=탐지 사유");
+ descriptions.put(BODY_LEAD_MANUAL, "본문 머리말(관리자 수동 등록). 치환 변수 없음");
+ descriptions.put(BODY_AFFECTED_HEADING, "영향 API 목록 소제목. 치환 변수 없음");
+ descriptions.put(BODY_FILLED_TEXT, "게시 상태로 나가는 검수 항목 기본 문구. 치환 변수 없음");
+ descriptions.put(BODY_DRAFT_TEXT, "초안 상태 검수 항목 문구. {hint}=body.sections 의 힌트");
+ descriptions.put(BODY_SECTIONS, "관리자가 채울 검수 항목. '제목|힌트' 를 ';;' 로 구분. 전체 1000바이트(한글 약 330자) 이내");
+ descriptions.put(TL_DETECTED, "최초 탐지 타임라인. {summary}=탐지 사유, {event}=ERROR_START 등, {affected}=영향 API 요약");
+ descriptions.put(TL_API_ADDED, "영향 API 추가 타임라인. {event}=이벤트, {affected}=영향 API 요약");
+ descriptions.put(TL_REDETECTED, "재탐지 타임라인. {event}=이벤트");
+ descriptions.put(TL_ESCALATED, "지연에서 장애로 격상 타임라인. {event}=이벤트");
+ descriptions.put(TL_RECOVERED_ALL, "전체 복구 타임라인. {event}=이벤트, {affected}=영향 API 요약");
+ descriptions.put(TL_RECOVERED_PART, "일부 복구 타임라인. {event}=이벤트, {affected}=영향 API 요약");
+ DESCRIPTIONS = Collections.unmodifiableMap(descriptions);
}
/** 관리자가 검수 시 채워 넣을 항목 */
@@ -106,10 +171,24 @@ public class ApiStatusDraftTemplate {
}
});
- /** 점(.) 으로 평탄화한 문구 맵 */
- private Map texts = DEFAULTS;
- private List sections = DEFAULT_SECTIONS;
+ @Autowired(required = false)
+ private PortalPropertyService portalPropertyService;
+ /** yml/코드 기본값 - DB 프로퍼티가 없을 때 쓰는 바탕 문구 */
+ private Map baselineTexts = DEFAULTS;
+ private List baselineSections = DEFAULT_SECTIONS;
+
+ /** 바탕 문구 위에 DB 프로퍼티를 덮은 결과 (실제 사용분) */
+ private volatile Map texts = DEFAULTS;
+ private volatile List sections = DEFAULT_SECTIONS;
+
+ /** 다음 DB 스냅샷 갱신 시각 (ms). 0 이면 아직 한 번도 안 읽음 */
+ private volatile long nextRefreshAt = 0L;
+
+ /**
+ * yml 바탕 문구 로딩. DB 는 여기서 읽지 않는다 - 기동 시점에는 데이터소스·테넌트가
+ * 준비되지 않았을 수 있고, 프로퍼티 조회 실패로 컨텍스트 기동이 막히면 안 된다.
+ */
@PostConstruct
public void load() {
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
@@ -127,14 +206,17 @@ public class ApiStatusDraftTemplate {
Map loaded = new LinkedHashMap<>(DEFAULTS);
flatten("", draft, loaded);
- this.texts = loaded;
- this.sections = readSections(draft);
- log.info("초안 양식 로딩 완료: {} (섹션 {}개)", RESOURCE, sections.size());
+ this.baselineTexts = loaded;
+ this.baselineSections = readSections(draft);
+ log.info("초안 양식 로딩 완료: {} (섹션 {}개)", RESOURCE, baselineSections.size());
} catch (Exception e) {
// 문구 설정 문제로 장애 탐지가 멈추면 안 된다
log.warn("{} 로딩 실패 - 기본 초안 양식 사용", RESOURCE, e);
- this.texts = DEFAULTS;
- this.sections = DEFAULT_SECTIONS;
+ this.baselineTexts = DEFAULTS;
+ this.baselineSections = DEFAULT_SECTIONS;
+ } finally {
+ this.texts = this.baselineTexts;
+ this.sections = this.baselineSections;
}
}
@@ -143,7 +225,11 @@ public class ApiStatusDraftTemplate {
* 정의되지 않은 치환자는 그대로 남겨 어떤 키가 빠졌는지 화면에서 드러나게 한다.
*/
public String text(String key, String... args) {
+ ensureFresh();
String template = texts.get(key);
+ if (template == null) {
+ template = baselineTexts.get(key);
+ }
if (template == null) {
template = DEFAULTS.get(key);
}
@@ -159,9 +245,124 @@ public class ApiStatusDraftTemplate {
}
public List getSections() {
+ ensureFresh();
return sections;
}
+ // ────────────── PTL_PROPERTY 스냅샷 ──────────────
+
+ /**
+ * DB 스냅샷이 만료됐으면 그룹 전체를 한 번에 다시 읽어 바탕 문구 위에 덮는다.
+ * 조회에 실패해도 다음 주기까지는 재시도하지 않는다 - DB 가 아플 때 공지 한 건마다 매달리면 안 된다.
+ */
+ private void ensureFresh() {
+ if (portalPropertyService == null) {
+ return;
+ }
+ if (System.currentTimeMillis() < nextRefreshAt) {
+ return;
+ }
+ synchronized (this) {
+ if (System.currentTimeMillis() < nextRefreshAt) {
+ return;
+ }
+ try {
+ refreshFromProperties();
+ } catch (Exception e) {
+ log.warn("공지 양식 프로퍼티 조회 실패 - 파일/기본 문구 사용", e);
+ } finally {
+ nextRefreshAt = System.currentTimeMillis() + REFRESH_INTERVAL_MS;
+ }
+ }
+ }
+
+ private void refreshFromProperties() {
+ Map group = portalPropertyService.getPortalPropertiesAsMap(PROPERTY_GROUP);
+
+ Map merged = new LinkedHashMap<>(baselineTexts);
+ String sectionsValue = null;
+ for (String key : DESCRIPTIONS.keySet()) {
+ String value = group.get(PROPERTY_PREFIX + key);
+ if (isBlank(value)) {
+ // 프로퍼티가 없으면 지금 값으로 만들어 둔다 (Oracle 은 빈 문자열도 NULL 이라 같은 취급).
+ // 관리자가 값을 지운 경우엔 이미 행이 있으므로 새로 만들지 않고 바탕 문구로 떨어진다.
+ value = seedProperty(key);
+ }
+ if (isBlank(value)) {
+ continue;
+ }
+ if (BODY_SECTIONS.equals(key)) {
+ sectionsValue = value;
+ continue;
+ }
+ merged.put(key, value);
+ }
+
+ List parsedSections = parseSections(sectionsValue);
+
+ this.texts = merged;
+ this.sections = parsedSections == null ? baselineSections : parsedSections;
+ }
+
+ /** 프로퍼티가 없을 때 현재 바탕 문구를 기본값으로 등록하고 그 값을 돌려준다 */
+ private String seedProperty(String key) {
+ String defaultValue = BODY_SECTIONS.equals(key)
+ ? formatSections(baselineSections)
+ : baselineTexts.get(key);
+ if (isBlank(defaultValue)) {
+ return null;
+ }
+ try {
+ return portalPropertyService.getOrCreateProperty(
+ PROPERTY_GROUP, PROPERTY_PREFIX + key, defaultValue, DESCRIPTIONS.get(key));
+ } catch (Exception e) {
+ log.warn("공지 양식 프로퍼티 등록 실패: {}{}", PROPERTY_PREFIX, key, e);
+ return null;
+ }
+ }
+
+ /** {@code 제목|힌트;;제목|힌트} 파싱. 쓸 수 있는 항목이 없으면 null (호출부가 바탕 섹션을 쓴다) */
+ private List parseSections(String value) {
+ if (isBlank(value)) {
+ return null;
+ }
+ List result = new ArrayList<>();
+ for (String item : value.split(SECTION_DELIMITER)) {
+ String entry = item.trim();
+ if (entry.isEmpty()) {
+ continue;
+ }
+ int idx = entry.indexOf(SECTION_FIELD_DELIMITER);
+ String title = idx < 0 ? entry : entry.substring(0, idx).trim();
+ String hint = idx < 0 ? "" : entry.substring(idx + 1).trim();
+ if (title.isEmpty()) {
+ continue;
+ }
+ result.add(new Section(title, hint));
+ }
+ if (result.isEmpty()) {
+ log.warn("검수 항목 프로퍼티를 해석하지 못함 - 파일/기본 항목 사용: {}", value);
+ return null;
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ /** 검수 항목을 프로퍼티 저장 형식으로 되돌린다 (최초 등록용) */
+ private String formatSections(List source) {
+ StringBuilder sb = new StringBuilder();
+ for (Section section : source) {
+ if (sb.length() > 0) {
+ sb.append(SECTION_DELIMITER);
+ }
+ sb.append(section.getTitle()).append(SECTION_FIELD_DELIMITER).append(section.getHint());
+ }
+ return sb.toString();
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+
// ────────────── 내부 헬퍼 ──────────────
/** 중첩 맵을 {@code body.lead-draft} 형태의 평탄 키로 편다. 리스트(sections)는 별도로 읽는다. */
diff --git a/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusNoticeComposer.java b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusNoticeComposer.java
new file mode 100644
index 0000000..6b504fa
--- /dev/null
+++ b/src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusNoticeComposer.java
@@ -0,0 +1,107 @@
+package com.eactive.eai.rms.ext.djb.apistatus;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * API Status 공지의 제목·본문 조립. 자동 탐지({@link ApiStatusDetectionService})와
+ * 관리자 수동 등록(공지사항 관리 화면)이 같은 양식을 쓰도록 문구 조립만 떼어 둔 것이다.
+ *
+ *
게시 여부 판정·API 명 조회는 하지 않는다 - 자동 탐지는 APIGW 스키마 조회가 필요하고
+ * 수동 등록은 영향 API 팝업이 이미 게시 API 만 고르게 되어 있어, 판정 책임을 호출부에 남긴다.
+ * 여기는 "게시 API 표시명 목록 + 미게시 건수" 만 받는다.
+ */
+@Component
+public class ApiStatusNoticeComposer {
+
+ private final ApiStatusDraftTemplate draftTemplate;
+
+ @Autowired
+ public ApiStatusNoticeComposer(ApiStatusDraftTemplate draftTemplate) {
+ this.draftTemplate = draftTemplate;
+ }
+
+ /**
+ * 공지 제목. 게시 API 가 하나도 없으면 인터페이스 명을 쓰지 않고 건수로만 적는다.
+ *
+ * @param keyword 제목 키워드 (장애 / 응답지연)
+ * @param publishedLabels 개발자포탈 게시 API 표시명 (노출 가능한 것만)
+ * @param hiddenCount 미게시 인터페이스 건수
+ * @param totalCount 영향 인터페이스 전체 건수
+ */
+ public String buildTitle(String keyword, List publishedLabels, int hiddenCount, int totalCount) {
+ if (publishedLabels == null || publishedLabels.isEmpty()) {
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_HIDDEN_ONLY,
+ "gw", hiddenLabel(totalCount), "keyword", keyword);
+ }
+ String first = publishedLabels.get(0);
+ if (totalCount > 1) {
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MULTI,
+ "first", first, "rest", String.valueOf(totalCount - 1), "keyword", keyword);
+ }
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_SINGLE, "first", first, "keyword", keyword);
+ }
+
+ /**
+ * 관리자 수동 등록 공지 제목. 자동 탐지 표기("[자동감지]", "GW 인터페이스 N건")를 쓰지 않는다 -
+ * 사람이 직접 쓰는 공지이고, 영향 API 선택 팝업이 게시 API 만 보여주므로 미게시 묶음 표기도 필요 없다.
+ *
+ * @param keyword 제목 키워드 (장애)
+ * @param publishedLabels 화면에서 고른 영향 API 표시명. 비어 있으면 API 명 없는 제목을 만든다
+ */
+ public String buildManualTitle(String keyword, List publishedLabels) {
+ if (publishedLabels == null || publishedLabels.isEmpty()) {
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MANUAL_EMPTY, "keyword", keyword);
+ }
+ String first = publishedLabels.get(0);
+ if (publishedLabels.size() > 1) {
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MANUAL_MULTI,
+ "first", first, "rest", String.valueOf(publishedLabels.size() - 1), "keyword", keyword);
+ }
+ return draftTemplate.text(ApiStatusDraftTemplate.TITLE_MANUAL_SINGLE, "first", first, "keyword", keyword);
+ }
+
+ /**
+ * 공지 본문(HTML). 머리말 + 영향 API 목록 + 검수 항목 순이다.
+ *
+ *
게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다.
+ * 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다.
+ *
+ * @param leadText 머리말 (이미 치환이 끝난 문구)
+ * @param draftHints true 면 검수 항목에 작성 힌트를, false 면 게시용 기본 문구를 채운다
+ */
+ public String buildBody(String leadText, List publishedLabels, int hiddenCount, boolean draftHints) {
+ StringBuilder detail = new StringBuilder();
+ detail.append(leadText);
+
+ detail.append("
");
+ }
+ return detail.toString();
+ }
+
+ /** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */
+ public String hiddenLabel(int hiddenCount) {
+ return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount));
+ }
+}
diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/NoticeDraftTemplateUI.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/NoticeDraftTemplateUI.java
new file mode 100644
index 0000000..0efb8a9
--- /dev/null
+++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/NoticeDraftTemplateUI.java
@@ -0,0 +1,17 @@
+package com.eactive.eai.rms.onl.apim.portalnotice;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 공지사항 등록 화면에 채워 넣을 장애 공지 양식 (제목 + 본문 HTML).
+ * 자동 탐지 초안과 같은 문구를 쓴다 - {@code ApiStatusNoticeComposer} 참조.
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class NoticeDraftTemplateUI {
+ private String subject;
+ private String detail;
+}
diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java
index b119554..b88bde9 100644
--- a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java
+++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManController.java
@@ -6,7 +6,12 @@ import com.eactive.eai.rms.common.combo.ComboVo;
import com.eactive.eai.rms.common.login.SessionManager;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeUISearch;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@@ -19,17 +24,23 @@ import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
+@Slf4j
@RequiredArgsConstructor
public class PortalNoticeManController extends BaseAnnotationController {
private final PortalNoticeManService portalNoticeManService;
private final ComboService comboService;
+ /** 영향 API 목록(JSON) 파싱 전용 - 화면에서 넘어온 값만 읽는다 */
+ private final ObjectMapper draftTemplateMapper = new ObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
@GetMapping(value = "/onl/apim/portalnotice/portalNoticeMan.view")
public void view() {
// view
@@ -87,6 +98,27 @@ public class PortalNoticeManController extends BaseAnnotationController {
return ResponseEntity.ok().build();
}
+ /**
+ * 장애 공지 양식(제목·본문) 조회. 자동 탐지 초안과 같은 문구를 관리자 수동 등록에서도 쓴다.
+ *
+ * @param apisJson 화면에서 고른 영향 API 배열 - {@code [{"apiId":"...","apiName":"..."}]}.
+ * 이름에 쉼표가 들어갈 수 있어 구분자 문자열 대신 JSON 으로 받는다.
+ */
+ @PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=DRAFT_TEMPLATE")
+ public ResponseEntity draftTemplate(String apisJson) {
+ List affectedApis = Collections.emptyList();
+ if (StringUtils.isNotBlank(apisJson)) {
+ try {
+ affectedApis = draftTemplateMapper.readValue(apisJson,
+ new TypeReference>() {});
+ } catch (IOException e) {
+ // 양식 채우기는 보조 기능이므로 파싱 실패 시 영향 API 없이 양식만 돌려준다
+ log.warn("영향 API 목록 파싱 실패 - 영향 API 없이 양식 생성", e);
+ }
+ }
+ return ResponseEntity.ok(portalNoticeManService.buildDraftTemplate(affectedApis));
+ }
+
@PostMapping(value = "/onl/apim/portalnotice/portalNoticeMan.json", params = "cmd=TIMELINE_LIST")
public ResponseEntity> selectTimeline(Long incidentId) {
return ResponseEntity.ok(portalNoticeManService.selectTimeline(incidentId));
diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java
index 7389dc6..aa7d10e 100644
--- a/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java
+++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/PortalNoticeManService.java
@@ -16,6 +16,8 @@ import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.eai.common.util.ContainerUtil;
import com.eactive.eai.rms.common.base.BaseService;
+import com.eactive.eai.rms.ext.djb.apistatus.ApiStatusDraftTemplate;
+import com.eactive.eai.rms.ext.djb.apistatus.ApiStatusNoticeComposer;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeUISearch;
import lombok.extern.slf4j.Slf4j;
@@ -51,6 +53,11 @@ public class PortalNoticeManService extends BaseService {
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
+ private final ApiStatusDraftTemplate draftTemplate;
+ private final ApiStatusNoticeComposer noticeComposer;
+
+ /** 수동 등록 양식의 제목 키워드. 자동 탐지의 "장애" 와 같은 자리에 들어간다 */
+ private static final String MANUAL_TITLE_KEYWORD = "장애";
private String decodeString(String value) {
if (ContainerUtil.get() == ContainerUtil.TOMCAT) {
@@ -70,13 +77,40 @@ public class PortalNoticeManService extends BaseService {
FileService fileService,
DjbApistatusIncidentRepository incidentRepository,
DjbApistatusIncidentApiRepository incidentApiRepository,
- DjbApistatusIncidentTimelineRepository incidentTimelineRepository){
+ DjbApistatusIncidentTimelineRepository incidentTimelineRepository,
+ ApiStatusDraftTemplate draftTemplate,
+ ApiStatusNoticeComposer noticeComposer){
this.portalNoticeService = portalNoticeService;
this.portalNoticeUIMapper = portalNoticeUIMapper;
this.fileService = fileService;
this.incidentRepository = incidentRepository;
this.incidentApiRepository = incidentApiRepository;
this.incidentTimelineRepository = incidentTimelineRepository;
+ this.draftTemplate = draftTemplate;
+ this.noticeComposer = noticeComposer;
+ }
+
+ /**
+ * 관리자가 직접 등록할 장애 공지의 제목·본문 양식을 만든다.
+ *
+ *
자동 탐지 초안과 같은 문구(PTL_PROPERTY {@code djb.apistatus.draft.*})를 쓴다.
+ * 탐지 사유({@code summary}) 처럼 수동 등록에 없는 값은 치환하지 않고 작성 안내 문구로 대신한다.
+ *
+ *
영향 API 는 화면에서 고른 값을 그대로 쓴다 - 선택 팝업이 이미 개발자포탈 게시 API 만
+ * 보여주므로 게시 여부를 다시 판정하지 않는다 (미게시 건수 0).
+ */
+ @Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
+ public NoticeDraftTemplateUI buildDraftTemplate(List affectedApis) {
+ List labels = affectedApis == null ? Collections.emptyList()
+ : affectedApis.stream()
+ .filter(api -> api != null && StringUtils.isNotBlank(api.getApiId()))
+ .map(api -> StringUtils.defaultIfBlank(api.getApiName(), api.getApiId()))
+ .collect(Collectors.toList());
+
+ String subject = noticeComposer.buildManualTitle(MANUAL_TITLE_KEYWORD, labels);
+ String detail = noticeComposer.buildBody(
+ draftTemplate.text(ApiStatusDraftTemplate.BODY_LEAD_MANUAL), labels, 0, true);
+ return new NoticeDraftTemplateUI(subject, detail);
}
private static boolean isIncidentKind(String noticeType) {
diff --git a/src/main/resources/apistatus-draft.yml b/src/main/resources/apistatus-draft.yml
index 5e0d998..027899d 100644
--- a/src/main/resources/apistatus-draft.yml
+++ b/src/main/resources/apistatus-draft.yml
@@ -1,9 +1,12 @@
# ============================================================
# API Status 자동 탐지 초안 양식
#
-# ApiStatusDetectionService 가 자동 생성하는 공지 제목/본문/타임라인 문구를 여기서 관리한다.
+# ApiStatusDetectionService 가 자동 생성하는 공지와 관리자 수동 등록 공지가 함께 쓰는 문구다.
# 환경별로 갈리는 값이 아니므로 (/WEB-INF/properties 와 달리) 단일 파일이다.
#
+# - 운영 중에는 PTL_PROPERTY (그룹 'Portal', 키 'djb.apistatus.draft.' + 아래 키) 값이 우선한다.
+# 이 파일은 그 프로퍼티가 없을 때 쓰는 바탕값이자 문구 원본이다.
+#
# - 치환자는 {name} 형식. 정의되지 않은 키는 치환되지 않고 그대로 남는다.
# - 이 파일이 없거나 파싱에 실패하면 코드에 박힌 기본 문구로 동작한다 (초안 생성은 멈추지 않는다).
# - 게시되지 않은 GW 인터페이스는 이름·ID 를 노출하지 않고 gw-label 로 묶어 표기한다.
@@ -20,12 +23,19 @@ draft:
multi: "[자동감지] {first} 외 {rest}종 API {keyword}"
# 게시된 API 가 하나도 없을 때 (인터페이스 명을 쓰지 않는다)
hidden-only: "[자동감지] {gw} {keyword}"
+ # 관리자 수동 등록용 - 사람이 직접 쓰는 공지라 "[자동감지]"·GW 묶음 표기를 쓰지 않는다
+ manual-single: "{first} API {keyword}"
+ manual-multi: "{first} 외 {rest}종 API {keyword}"
+ # 영향 API 를 아직 고르지 않은 상태
+ manual-empty: "API {keyword}"
# ---- 공지 본문 (HTML) ----
body:
# {summary}=탐지 사유(에러율 임계 초과 등)
lead-publish: "
{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.
"
lead-draft: "
{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.
"
+ # 관리자가 공지사항 관리에서 직접 등록할 때 (탐지 사유가 없으므로 치환자를 쓰지 않는다)
+ lead-manual: "
[작성 필요] 장애 개요를 입력해 주십시오.
"
affected-heading: "영향 API"
# 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
filled-text: "확인 중입니다."