관리자 수동 등록 장애 공지 양식 추가

- 영향을 받는 API 표시 문구 재활용 및 본문 자동 생성 지원
- yml 및 DB 프로퍼티 병합 처리로 문구 동기화 자동화
This commit is contained in:
Rinjae
2026-09-01 18:26:41 +09:00
parent 6e4abf9f27
commit 60d1dcea96
8 changed files with 482 additions and 54 deletions
@@ -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 @@
</td>
</tr>
<tr>
<th>본문 <font color="red">*</font></th>
<th>본문 <font color="red">*</font>
<div class="template-row" style="display:none;margin-top:5px;">
<button type="button" class="cssbtn smallBtn" id="btn_applyTemplate" level="W" status="DETAIL,NEW"
style="min-width:0;width:90%;" title="장애 공지 양식으로 제목과 본문을 채웁니다.">양식 적용</button>
</div>
</th>
<td colspan="3">
<textarea id="contents" name="noticeDetail" style="width:100%;height:300px" data-required data-warning="본문을 입력하여 주십시오."></textarea>
</td>
@@ -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;
}
@@ -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));
@@ -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) {
+11 -1
View File
@@ -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: "확인 중입니다."