알림 테스트 도구 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- SCSS 스타일 및 HTML 템플릿 구현
- 알림 발송/상태 처리 자바 로직 추가
- 클라이언트 JS: 동적 데이터 바인딩, 발송/이력 관리
This commit is contained in:
Rinjae
2026-08-26 12:57:03 +09:00
parent 758b3ebd7d
commit 033faa6714
13 changed files with 1311 additions and 10 deletions
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 이력 1건. HttpSession(속성 {@code NOTI_TEST_HISTORY})에 리스트로 보관한다.
*
* <p>DB 테이블을 신설하지 않는 요구사항에 따라, 상태는 저장하지 않고 매 폴링마다
* {@code requestIds}로 PTL_MESSAGE_REQUEST를 다시 조회해 {@link #refreshStatus} 로 갱신한다.</p>
*/
@Getter
public class NotiTestHistoryEntry implements Serializable {
private static final long serialVersionUID = 1L;
private final String entryId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private final LocalDateTime requestedAt;
private final NotiTestTarget.Category category;
private final String targetLabel;
private final String messageCode;
private final NotiTestTarget.Strategy strategy;
private final String recipient;
private final List<String> messages;
private final List<String> requestIds;
private final Map<String, String> statuses = new LinkedHashMap<>();
private final String warning;
private final String errorMessage;
private NotiTestHistoryEntry(NotiTestTarget target, String recipient, NotiTestResult result) {
this.entryId = UUID.randomUUID().toString();
this.requestedAt = LocalDateTime.now();
this.category = target.getCategory();
this.targetLabel = target.getLabel();
this.messageCode = target.getMessageCode().name();
this.strategy = target.getStrategy();
this.recipient = recipient;
this.messages = result.getCreated().stream()
.map(MessageRequest::getMessage)
.distinct()
.collect(Collectors.toList());
this.requestIds = result.getCreated().stream().map(MessageRequest::getId).collect(Collectors.toList());
this.warning = result.getWarning();
this.errorMessage = result.getErrorMessage();
for (MessageRequest request : result.getCreated()) {
this.statuses.put(request.getId(), request.getRequestStatus());
}
}
public static NotiTestHistoryEntry of(NotiTestTarget target, String recipient, NotiTestResult result) {
return new NotiTestHistoryEntry(target, recipient, result);
}
/** 폴링 시 최신 상태로 덮어쓴다. 세션에 다시 저장하는 쪽은 호출부 책임이다. */
public void refreshStatus(Map<String, MessageRequest> byId) {
for (String id : requestIds) {
MessageRequest request = byId.get(id);
if (request != null) {
statuses.put(id, request.getRequestStatus());
}
}
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 알림 발송 테스트 페이지 관련 {@code PTL_PROPERTY} 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다({@link com.eactive.apim.portal.djb.swing.SwingNotifyProperties} 참고).
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를 생성하므로
* 별도 초기 데이터 없이도 동작하며, 운영자는 PTL_PROPERTY 값만 바꿔 재배포 없이 on/off·계정 추가가 가능하다.</p>
*/
@Component
@RequiredArgsConstructor
public class NotiTestProperties {
public static final String GROUP = "Portal";
public static final String KEY_ENABLED = "notification-test.enabled";
public static final String KEY_ALLOWED_EMAILS = "notification-test.allowed-emails";
private static final String LIST_DELIMITERS = "[,;\\r\\n]";
private final PortalPropertyService portalPropertyService;
public boolean isEnabled() {
String value = resolve(KEY_ENABLED, "false", "알림 발송 테스트 페이지 활성화 여부(true/false)");
return "true".equalsIgnoreCase(value == null ? null : value.trim());
}
public boolean isAllowed(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}
String allowList = resolve(KEY_ALLOWED_EMAILS, "",
"알림 발송 테스트 페이지 접근 허용 이메일 목록 (콤마/세미콜론/줄바꿈 구분)");
if (allowList == null || allowList.trim().isEmpty()) {
return false;
}
String target = email.trim();
for (String token : allowList.split(LIST_DELIMITERS)) {
String candidate = token.trim();
if (!candidate.isEmpty() && candidate.equalsIgnoreCase(target)) {
return true;
}
}
return false;
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
}
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.template.entity.MessageRequest;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
/**
* {@link NotiTestService#send} 한 번 호출의 결과. 화면/세션에는 이 값을 그대로 담지 않고
* {@link NotiTestHistoryEntry}로 변환해서 보관한다(엔티티를 세션에 직렬화하지 않기 위함).
*/
@Getter
public class NotiTestResult {
private final boolean success;
private final List<MessageRequest> created;
private final String warning;
private final String errorMessage;
private NotiTestResult(boolean success, List<MessageRequest> created, String warning, String errorMessage) {
this.success = success;
this.created = created;
this.warning = warning;
this.errorMessage = errorMessage;
}
public static NotiTestResult of(List<MessageRequest> created, String warning) {
return new NotiTestResult(true, created == null ? new ArrayList<>() : created, warning, null);
}
public static NotiTestResult error(String errorMessage) {
return new NotiTestResult(false, new ArrayList<>(), null, errorMessage);
}
}
@@ -0,0 +1,121 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.djb.swing.SwingMessageWriter;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import com.eactive.apim.portal.template.service.MessageSendService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지의 발송 로직. {@link NotiTestTarget.Strategy} 에 따라 세 갈래로 분기한다.
*
* <p>HANDLER 전략은 {@code MessageHandlerService.publishEvent}(이벤트버스, fire-and-forget)를 쓰지 않는다 —
* 반환값을 받을 방법이 없기 때문이다. 대신 핸들러의 {@code createEvent}만 재사용해 파라미터 매핑 로직은
* 그대로 살리고, {@code MessageSendService.sendMessage}를 직접 호출해 생성된 {@link MessageRequest} 목록을
* 돌려받는다. {@code MessageSendEvent} 구독자는 {@code MessageHandlerService.handleMessageSendEvent} 한
* 곳뿐이라 이 우회는 안전하다.</p>
*/
@Service
@RequiredArgsConstructor
public class NotiTestService {
private final MessageHandlerService messageHandlerService;
private final MessageSendService messageSendService;
private final SwingMessageWriter swingMessageWriter;
private final MessageTemplateRepository messageTemplateRepository;
public NotiTestResult send(String messageCodeName, Map<String, String> formParams) {
Optional<NotiTestTarget> targetOpt = NotiTestTarget.byMessageCode(messageCodeName);
if (!targetOpt.isPresent()) {
return NotiTestResult.error("알 수 없는 테스트 대상입니다: " + messageCodeName);
}
NotiTestTarget target = targetOpt.get();
Map<String, String> domainParams = extractDomainParams(target, formParams);
switch (target.getStrategy()) {
case HANDLER:
return sendViaHandler(target, buildRecipient(formParams), domainParams);
case DIRECT:
return sendDirect(target, buildRecipient(formParams), domainParams);
case BROADCAST:
return sendBroadcast(target, domainParams);
default:
return NotiTestResult.error("알 수 없는 발송 전략입니다: " + target.getStrategy());
}
}
private NotiTestResult sendViaHandler(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
MessageEventHandler handler = messageHandlerService.getHandlers().get(target.getMessageCode().name());
if (handler == null) {
// 카탈로그가 HANDLER 로 분류했는데 빈이 없는 방어적 상황(운영 배포 누락 등)
return NotiTestResult.error("핸들러 빈이 등록되어 있지 않습니다: " + target.getMessageCode());
}
Map<String, Object> rawParams = new HashMap<>(domainParams);
MessageSendEvent event = handler.createEvent(this, recipient, rawParams);
List<MessageRequest> created =
messageSendService.sendMessage(event.getMessageCode(), event.getRecipient(), event.getParams());
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendDirect(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
List<MessageRequest> created =
messageSendService.sendMessage(target.getMessageCode(), recipient, new HashMap<>(domainParams));
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendBroadcast(NotiTestTarget target, Map<String, String> domainParams) {
Map<String, Object> params = new HashMap<>(domainParams);
List<MessageRequest> created = swingMessageWriter.write(target.getMessageCode(), params);
return NotiTestResult.of(created, null);
}
/** 실서비스 템플릿에 추가 수신자(PTL_MESSAGE_RECIPIENT)가 등록돼 있으면 테스트 발송에도 함께 나간다는 경고. */
private String additionalRecipientWarning(NotiTestTarget target) {
return messageTemplateRepository.findById(target.getMessageCode().name())
.map(t -> t.getAdditionalRecipients())
.filter(list -> list != null && !list.isEmpty())
.map(list -> "이 항목은 실서비스 템플릿을 사용합니다. 등록된 추가 수신자 "
+ list.size() + "명에게도 함께 발송됩니다.")
.orElse(null);
}
/** {@code param_<key>} 폼 필드를 읽되, 비어있으면 카탈로그의 샘플값으로 채운다. */
private Map<String, String> extractDomainParams(NotiTestTarget target, Map<String, String> formParams) {
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, String> sample : target.getSampleParams().entrySet()) {
String value = formParams.get("param_" + sample.getKey());
result.put(sample.getKey(), StringUtils.hasText(value) ? value.trim() : sample.getValue());
}
return result;
}
private MessageRecipient buildRecipient(Map<String, String> formParams) {
String email = trimToNull(formParams.get("recipientEmail"));
return MessageRecipient.builder()
.username(trimToNull(formParams.get("recipientUsername")))
.userId(email)
.email(email)
.phone(trimToNull(formParams.get("recipientPhone")))
.messengerId(trimToNull(formParams.get("recipientMessengerId")))
.build();
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -0,0 +1,116 @@
package com.eactive.apim.portal.djb.notitest.catalog;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.Getter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지가 다루는 16종 테스트 대상 카탈로그.
*
* <p>발송 경로가 코드 위치에 따라 세 갈래로 갈린다({@link Strategy} 참고). 항목을 추가/변경할 때는
* 이 enum 하나만 고치면 된다 — 컨트롤러/서비스/화면은 이 카탈로그를 그대로 순회한다.</p>
*/
@Getter
public enum NotiTestTarget {
// ---- 전략 HANDLER: elink-portal-common 의 MessageEventHandler 재사용 ----
EMAIL_VERIFICATION(Category.EMAIL, "이메일 인증코드", MessageCode.USER_VERIFICATION_EMAIL,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_VERIFICATION(Category.SMS, "SMS 인증코드", MessageCode.USER_VERIFICATION_MOBILEPHONE,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_ACCOUNT_LOCKED(Category.SMS, "계정 잠김", MessageCode.USER_ACCOUNT_LOCKED,
Strategy.HANDLER, params("reason", "로그인 5회 실패")),
SMS_PASSWORD_CHANGED(Category.SMS, "비밀번호 변경", MessageCode.USER_PASSWORD_CHANGED,
Strategy.HANDLER, params()),
SMS_INVITATION(Category.SMS, "법인 내 초대", MessageCode.USER_INVITATION,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동",
"url", "https://example.com/invite",
"authNumber", "123456")),
SMS_INVITATION_CANCELED(Category.SMS, "법인 내 초대 취소", MessageCode.USER_INVITATION_CANCELED,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동")),
// ---- 전략 DIRECT: eapim-admin 전용 핸들러(클래스패스 미공유) — MessageSendService 직접 호출 ----
SMS_ORG_APPROVED(Category.SMS, "서비스 가입 승인", MessageCode.MANAGER_WITH_ORG_REGISTER_APPROVED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인")),
SMS_ORG_REJECTED(Category.SMS, "서비스 가입 거절", MessageCode.MANAGER_WITH_ORG_REGISTER_REJECTED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인", "REASON", "서류 미비")),
SMS_APP_APPROVED(Category.SMS, "클라이언트 승인", MessageCode.APP_APPROVE,
Strategy.DIRECT, params("APP_NAME", "테스트앱")),
SMS_APP_REJECTED(Category.SMS, "클라이언트 거절", MessageCode.APP_REJECTED,
Strategy.DIRECT, params("APP_NAME", "테스트앱", "REASON", "정책 위반")),
SMS_ADMIN_LOGIN(Category.SMS, "관리자포탈 로그인", MessageCode.ADMIN_VERIFICATION_MOBILEPHONE,
Strategy.DIRECT, params("authNumber", "123456", "userName", "관리자")),
MSG_API_STATUS(Category.MESSENGER, "API 상태 감시", MessageCode.API_STATUS_CHANGED,
Strategy.DIRECT, params("message", "[테스트] API 상태 변화 알림")),
MSG_INFLOW_TOKEN(Category.MESSENGER, "이상 징후 감시", MessageCode.INFLOW_TOKEN_FAILED,
Strategy.DIRECT, params("message", "[테스트] 유량제어 토큰 획득 실패")),
// ---- 전략 BROADCAST: SwingMessageWriter 직접 호출(PTL_PROPERTY 역할의 내부직원 전원에게 발송) ----
MSG_QNA_CREATED(Category.MESSENGER, "QnA 질문등록", MessageCode.INQUIRY_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"writerName", "홍길동")),
MSG_QNA_COMMENT(Category.MESSENGER, "QnA 댓글 등록", MessageCode.INQUIRY_COMMENT_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"commentContent", "테스트 댓글입니다",
"writerName", "홍길동")),
MSG_PARTNERSHIP(Category.MESSENGER, "개선요청 새글 등록", MessageCode.PARTNERSHIP_CREATED,
Strategy.BROADCAST, params(
"partnershipId", "TEST-0001",
"bizSubject", "[테스트] 개선요청 제목",
"writerName", "홍길동")),
;
@Getter
public enum Category {
EMAIL("이메일"), SMS("SMS"), MESSENGER("메신저");
private final String label;
Category(String label) {
this.label = label;
}
}
public enum Strategy { HANDLER, DIRECT, BROADCAST }
private final Category category;
private final String label;
private final MessageCode messageCode;
private final Strategy strategy;
private final Map<String, String> sampleParams;
NotiTestTarget(Category category, String label, MessageCode messageCode, Strategy strategy,
Map<String, String> sampleParams) {
this.category = category;
this.label = label;
this.messageCode = messageCode;
this.strategy = strategy;
this.sampleParams = sampleParams;
}
public static Optional<NotiTestTarget> byMessageCode(String messageCodeName) {
return Arrays.stream(values())
.filter(t -> t.messageCode.name().equalsIgnoreCase(messageCodeName))
.findFirst();
}
private static Map<String, String> params(String... kv) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
map.put(kv[i], kv[i + 1]);
}
return map;
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.djb.notitest.controller;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.notitest.NotiTestHistoryEntry;
import com.eactive.apim.portal.djb.notitest.NotiTestProperties;
import com.eactive.apim.portal.djb.notitest.NotiTestResult;
import com.eactive.apim.portal.djb.notitest.NotiTestService;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 페이지.
*
* <p>PTL_PROPERTY({@link NotiTestProperties})로 기능 on/off 와 접근 허용 이메일 목록을 관리한다.
* 운영에서도 상시 켜둘 수 있도록 GNB 에는 노출하지 않고 직접 URL 로만 접근한다(page.yml/portal.pages
* 미등록). 발송 이력은 별도 테이블 없이 HttpSession 에 보관하고, 상태는 기존 PTL_MESSAGE_REQUEST 를
* 3초 폴링으로 재조회해 갱신한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("/djb/notitest")
@RequiredArgsConstructor
public class NotiTestController {
private static final String SESSION_HISTORY_KEY = "NOTI_TEST_HISTORY";
private static final int MAX_HISTORY = 50;
private final NotiTestProperties notiTestProperties;
private final NotiTestService notiTestService;
private final MessageRequestRepository messageRequestRepository;
/** 폼 + 이력 렌더 */
@GetMapping
public ModelAndView index(HttpSession session, HttpServletResponse response) {
if (!notiTestProperties.isEnabled()) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
}
if (!isAllowedForCurrentUser()) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return null;
}
PortalAuthenticatedUser me = SecurityUtil.getPortalAuthenticatedUser();
ModelAndView mav = new ModelAndView("djb/notitest/index");
mav.addObject("targetsByCategory", groupByCategory());
mav.addObject("history", getHistory(session));
mav.addObject("defaultUsername", me.getUserName());
mav.addObject("defaultEmail", me.getEmailAddr());
mav.addObject("defaultPhone", me.getMobileNumber());
return mav;
}
/** 발송(AJAX) */
@PostMapping("/send")
@ResponseBody
public ResponseEntity<?> send(@RequestParam Map<String, String> allParams, HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
String messageCode = allParams.get("messageCode");
if (!StringUtils.hasText(messageCode)) {
return ResponseEntity.badRequest().body(Collections.singletonMap("error", "messageCode is required"));
}
NotiTestTarget target = NotiTestTarget.byMessageCode(messageCode).orElse(null);
if (target == null) {
return ResponseEntity.badRequest()
.body(Collections.singletonMap("error", "알 수 없는 테스트 대상입니다: " + messageCode));
}
NotiTestResult result = notiTestService.send(messageCode, allParams);
NotiTestHistoryEntry entry = NotiTestHistoryEntry.of(target, resolveRecipient(target, allParams), result);
pushHistory(session, entry);
return ResponseEntity.ok(entry);
}
/** 상태 폴링(AJAX, 3초 간격) — 별도 테이블 없이 PTL_MESSAGE_REQUEST 만 재조회한다. */
@GetMapping("/status")
@ResponseBody
public ResponseEntity<?> status(HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
List<NotiTestHistoryEntry> history = getHistory(session);
Set<String> ids = history.stream()
.flatMap(h -> h.getRequestIds().stream())
.collect(Collectors.toSet());
if (!ids.isEmpty()) {
Map<String, MessageRequest> byId = messageRequestRepository.findAllById(ids).stream()
.collect(Collectors.toMap(MessageRequest::getId, Function.identity()));
history.forEach(h -> h.refreshStatus(byId));
session.setAttribute(SESSION_HISTORY_KEY, history);
}
return ResponseEntity.ok(history);
}
private ResponseEntity<?> guardResponse() {
if (!notiTestProperties.isEnabled()) {
return ResponseEntity.notFound().build();
}
if (!isAllowedForCurrentUser()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return null;
}
private boolean isAllowedForCurrentUser() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String email = user == null ? null : user.getEmailAddr();
boolean allowed = notiTestProperties.isAllowed(email);
if (!allowed) {
log.warn("알림 테스트 페이지 접근 거부 - email: {}", email);
}
return allowed;
}
private Map<NotiTestTarget.Category, List<NotiTestTarget>> groupByCategory() {
return Arrays.stream(NotiTestTarget.values())
.collect(Collectors.groupingBy(NotiTestTarget::getCategory, LinkedHashMap::new, Collectors.toList()));
}
private String resolveRecipient(NotiTestTarget target, Map<String, String> formParams) {
if (target.getStrategy() == NotiTestTarget.Strategy.BROADCAST) {
return "실제 담당자 전원";
}
String phone = formParams.get("recipientPhone");
String email = formParams.get("recipientEmail");
String messengerId = formParams.get("recipientMessengerId");
if (target.getCategory() == NotiTestTarget.Category.EMAIL && StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(phone)) {
return phone;
}
if (StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(messengerId)) {
return messengerId;
}
return "-";
}
@SuppressWarnings("unchecked")
private void pushHistory(HttpSession session, NotiTestHistoryEntry entry) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
history = (history == null) ? new ArrayList<>() : new ArrayList<>(history);
history.add(0, entry);
if (history.size() > MAX_HISTORY) {
history = new ArrayList<>(history.subList(0, MAX_HISTORY));
}
session.setAttribute(SESSION_HISTORY_KEY, history);
}
@SuppressWarnings("unchecked")
private List<NotiTestHistoryEntry> getHistory(HttpSession session) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
return (history == null) ? new ArrayList<>() : history;
}
}
@@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -42,38 +43,43 @@ public class SwingMessageWriter {
private final SwingNotifyProperties properties; private final SwingNotifyProperties properties;
@Transactional @Transactional
public void write(MessageCode code, Map<String, Object> params) { public List<MessageRequest> write(MessageCode code, Map<String, Object> params) {
String role = properties.getTargetRole(); String role = properties.getTargetRole();
List<UserInfo> staffs = swingStaffRepository.findByRole(role); List<UserInfo> staffs = swingStaffRepository.findByRole(role);
if (staffs == null || staffs.isEmpty()) { if (staffs == null || staffs.isEmpty()) {
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name()); log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
return; return new ArrayList<>();
} }
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null); MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
if (template == null) { if (template == null) {
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name()); log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
return; return new ArrayList<>();
} }
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) { if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}", log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
code.name(), template.getEnableMessenger()); code.name(), template.getEnableMessenger());
return; return new ArrayList<>();
} }
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds(); SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
EmployeeIdPolicy policy = properties.getNonEmployeePolicy(); EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
List<MessageRequest> created = new ArrayList<>();
for (UserInfo staff : staffs) { for (UserInfo staff : staffs) {
try { try {
writeOne(code, template, params, staff, umsIds, policy); MessageRequest saved = writeOne(code, template, params, staff, umsIds, policy);
if (saved != null) {
created.add(saved);
}
} catch (Exception e) { } catch (Exception e) {
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e); log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
} }
} }
return created;
} }
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params, private MessageRequest writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds, UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
EmployeeIdPolicy policy) { EmployeeIdPolicy policy) {
@@ -83,10 +89,10 @@ public class SwingMessageWriter {
if (!properties.isEmployeeId(messengerId)) { if (!properties.isEmployeeId(messengerId)) {
switch (policy) { switch (policy) {
case REJECT: case REJECT:
return; return null;
case REJECT_LOG: case REJECT_LOG:
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name()); log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
return; return null;
case SKIP: case SKIP:
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}", log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
STATUS_SKIPPED, messengerId, code.name()); STATUS_SKIPPED, messengerId, code.name());
@@ -124,8 +130,9 @@ public class SwingMessageWriter {
request.setRequestStatus(requestStatus); request.setRequestStatus(requestStatus);
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다. // email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
messageRequestRepository.save(request); MessageRequest saved = messageRequestRepository.save(request);
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus); log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
return saved;
} }
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */ /** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
+185
View File
@@ -27728,6 +27728,191 @@ input[type=checkbox]:checked + .custom-checkbox {
} }
} }
.nt-page,
.nt-page h1,
.nt-page h2,
.nt-page h3 {
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.nt-page {
margin: 44px 0 60px;
}
.nt-page .nt-header {
margin-bottom: 24px;
}
.nt-page .nt-header h1 {
margin: 0 0 8px;
font-size: 32px;
font-weight: 700;
color: #1A1A2E;
}
.nt-page .nt-desc {
margin: 0;
font-size: 14px;
color: #64748B;
line-height: 1.5;
}
.nt-page .nt-hint {
margin: 8px 0 0;
font-size: 12px;
color: #64748B;
}
.nt-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.nt-columns {
grid-template-columns: 1fr;
}
}
.nt-catalog {
display: flex;
flex-direction: column;
gap: 16px;
}
.nt-category-card {
padding: 24px;
}
.nt-category-card .nt-category-title {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-target {
position: relative;
padding: 10px 0;
border-bottom: 1px solid #E2E8F0;
}
.nt-target:last-child {
border-bottom: none;
}
.nt-target .nt-risk-badge {
margin-left: 8px;
}
.nt-params {
margin: 12px 0 4px 26px;
padding: 12px 16px;
background: #F8FAFC;
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.nt-params .nt-param-field {
margin: 0;
min-width: 180px;
flex: 1 1 200px;
}
.nt-params .nt-param-field label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #64748B;
}
.nt-side {
position: sticky;
top: 96px;
}
.nt-recipient-card {
padding: 24px;
}
.nt-recipient-card h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-recipient-card .btn-block {
width: 100%;
margin-top: 8px;
}
.nt-history-card {
margin-top: 24px;
padding: 24px;
}
.nt-history-card h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: #1A1A2E;
}
.nt-table-wrap {
margin-top: 12px;
overflow-x: auto;
}
.nt-table-wrap table {
width: 100%;
border-collapse: collapse;
border: 1px solid #E2E8F0;
}
.nt-table-wrap th, .nt-table-wrap td {
padding: 10px 14px;
border: 1px solid #E2E8F0;
text-align: left;
font-size: 14px;
vertical-align: top;
white-space: nowrap;
}
.nt-table-wrap thead th {
background: #EFF6FF;
font-weight: 600;
color: #1A1A2E;
}
.nt-table-wrap tbody td {
color: #64748B;
}
.nt-message-cell {
min-width: 280px;
white-space: normal;
}
.nt-message-textarea {
width: 100%;
min-height: 64px;
padding: 8px 10px;
border: 1px solid #E2E8F0;
border-radius: 6px;
background: #F8FAFC;
color: #1A1A2E;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
line-height: 1.5;
resize: vertical;
}
.nt-message-textarea:focus {
outline: none;
border-color: #0049b4;
}
.nt-row-note {
margin-top: 4px;
font-size: 12px;
}
.nt-row-warning {
color: #FF6B6B;
}
.nt-row-error {
color: #FF6B6B;
font-weight: 500;
}
.d-none { .d-none {
display: none !important; display: none !important;
} }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,197 @@
(function () {
'use strict';
var form = document.getElementById('ntForm');
if (!form) return;
var SEND_URL = '/djb/notitest/send';
var STATUS_URL = '/djb/notitest/status';
var POLL_INTERVAL_MS = 3000;
var radios = Array.prototype.slice.call(document.querySelectorAll('.nt-target-radio'));
var sendBtn = document.getElementById('ntSendBtn');
var hint = document.getElementById('ntSelectedHint');
var historyBody = document.getElementById('ntHistoryBody');
var emptyRow = document.getElementById('ntHistoryEmpty');
var STATUS_LABEL = {
PENDING: '대기', PROCESSING: '처리중', SENT: '발송완료', FAILED: '실패', SKIPPED: '스킵'
};
var STATUS_CLASS = {
PENDING: 'status-inactive', PROCESSING: 'status-processing',
SENT: 'status-completed', FAILED: 'status-pending', SKIPPED: 'status-inactive'
};
var CATEGORY_LABEL = { EMAIL: '이메일', SMS: 'SMS', MESSENGER: '메신저' };
function getCsrfToken() {
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
var meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
function fetchJson(url, options) {
options = options || {};
var headers = Object.assign({}, options.headers || {});
var method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
headers['X-XSRF-TOKEN'] = getCsrfToken();
}
return fetch(url, Object.assign({ credentials: 'same-origin' }, options, { headers: headers }));
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function aggregateStatus(entry) {
var ids = entry.requestIds || [];
if (ids.length === 0) {
return entry.errorMessage ? 'FAILED' : 'PENDING';
}
var statuses = ids.map(function (id) { return (entry.statuses || {})[id]; });
if (statuses.indexOf('FAILED') >= 0) return 'FAILED';
if (statuses.every(function (s) { return s === 'SENT' || s === 'SKIPPED'; })) return 'SENT';
if (statuses.indexOf('PROCESSING') >= 0) return 'PROCESSING';
return 'PENDING';
}
function renderRow(entry) {
var status = aggregateStatus(entry);
var label = STATUS_LABEL[status] || status;
var cls = STATUS_CLASS[status] || 'status-inactive';
var note = '';
if (entry.warning) {
note += '<div class="nt-row-note nt-row-warning">' + escapeHtml(entry.warning) + '</div>';
}
if (entry.errorMessage) {
note += '<div class="nt-row-note nt-row-error">' + escapeHtml(entry.errorMessage) + '</div>';
}
var messageText = (entry.messages || []).join('\n');
return '' +
'<tr data-entry-id="' + escapeHtml(entry.entryId) + '">' +
'<td>' + escapeHtml(entry.requestedAt) + '</td>' +
'<td>' + escapeHtml(CATEGORY_LABEL[entry.category] || entry.category) + '</td>' +
'<td>' + escapeHtml(entry.targetLabel) + note + '</td>' +
'<td>' + escapeHtml(entry.recipient) + '</td>' +
'<td class="nt-message-cell"><textarea class="nt-message-textarea" readonly rows="3">' +
escapeHtml(messageText) + '</textarea></td>' +
'<td><span class="status-badge-header ' + cls + '">' + label + '</span></td>' +
'</tr>';
}
function renderHistory(list) {
if (!list || list.length === 0) {
historyBody.innerHTML = '';
if (emptyRow) historyBody.appendChild(emptyRow);
return;
}
historyBody.innerHTML = list.map(renderRow).join('');
}
function prependEntry(entry) {
if (emptyRow && emptyRow.parentNode === historyBody) {
historyBody.removeChild(emptyRow);
}
historyBody.insertAdjacentHTML('afterbegin', renderRow(entry));
}
function selectedRadio() {
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i];
}
return null;
}
function paramsPanelFor(radio) {
return document.getElementById('params-' + radio.id.replace('target-', ''));
}
function onRadioChange() {
document.querySelectorAll('.nt-params').forEach(function (el) { el.style.display = 'none'; });
var radio = selectedRadio();
if (!radio) {
sendBtn.disabled = true;
hint.textContent = '왼쪽에서 발송할 항목을 선택하세요.';
return;
}
var panel = paramsPanelFor(radio);
if (panel) panel.style.display = '';
sendBtn.disabled = false;
var strategy = radio.getAttribute('data-strategy');
if (strategy === 'BROADCAST') {
hint.textContent = '이 항목은 테스트 대상을 임의로 지정할 수 없으며, 실제 담당 내부직원 전원에게 발송됩니다.';
} else if (strategy === 'DIRECT') {
hint.textContent = '이 항목은 실서비스 템플릿을 사용합니다. 추가 수신자가 등록되어 있으면 함께 발송될 수 있습니다.';
} else {
hint.textContent = '';
}
}
radios.forEach(function (r) { r.addEventListener('change', onRadioChange); });
form.addEventListener('submit', function (evt) {
evt.preventDefault();
var radio = selectedRadio();
if (!radio) return;
var strategy = radio.getAttribute('data-strategy');
if ((strategy === 'DIRECT' || strategy === 'BROADCAST') &&
!window.confirm('실제 수신자(추가 수신자 또는 담당 내부직원)에게 함께 발송될 수 있습니다. 계속하시겠습니까?')) {
return;
}
var params = new URLSearchParams();
params.set('messageCode', radio.value);
['recipientUsername', 'recipientEmail', 'recipientPhone', 'recipientMessengerId'].forEach(function (id) {
var el = document.getElementById(id);
if (el) params.set(id, el.value);
});
var panel = paramsPanelFor(radio);
if (panel) {
panel.querySelectorAll('input').forEach(function (input) {
params.set(input.name, input.value);
});
}
sendBtn.disabled = true;
fetchJson(SEND_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
})
.then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (body) {
throw new Error(body.error || ('발송 요청 실패: ' + res.status));
});
}
return res.json();
})
.then(function (entry) {
prependEntry(entry);
})
.catch(function (e) {
window.alert(e.message || '발송 중 오류가 발생했습니다.');
})
.then(function () {
sendBtn.disabled = false;
});
});
function poll() {
fetchJson(STATUS_URL)
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (list) { if (list) renderHistory(list); })
.catch(function () { /* 폴링 실패 시 다음 주기에 재시도 */ });
}
poll();
setInterval(poll, POLL_INTERVAL_MS);
})();
+1
View File
@@ -73,6 +73,7 @@
@use 'pages/api-statistics' as *; @use 'pages/api-statistics' as *;
@use 'pages/webhook' as *; @use 'pages/webhook' as *;
@use 'pages/api-status' as *; @use 'pages/api-status' as *;
@use 'pages/noti-test' as *;
// 6. Themes // 6. Themes
@use 'themes/dark' as *; @use 'themes/dark' as *;
@@ -0,0 +1,208 @@
// ============================================================
// 알림 발송 테스트 페이지 (/djb/notitest)
// PTL_PROPERTY 로 접근 제한되는 내부 도구 — GNB 미노출
// ============================================================
@use '../abstracts/variables' as *;
// 전역 타이포가 h1~h6 에 $font-family-heading(OneShinhan 우선)을 지정하므로
// 이 페이지는 범위 안에서 본문 폰트로 되돌린다(내부 도구 — 브랜드 서체 불필요).
.nt-page,
.nt-page h1,
.nt-page h2,
.nt-page h3 {
font-family: $font-family-primary;
}
.nt-page {
margin: 44px 0 60px;
.nt-header {
margin-bottom: 24px;
h1 {
margin: 0 0 8px;
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
}
}
.nt-desc {
margin: 0;
font-size: $font-size-sm;
color: $text-gray;
line-height: 1.5;
}
.nt-hint {
margin: 8px 0 0;
font-size: $font-size-xs;
color: $text-gray;
}
}
.nt-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: start;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.nt-catalog {
display: flex;
flex-direction: column;
gap: 16px;
}
.nt-category-card {
padding: 24px;
.nt-category-title {
margin: 0 0 12px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
}
.nt-target {
position: relative;
padding: 10px 0;
border-bottom: 1px solid $border-gray;
&:last-child {
border-bottom: none;
}
.nt-risk-badge {
margin-left: 8px;
}
}
.nt-params {
margin: 12px 0 4px 26px;
padding: 12px 16px;
background: $gray-bg;
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
.nt-param-field {
margin: 0;
min-width: 180px;
flex: 1 1 200px;
label {
display: block;
margin-bottom: 4px;
font-size: $font-size-xs;
color: $text-gray;
}
}
}
.nt-side {
position: sticky;
top: 96px;
}
.nt-recipient-card {
padding: 24px;
h2 {
margin: 0 0 8px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
.btn-block {
width: 100%;
margin-top: 8px;
}
}
.nt-history-card {
margin-top: 24px;
padding: 24px;
h2 {
margin: 0 0 4px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
}
.nt-table-wrap {
margin-top: 12px;
overflow-x: auto;
table {
width: 100%;
border-collapse: collapse;
border: 1px solid $border-gray;
}
th, td {
padding: 10px 14px;
border: 1px solid $border-gray;
text-align: left;
font-size: $font-size-sm;
vertical-align: top;
white-space: nowrap;
}
thead th {
background: $light-bg;
font-weight: $font-weight-semibold;
color: $text-dark;
}
tbody td {
color: $text-gray;
}
}
.nt-message-cell {
min-width: 280px;
white-space: normal;
}
.nt-message-textarea {
width: 100%;
min-height: 64px;
padding: 8px 10px;
border: 1px solid $border-gray;
border-radius: 6px;
background: $gray-bg;
color: $text-dark;
font-family: $font-family-primary;
font-size: $font-size-xs;
line-height: 1.5;
resize: vertical;
&:focus {
outline: none;
border-color: $primary-blue;
}
}
.nt-row-note {
margin-top: 4px;
font-size: $font-size-xs;
}
.nt-row-warning {
color: $accent-orange;
}
.nt-row-error {
color: $accent-orange;
font-weight: $font-weight-medium;
}
@@ -0,0 +1,110 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content nt-page">
<div class="nt-header">
<h1>알림 발송 테스트</h1>
<p class="nt-desc">기존 발송 인프라(PTL_MESSAGE_REQUEST → eapim-admin UmsDispatchJob)를 그대로 통과시켜
16종 알림 경로가 운영에서도 살아있는지 확인합니다. 접근 허용 계정만 사용할 수 있는 내부 도구입니다.</p>
</div>
<form id="ntForm" class="nt-form" autocomplete="off">
<div class="nt-columns">
<div class="nt-catalog">
<div class="card nt-category-card" th:each="entry : ${targetsByCategory}">
<h2 class="nt-category-title" th:text="${entry.key.label}">카테고리</h2>
<div class="form-check nt-target" th:each="t : ${entry.value}">
<input type="radio" name="messageCode" class="nt-target-radio"
th:id="'target-' + ${t.name()}"
th:value="${t.messageCode.name()}"
th:attr="data-strategy=${t.strategy.name()},data-category=${t.category.name()},data-label=${t.label}"/>
<label th:for="'target-' + ${t.name()}" th:text="${t.label}">항목명</label>
<span class="badge badge-sm badge-outline status-active nt-risk-badge"
th:if="${t.strategy.name() == 'DIRECT'}">실서비스 템플릿 사용</span>
<span class="badge badge-sm badge-outline status-pending nt-risk-badge"
th:if="${t.strategy.name() == 'BROADCAST'}">실제 담당자에게 발송</span>
<div class="nt-params" th:id="'params-' + ${t.name()}" style="display:none">
<div class="form-group nt-param-field" th:each="p : ${t.sampleParams}">
<label th:text="${p.key}" th:for="'param-' + ${t.name()} + '-' + ${p.key}">key</label>
<input type="text" class="form-control form-control-sm"
th:id="'param-' + ${t.name()} + '-' + ${p.key}"
th:name="'param_' + ${p.key}" th:value="${p.value}"/>
</div>
<p class="nt-hint" th:if="${#maps.isEmpty(t.sampleParams)}">추가 입력값이 필요하지 않습니다.</p>
</div>
</div>
</div>
</div>
<div class="nt-side">
<div class="card nt-recipient-card">
<h2>수신자</h2>
<p class="nt-desc">기본값은 로그인 계정 정보입니다. 메신저ID(행번)는 "관리자포탈 로그인·API 상태
감시·이상 징후 감시" 3종에만 사용됩니다.</p>
<div class="form-group">
<label for="recipientUsername">이름</label>
<input type="text" id="recipientUsername" name="recipientUsername" class="form-control"
th:value="${defaultUsername}"/>
</div>
<div class="form-group">
<label for="recipientEmail">이메일</label>
<input type="text" id="recipientEmail" name="recipientEmail" class="form-control"
th:value="${defaultEmail}"/>
</div>
<div class="form-group">
<label for="recipientPhone">전화번호</label>
<input type="text" id="recipientPhone" name="recipientPhone" class="form-control"
th:value="${defaultPhone}"/>
</div>
<div class="form-group">
<label for="recipientMessengerId">메신저ID(행번)</label>
<input type="text" id="recipientMessengerId" name="recipientMessengerId" class="form-control"
placeholder="예: 10012345"/>
</div>
<button type="submit" class="btn btn-primary btn-block" id="ntSendBtn" disabled="disabled">발송</button>
<p class="nt-hint" id="ntSelectedHint">왼쪽에서 발송할 항목을 선택하세요.</p>
</div>
</div>
</div>
</form>
<div class="card nt-history-card">
<h2>발송 이력</h2>
<p class="nt-desc">3초 간격으로 상태를 다시 조회합니다(PENDING → PROCESSING → SENT/FAILED).</p>
<div class="nt-table-wrap">
<table class="table">
<thead>
<tr>
<th>발송시각</th>
<th>채널</th>
<th>항목</th>
<th>수신자</th>
<th>메시지</th>
<th>상태</th>
</tr>
</thead>
<tbody id="ntHistoryBody">
<tr id="ntHistoryEmpty">
<td colspan="6">발송 이력이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script th:src="@{/js/djb/notitest.js}"></script>
</th:block>
</body>
</html>