From 60d1dcea964e44de5ea28d1a829c8a0cfa5319f0 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 1 Sep 2026 18:26:41 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=93=B1=EB=A1=9D=20=EC=9E=A5=EC=95=A0=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=20=EC=96=91=EC=8B=9D=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=98=81=ED=96=A5=EC=9D=84=20=EB=B0=9B=EB=8A=94=20API=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=20=EC=9E=AC=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EB=B3=B8=EB=AC=B8=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=A7=80=EC=9B=90=20-=20yml=20?= =?UTF-8?q?=EB=B0=8F=20DB=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portalnotice/portalNoticeManDetail.jsp | 44 +++- .../apistatus/ApiStatusDetectionService.java | 57 ++--- .../djb/apistatus/ApiStatusDraftTemplate.java | 231 ++++++++++++++++-- .../apistatus/ApiStatusNoticeComposer.java | 107 ++++++++ .../portalnotice/NoticeDraftTemplateUI.java | 17 ++ .../PortalNoticeManController.java | 32 +++ .../portalnotice/PortalNoticeManService.java | 36 ++- src/main/resources/apistatus-draft.yml | 12 +- 8 files changed, 482 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/eactive/eai/rms/ext/djb/apistatus/ApiStatusNoticeComposer.java create mode 100644 src/main/java/com/eactive/eai/rms/onl/apim/portalnotice/NoticeDraftTemplateUI.java diff --git a/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp b/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp index f47bc19..1267845 100644 --- a/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp +++ b/WebContent/jsp/onl/apim/portalnotice/portalNoticeManDetail.jsp @@ -118,6 +118,9 @@ $('.incident-endat-hint').hide(); $('#endAt').prop('readonly', false); } + // 양식(템플릿)은 장애 공지에만 쓴다 - 점검은 문구 체계가 다르다 + $('.template-row').toggle(t === NOTICE_TYPE_INCIDENT); + // 타임라인은 등록된 장애(수정 모드)에서만 다룬다 if (t === NOTICE_TYPE_INCIDENT && isDetail && currentIncidentId) { $('#timelineSection').show(); @@ -126,6 +129,31 @@ } } + /** + * 장애 공지 양식(제목·본문)을 서버에서 받아 채운다. + * 문구는 PTL_PROPERTY 'djb.apistatus.draft.*' 이며 자동 탐지 초안과 같은 양식이다. + * + * @param force true 면 이미 입력한 제목·본문도 (확인 후) 덮어쓴다 + */ + function applyTemplate(force) { + if (force) { + var hasInput = $.trim($('#noticeSubject').val()) !== '' || !$('#contents').summernote('isEmpty'); + if (hasInput && !confirm('현재 입력한 제목·본문을 양식으로 덮어씁니다. 계속하시겠습니까?')) { + return; + } + } + $.ajax({ + type: "POST", url: url, dataType: "json", + data: {cmd: 'DRAFT_TEMPLATE', apisJson: JSON.stringify(affectedApis)}, + success: function (data) { + if (!data) return; + $('#noticeSubject').val(data.subject || ''); + $('#contents').summernote('code', data.detail || ''); + }, + error: function (e) { alert(e.responseText); } + }); + } + function decodeHTMLEntities(text) { var textArea = document.createElement('textarea'); textArea.innerHTML = text; @@ -441,6 +469,15 @@ $('#noticeType').on('change', toggleIncidentFields); + // 신규 등록에서 장애를 고르면 빈 본문에 한해 양식을 자동으로 채운다. + // (수정 모드나 이미 쓴 본문은 건드리지 않는다 - 덮어쓰려면 '양식 적용' 버튼) + $('#noticeType').on('change', function () { + if (!isDetail && $(this).val() === NOTICE_TYPE_INCIDENT && $('#contents').summernote('isEmpty')) { + applyTemplate(false); + } + }); + $('#btn_applyTemplate').click(function () { applyTemplate(true); }); + $('#btn_addAffectedApi').click(openApiPopup); $('#btn_removeAffectedApi').click(removeSelectedAffectedApis); @@ -590,7 +627,12 @@ - 본문 * + 본문 * + + 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 a897496..cf35aae 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 @@ -130,10 +130,14 @@ public class ApiStatusDetectionService { @Autowired private PortalPropertyService portalPropertyService; - /** 자동 초안 문구 양식 (classpath:apistatus-draft.yml) */ + /** 공지 문구 양식 (PTL_PROPERTY > classpath:apistatus-draft.yml) */ @Autowired private ApiStatusDraftTemplate draftTemplate; + /** 제목·본문 조립. 관리자 수동 등록 화면과 같은 양식을 쓰기 위해 분리했다 */ + @Autowired + private ApiStatusNoticeComposer noticeComposer; + /** * 이벤트 감지 * @param event "CHECK_START" : 점검시작 @@ -642,7 +646,7 @@ public class ApiStatusDetectionService { /** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */ private String hiddenLabel(int hiddenCount) { - return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount)); + return noticeComposer.hiddenLabel(hiddenCount); } /** @@ -667,16 +671,17 @@ public class ApiStatusDetectionService { */ 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); + return noticeComposer.buildTitle(titleKeyword, labelsOf(split.published, apiNames), + split.hiddenCount, apiIds.size()); + } + + /** 인터페이스 ID 목록을 표시명 목록으로 바꾼다 (명칭을 못 찾으면 ID 그대로) */ + private List labelsOf(List apiIds, Map apiNames) { + List labels = new ArrayList<>(); + for (String apiId : apiIds) { + labels.add(apiLabel(apiId, apiNames)); } - 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); + return labels; } /** @@ -690,39 +695,19 @@ public class ApiStatusDetectionService { */ private PortalNotice createNotice(String title, String summary, List apiIds, Map apiNames, LocalDateTime now, boolean publish) { - StringBuilder detail = new StringBuilder(); - detail.append(draftTemplate.text(publish + String lead = draftTemplate.text(publish ? ApiStatusDraftTemplate.BODY_LEAD_PUBLISH : ApiStatusDraftTemplate.BODY_LEAD_DRAFT, - "summary", summary)); + "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 (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) { - detail.append("

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

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

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

"); - } + 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단이다.

+ *
    + *
  1. {@code PTL_PROPERTY} (그룹 {@link #PROPERTY_GROUP}, 키 {@link #PROPERTY_PREFIX} + 문구키) + * - 운영 중 포탈 프로퍼티 관리 화면에서 재배포 없이 고칠 수 있다.
  2. + *
  3. {@code classpath:apistatus-draft.yml} - 배포본에 남기는 문구 원본.
  4. + *
  5. 코드 {@link #DEFAULTS} - 위 둘이 모두 없을 때.
  6. + *
* - *

파일이 없거나 파싱에 실패하면 {@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("

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

    "); + if (publishedLabels != null) { + for (String label : publishedLabels) { + detail.append("
  • ").append(label).append("
  • "); + } + } + if (hiddenCount > 0) { + detail.append("
  • ").append(hiddenLabel(hiddenCount)).append("
  • "); + } + detail.append("
"); + + for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) { + detail.append("

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

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

").append(draftHints + ? draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()) + : draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)) + .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: "확인 중입니다."