관리자 수동 등록 장애 공지 양식 추가
- 영향을 받는 API 표시 문구 재활용 및 본문 자동 생성 지원 - yml 및 DB 프로퍼티 병합 처리로 문구 동기화 자동화
This commit is contained in:
+21
-36
@@ -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<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);
|
||||
return noticeComposer.buildTitle(titleKeyword, labelsOf(split.published, apiNames),
|
||||
split.hiddenCount, apiIds.size());
|
||||
}
|
||||
|
||||
/** 인터페이스 ID 목록을 표시명 목록으로 바꾼다 (명칭을 못 찾으면 ID 그대로) */
|
||||
private List<String> labelsOf(List<String> apiIds, Map<String, String> apiNames) {
|
||||
List<String> 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<String> apiIds,
|
||||
Map<String, String> 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("<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 (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
|
||||
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
|
||||
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||
detail.append("<p>").append(publish
|
||||
? draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT)
|
||||
: draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint()))
|
||||
.append("</p>");
|
||||
}
|
||||
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");
|
||||
|
||||
@@ -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 공지 양식. 자동 탐지 초안과 관리자 수동 등록이 같은 문구를 쓴다.
|
||||
*
|
||||
* <p>자동 생성 공지의 제목·본문·타임라인 문구를 코드가 아닌 설정으로 관리한다.
|
||||
* admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없으므로 SnakeYAML 로 직접 읽는다
|
||||
* ({@code <context:property-placeholder>} 는 properties 전용이고, 검수 섹션이 리스트라
|
||||
* properties 로는 인덱스 키로 흩어진다).</p>
|
||||
* <p>문구 출처는 우선순위 3단이다.</p>
|
||||
* <ol>
|
||||
* <li>{@code PTL_PROPERTY} (그룹 {@link #PROPERTY_GROUP}, 키 {@link #PROPERTY_PREFIX} + 문구키)
|
||||
* - 운영 중 포탈 프로퍼티 관리 화면에서 재배포 없이 고칠 수 있다.</li>
|
||||
* <li>{@code classpath:apistatus-draft.yml} - 배포본에 남기는 문구 원본.</li>
|
||||
* <li>코드 {@link #DEFAULTS} - 위 둘이 모두 없을 때.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>파일이 없거나 파싱에 실패하면 {@link #DEFAULTS} 로 동작한다 - 문구 설정 문제로
|
||||
* 장애 탐지 자체가 멈추면 안 되기 때문이다. 개별 키가 비어도 같은 이유로 기본값으로 떨어진다.</p>
|
||||
* <p>yml 을 SnakeYAML 로 직접 읽는 이유: admin 은 Spring Boot 가 아니라 yml 자동 바인딩이 없고
|
||||
* ({@code <context:property-placeholder>} 는 properties 전용), 검수 섹션이 리스트라 properties 로는
|
||||
* 인덱스 키로 흩어진다.</p>
|
||||
*
|
||||
* <p>DB 조회는 60초 스냅샷으로 캐시한다 - 공지 한 건을 만드는 데 {@link #text} 가 수십 번 불리므로
|
||||
* 매번 조회하면 안 된다. 조회에 실패하거나 개별 키가 비어 있으면 그 아래 단계 문구로 떨어진다.
|
||||
* 문구 설정 문제로 장애 탐지 자체가 멈추면 안 되기 때문이다.</p>
|
||||
*/
|
||||
@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<String, String> DEFAULTS;
|
||||
|
||||
/** 프로퍼티 최초 등록 시 함께 적는 설명 (관리 화면에서 치환자 의미를 보여준다) */
|
||||
private static final Map<String, String> DESCRIPTIONS;
|
||||
|
||||
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(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, "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>");
|
||||
defaults.put(BODY_LEAD_DRAFT, "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
|
||||
defaults.put(BODY_LEAD_MANUAL, "<p>[작성 필요] 장애 개요를 입력해 주십시오.</p>");
|
||||
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<String, String> 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<String, String> texts = DEFAULTS;
|
||||
private List<Section> sections = DEFAULT_SECTIONS;
|
||||
@Autowired(required = false)
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
/** yml/코드 기본값 - DB 프로퍼티가 없을 때 쓰는 바탕 문구 */
|
||||
private Map<String, String> baselineTexts = DEFAULTS;
|
||||
private List<Section> baselineSections = DEFAULT_SECTIONS;
|
||||
|
||||
/** 바탕 문구 위에 DB 프로퍼티를 덮은 결과 (실제 사용분) */
|
||||
private volatile Map<String, String> texts = DEFAULTS;
|
||||
private volatile List<Section> 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<String, String> 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<Section> 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<String, String> group = portalPropertyService.getPortalPropertiesAsMap(PROPERTY_GROUP);
|
||||
|
||||
Map<String, String> 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<Section> 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<Section> parseSections(String value) {
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
List<Section> 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<Section> 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)는 별도로 읽는다. */
|
||||
|
||||
@@ -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})와
|
||||
* 관리자 수동 등록(공지사항 관리 화면)이 같은 양식을 쓰도록 문구 조립만 떼어 둔 것이다.
|
||||
*
|
||||
* <p>게시 여부 판정·API 명 조회는 하지 않는다 - 자동 탐지는 APIGW 스키마 조회가 필요하고
|
||||
* 수동 등록은 영향 API 팝업이 이미 게시 API 만 고르게 되어 있어, 판정 책임을 호출부에 남긴다.
|
||||
* 여기는 "게시 API 표시명 목록 + 미게시 건수" 만 받는다.</p>
|
||||
*/
|
||||
@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<String> 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<String> 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 목록 + 검수 항목 순이다.
|
||||
*
|
||||
* <p>게시된 API 만 이름으로 적고(인터페이스 ID 는 넣지 않는다) 나머지는 건수로 묶는다.
|
||||
* 공지 본문은 저장 시점에 굳는 HTML 이라 나중에 사용자별로 가릴 수 없다.</p>
|
||||
*
|
||||
* @param leadText 머리말 (이미 치환이 끝난 문구)
|
||||
* @param draftHints true 면 검수 항목에 작성 힌트를, false 면 게시용 기본 문구를 채운다
|
||||
*/
|
||||
public String buildBody(String leadText, List<String> publishedLabels, int hiddenCount, boolean draftHints) {
|
||||
StringBuilder detail = new StringBuilder();
|
||||
detail.append(leadText);
|
||||
|
||||
detail.append("<p><b>")
|
||||
.append(draftTemplate.text(ApiStatusDraftTemplate.BODY_AFFECTED_HEADING))
|
||||
.append("</b></p><ul>");
|
||||
if (publishedLabels != null) {
|
||||
for (String label : publishedLabels) {
|
||||
detail.append("<li>").append(label).append("</li>");
|
||||
}
|
||||
}
|
||||
if (hiddenCount > 0) {
|
||||
detail.append("<li>").append(hiddenLabel(hiddenCount)).append("</li>");
|
||||
}
|
||||
detail.append("</ul>");
|
||||
|
||||
for (ApiStatusDraftTemplate.Section section : draftTemplate.getSections()) {
|
||||
detail.append("<p><b>").append(section.getTitle()).append("</b></p>");
|
||||
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||
detail.append("<p>").append(draftHints
|
||||
? draftTemplate.text(ApiStatusDraftTemplate.BODY_DRAFT_TEXT, "hint", section.getHint())
|
||||
: draftTemplate.text(ApiStatusDraftTemplate.BODY_FILLED_TEXT))
|
||||
.append("</p>");
|
||||
}
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
/** "GW 인터페이스 3건" - 게시되지 않은 인터페이스 묶음 표기 */
|
||||
public String hiddenLabel(int hiddenCount) {
|
||||
return draftTemplate.text(ApiStatusDraftTemplate.GW_LABEL, "count", String.valueOf(hiddenCount));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+32
@@ -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<NoticeDraftTemplateUI> draftTemplate(String apisJson) {
|
||||
List<IncidentAffectedApiUI> affectedApis = Collections.emptyList();
|
||||
if (StringUtils.isNotBlank(apisJson)) {
|
||||
try {
|
||||
affectedApis = draftTemplateMapper.readValue(apisJson,
|
||||
new TypeReference<List<IncidentAffectedApiUI>>() {});
|
||||
} 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<List<IncidentTimelineUI>> selectTimeline(Long incidentId) {
|
||||
return ResponseEntity.ok(portalNoticeManService.selectTimeline(incidentId));
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자가 직접 등록할 장애 공지의 제목·본문 양식을 만든다.
|
||||
*
|
||||
* <p>자동 탐지 초안과 같은 문구(PTL_PROPERTY {@code djb.apistatus.draft.*})를 쓴다.
|
||||
* 탐지 사유({@code summary}) 처럼 수동 등록에 없는 값은 치환하지 않고 작성 안내 문구로 대신한다.</p>
|
||||
*
|
||||
* <p>영향 API 는 화면에서 고른 값을 그대로 쓴다 - 선택 팝업이 이미 개발자포탈 게시 API 만
|
||||
* 보여주므로 게시 여부를 다시 판정하지 않는다 (미게시 건수 0).</p>
|
||||
*/
|
||||
@Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
|
||||
public NoticeDraftTemplateUI buildDraftTemplate(List<IncidentAffectedApiUI> affectedApis) {
|
||||
List<String> 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) {
|
||||
|
||||
@@ -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: "<p>{summary} 로 자동 감지된 장애입니다. 상세 내용은 확인 후 갱신될 수 있습니다.</p>"
|
||||
lead-draft: "<p>{summary} 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>"
|
||||
# 관리자가 공지사항 관리에서 직접 등록할 때 (탐지 사유가 없으므로 치환자를 쓰지 않는다)
|
||||
lead-manual: "<p>[작성 필요] 장애 개요를 입력해 주십시오.</p>"
|
||||
affected-heading: "영향 API"
|
||||
# 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
|
||||
filled-text: "확인 중입니다."
|
||||
|
||||
Reference in New Issue
Block a user