알림 테스트 도구 추가:
- SCSS 스타일 및 HTML 템플릿 구현 - 알림 발송/상태 처리 자바 로직 추가 - 클라이언트 JS: 동적 데이터 바인딩, 발송/이력 관리
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user