Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/eapim-admin.git
eapim-admin CI / build (push) Has been cancelled

This commit is contained in:
eastargh
2026-06-22 13:40:24 +09:00
12 changed files with 949 additions and 2 deletions
+2 -1
View File
@@ -14,4 +14,5 @@ ApiSpecController_API스펙관리_APIGW_INSERT,DELETE
MessageTemplateManController_메세지템플릿관리_APIGW_INSERT,UPDATE,DELETE
PortalPartnershipManController_피드백/개선요청관리_APIGW_UNMASK,DELETE
PortalPropertyController_포탈 프로퍼티 관리_APIGW_UPDATE
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
PortalUserTermsManController_약관동의 이력_APIGW_UNMASK
MessageRequestManController_메세지발송내역_APIGW_UPDATE_STATUS
@@ -0,0 +1,211 @@
<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ page import="java.io.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
.mask-email { color:#1a73e8; font-weight:bold; }
.mask-phone { color:#d93025; font-weight:bold; }
.mask-digits { color:#9334e6; font-weight:bold; }
.msg-cell { display:inline-block; max-width:380px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; vertical-align:middle; }
</style>
<script language="javascript">
var url = '<c:url value="/onl/apim/messagerequest/messageRequestMan.json" />';
var url_view = '<c:url value="/onl/apim/messagerequest/messageRequestMan.view" />';
function escapeHtmlJs(s) { return $('<div>').text(s == null ? '' : s).html(); }
function escapeAttr(s) { return escapeHtmlJs(s).replace(/"/g, '&quot;'); }
// 제목: 없으면 messageCode, 마우스오버에 코드명(코드) 표기
function formatSubject(cellvalue, options, rowObject) {
var code = rowObject.messageCode || '';
var subject = (cellvalue && cellvalue !== '') ? cellvalue : '';
var title = rowObject.messageCodeName ? (rowObject.messageCodeName + ' (' + code + ')') : code;
if (subject) {
// 제목이 있으면 2줄: 제목 + 하단에 메세지코드
var codeLine = code ? '<br><span style="color:#888;font-size:0.85em;">' + escapeHtmlJs(code) + '</span>' : '';
return '<span title="' + escapeAttr(title) + '">' + escapeHtmlJs(subject) + codeLine + '</span>';
}
// 제목이 없으면 메세지코드만
return '<span title="' + escapeAttr(title) + '">' + escapeHtmlJs(code) + '</span>';
}
// 본문: 서버에서 마스킹+escape 된 안전 HTML 그대로 렌더
function formatMessage(cellvalue, options, rowObject) {
return '<span class="msg-cell">' + (rowObject.messageHtml || '') + '</span>';
}
// 수신자 정보: 휴대폰 / 이메일 / 메신저ID (모두 마스킹됨)
function formatReceiverInfo(cellvalue, options, rowObject) {
var parts = [];
if (rowObject.phone) parts.push(escapeHtmlJs(rowObject.phone));
if (rowObject.email) parts.push(escapeHtmlJs(rowObject.email));
if (rowObject.messengerId) parts.push(escapeHtmlJs(rowObject.messengerId));
return parts.join('<br>');
}
function formatRequestDate(cellvalue, options, rowObject) {
return '<span title="' + escapeAttr(rowObject.requestDateFull || '') + '">' + escapeHtmlJs(rowObject.requestDateShort || '') + '</span>';
}
function formatSentDate(cellvalue, options, rowObject) {
return '<span title="' + escapeAttr(rowObject.sentDateFull || '') + '">' + escapeHtmlJs(rowObject.sentDateShort || '') + '</span>';
}
function init() {
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'LIST_INIT_COMBO'},
success: function (json) {
new makeOptions("CODE", "NAME").setObj($("select[name=searchRequestStatus]"))
.setNoValueInclude(true).setNoValue("", "<%=localeMessage.getString("combo.all")%>")
.setData(json.statusList).rendering();
new makeOptions("CODE", "NAME").setObj($("select[name=searchMessageCode]"))
.setNoValueInclude(true).setNoValue("", "<%=localeMessage.getString("combo.all")%>")
.setData(json.messageCodeList).rendering();
putSelectFromParam();
// 콤보 로드 후 그리드 생성 (경합 방지)
buildGrid();
},
error: function (e) { alert(e.responseText); }
});
}
function buildGrid() {
$('#grid').jqGrid({
datatype: "json", mtype: 'POST', url: url,
postData: getSearchForJqgrid("cmd", "LIST"),
colNames: ['id', 'No.', '제목', '메세지 내용', '수신자명', '수신자 정보', '유형', '상태', '요청시간', '발송시간'],
colModel: [
{name: 'id', align: 'center', key: true, hidden: true},
{name: 'rowNum', align: 'center', width: 45, sortable: false},
{name: 'subject', align: 'left', width: 160, formatter: formatSubject},
{name: 'messageHtml', align: 'left', width: 300, formatter: formatMessage},
{name: 'username', align: 'center', width: 80},
{name: 'receiverInfo', align: 'left', width: 150, formatter: formatReceiverInfo},
{name: 'messageType', align: 'center', width: 60},
{name: 'requestStatus', align: 'center', width: 70},
{name: 'requestDateShort', align: 'center', width: 110, formatter: formatRequestDate},
{name: 'sentDateShort', align: 'center', width: 110, formatter: formatSentDate}
],
jsonReader: {repeatitems: false},
pager: $('#pager'),
page: '${param.page}',
rowNum: '${rmsDefaultRowNum}',
autoheight: true,
height: $("#container").height(),
autowidth: true,
viewrecords: true,
rowList: eval('[${rmsDefaultRowList}]'),
ondblClickRow: function (rowId) {
var rowData = $(this).getRowData(rowId);
var id = rowData['id'];
var url2 = url_view + '?cmd=DETAIL';
url2 += '&page=' + $(this).getGridParam("page");
url2 += '&returnUrl=' + getReturnUrl();
url2 += '&menuId=' + '${param.menuId}';
url2 += '&id=' + id;
goNav(url2);
},
loadComplete: function () {
var page = $(this).getGridParam('page');
var rowNum = $(this).getGridParam('rowNum');
var rows = $(this).getDataIDs();
var colModel = $(this).getGridParam("colModel");
for (var i = 0; i < rows.length; i++) {
var number = ((page - 1) * rowNum + i) + 1;
$(this).setCell(rows[i], 'rowNum', number);
}
// 서버 고정 정렬(요청일 역순) — 컬럼 정렬 비활성
for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, {sortable: false});
}
},
loadError: function (jqXHR, textStatus, errorThrown) {
var location = '<%=request.getContextPath()%>/';
comloadError(jqXHR, textStatus, errorThrown, location);
}
});
resizeJqGridWidth('grid', 'content_middle', '1000');
}
$(document).ready(function () {
init();
$("#btn_search").click(function () {
var postData = getSearchForJqgrid("cmd", "LIST");
$("#grid").setGridParam({url: url, postData: postData, page: 1}).trigger("reloadGrid");
});
$("select[name^=search]").change(function () { $("#btn_search").click(); });
$("input[name^=search]").keydown(function (key) {
if (key.keyCode == 13) { $("#btn_search").click(); }
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path"><li><a href="#">${rmsMenuPath}</a></li></ul>
</div><!-- end content_top -->
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R"><i class="material-icons">search</i> <%= localeMessage.getString("button.search") %></button>
</div>
<div class="title">메세지 발송 내역<span class="tooltip">발송 요청 이력을 조회합니다. 개인정보는 마스킹되어 표시됩니다.</span></div>
<form id="ajaxForm" onsubmit="return false;">
<table class="search_condition" cellspacing=0;>
<colgroup>
<col style="width:120px;"><col style="width:200px;">
<col style="width:120px;"><col style="width:200px;">
<col style="width:120px;"><col style="width:200px;">
</colgroup>
<tbody>
<tr>
<th>상태</th>
<td><div class="select-style"><select name="searchRequestStatus"></select></div></td>
<th>메세지 코드</th>
<td><div class="select-style"><select name="searchMessageCode"></select></div></td>
<th>수신자명</th>
<td><input type="text" name="searchRecipient" autocomplete="off"></td>
</tr>
<tr>
<th>유형</th>
<td>
<div class="select-style">
<select name="searchMessageType">
<option value="">전체</option>
<option value="EMAIL">EMAIL</option>
<option value="SMS">SMS</option>
</select>
</div>
</td>
<th>요청기간</th>
<td colspan="3">
<input type="date" name="searchFromDate"> ~ <input type="date" name="searchToDate">
</td>
</tr>
</tbody>
</table>
</form>
<table id="grid"></table>
<div id="pager"></div>
</div><!-- end content_middle -->
</div><!-- end right_box -->
</body>
</html>
@@ -0,0 +1,149 @@
<%@ page language="java" contentType="text/html; charset=utf-8" %>
<%@ page import="java.io.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring" %>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
%>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<jsp:include page="/jsp/common/include/script.jsp"/>
<style>
.mask-email { color:#1a73e8; font-weight:bold; }
.mask-phone { color:#d93025; font-weight:bold; }
.mask-digits { color:#9334e6; font-weight:bold; }
.msg-detail { white-space:pre-wrap; word-break:break-all; min-height:80px; line-height:1.6; }
</style>
<script language="javascript">
var url = '<c:url value="/onl/apim/messagerequest/messageRequestMan.json" />';
var url_view = '<c:url value="/onl/apim/messagerequest/messageRequestMan.view" />';
var key = '${param.id}';
function detail() {
if (!key) return;
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'DETAIL', id: key},
success: function (data) {
var codeText = (data.messageCodeName || '') + (data.messageCode ? ' (' + data.messageCode + ')' : '');
$('#messageCodeName').text(codeText);
$('#subject').text((data.subject && data.subject !== '') ? data.subject : (data.messageCode || ''));
$('#messageType').text(data.messageType || '');
$('#requestStatus').text(data.requestStatus || '');
$('#username').text(data.username || '');
$('#email').text(data.email || '');
$('#phone').text(data.phone || '');
$('#messengerId').text(data.messengerId || '');
$('#umsUid').text(data.umsUid || '');
$('#eaiInterfaceId').text(data.eaiInterfaceId || '');
$('#serviceId').text(data.serviceId || '');
$('#requestDate').text(data.requestDateFull || '');
$('#sentDate').text(data.sentDateFull || '');
// 서버에서 마스킹+escape 된 안전 HTML
$('#messageHtml').html(data.messageHtml || '');
// PENDING 건만 '실패 처리' 노출 (권한 제어는 buttonControl 이 담당)
if (data.requestStatus !== 'PENDING') {
$('#btn_fail').hide();
}
},
error: function (e) { alert(e.responseText); }
});
}
$(document).ready(function () {
var returnUrl = getReturnUrlForReturn();
buttonControl();
detail();
$('#btn_fail').click(function () {
if (!confirm('이 건을 FAILED(발송 실패) 상태로 변경하시겠습니까?')) return;
$.ajax({
type: "POST", url: url, dataType: "json",
data: {cmd: 'UPDATE_STATUS', id: key},
success: function () {
alert("변경되었습니다.");
detail();
},
error: function (e) { alert(e.responseText); }
});
});
$('#btn_previous').click(function () { goNav(returnUrl); });
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path"><li><a href="#">${rmsMenuPath}</a></li></ul>
</div>
<div class="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_fail" level="W" status="DETAIL" style="background-color:#dc3545;border-color:#dc3545;color:#fff;"><i class="material-icons">report</i> 실패 처리</button>
<button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button>
</div>
<div class="title" id="title">메세지 발송 내역 상세<span class="tooltip">개인정보는 마스킹되어 표시됩니다.</span></div>
<table class="table_row" cellspacing="0">
<colgroup>
<col style="width:12%"/><col style="width:38%"/>
<col style="width:12%"/><col style="width:38%"/>
</colgroup>
<tr>
<th>메세지 코드</th>
<td colspan="3"><span id="messageCodeName"></span></td>
</tr>
<tr>
<th>제목</th>
<td colspan="3"><span id="subject"></span></td>
</tr>
<tr>
<th>유형</th>
<td><span id="messageType"></span></td>
<th>상태</th>
<td><span id="requestStatus"></span></td>
</tr>
<tr>
<th>수신자명</th>
<td><span id="username"></span></td>
<th>메신저 ID</th>
<td><span id="messengerId"></span></td>
</tr>
<tr>
<th>이메일</th>
<td><span id="email"></span></td>
<th>휴대폰</th>
<td><span id="phone"></span></td>
</tr>
<tr>
<th>요청 시간</th>
<td><span id="requestDate"></span></td>
<th>발송 시간</th>
<td><span id="sentDate"></span></td>
</tr>
<tr>
<th>EAI 인터페이스 ID</th>
<td><span id="eaiInterfaceId"></span></td>
<th>서비스 ID</th>
<td><span id="serviceId"></span></td>
</tr>
<tr>
<th>UMS UID</th>
<td colspan="3"><span id="umsUid"></span></td>
</tr>
<tr>
<th>메세지 내용</th>
<td colspan="3"><div id="messageHtml" class="msg-detail"></div></td>
</tr>
</table>
</div>
</div>
</body>
</html>
@@ -0,0 +1,17 @@
package com.eactive.eai.rms.data.entity.onl.apim.messagerequest;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
/**
* 발송 내역(PTL_MESSAGE_REQUEST) 조회용 admin 전용 리포지토리.
*
* <p>elink-portal-common 의 {@code template.repository.MessageRequestRepository}(JpaRepository) 와
* <b>단순 클래스명이 겹치면 Spring Data 빈 이름이 충돌</b>하므로(둘 다 admin 컨텍스트에 스캔됨)
* 이름을 분리한다. 또한 QueryDSL {@code findAll(predicate, pageable)} 를 쓰기 위해
* {@link BaseRepository} 를 상속한다.</p>
*/
@EMSDataSource
public interface MessageRequestEmsRepository extends BaseRepository<MessageRequest, String> {
}
@@ -0,0 +1,65 @@
package com.eactive.eai.rms.data.entity.onl.apim.messagerequest;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.entity.QMessageRequest;
import com.eactive.eai.rms.common.util.StringUtils;
import com.eactive.eai.rms.data.jpa.AbstractEMSDataSerivce;
import com.eactive.eai.rms.onl.common.exception.BizException;
import com.querydsl.core.BooleanBuilder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(transactionManager = "transactionManagerForEMS")
public class MessageRequestService
extends AbstractEMSDataSerivce<MessageRequest, String, MessageRequestEmsRepository> {
public Page<MessageRequest> findAll(Pageable pageable, MessageRequestUISearch search) {
QMessageRequest q = QMessageRequest.messageRequest;
BooleanBuilder predicate = new BooleanBuilder();
if (StringUtils.isNotBlank(search.getSearchRequestStatus())) {
predicate.and(q.requestStatus.eq(search.getSearchRequestStatus()));
}
if (StringUtils.isNotBlank(search.getSearchMessageCode())) {
MessageCode.findServiceId(search.getSearchMessageCode())
.ifPresent(code -> predicate.and(q.messageCode.eq(code)));
}
if (StringUtils.isNotBlank(search.getSearchMessageType())) {
predicate.and(q.messageType.eq(search.getSearchMessageType()));
}
if (StringUtils.isNotBlank(search.getSearchRecipient())) {
predicate.and(q.username.containsIgnoreCase(search.getSearchRecipient()));
}
// requestDate 는 yyyyMMddHHmmss 14자리 문자열로 저장(LocalDateTimeToStringConverter14)되므로
// 고정폭 비교로 기간 필터가 동작한다.
if (search.getSearchFromDate() != null) {
predicate.and(q.requestDate.goe(search.getSearchFromDate().atStartOfDay()));
}
if (search.getSearchToDate() != null) {
predicate.and(q.requestDate.loe(search.getSearchToDate().atTime(23, 59, 59)));
}
// 발송요청 역순 고정 (Pageable 에 정렬이 없을 때의 안전장치)
Sort base = Sort.by(Sort.Direction.DESC, "requestDate");
Sort sort = pageable.getSort().isSorted() ? pageable.getSort() : base;
Pageable fixed = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
return repository.findAll(predicate, fixed);
}
/** PENDING 상태인 건만 FAILED 로 전환한다. */
public void updateStatusToFailedIfPending(String id) {
MessageRequest entity = getById(id);
if (!"PENDING".equals(entity.getRequestStatus())) {
throw new BizException("PENDING 상태인 건만 변경할 수 있습니다.");
}
entity.setRequestStatus("FAILED");
save(entity);
}
}
@@ -0,0 +1,29 @@
package com.eactive.eai.rms.data.entity.onl.apim.messagerequest;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Data
public class MessageRequestUISearch {
/* 발송 상태 (PENDING/SENT/FAILED) */
private String searchRequestStatus;
/* 메세지 코드 (MessageCode enum name) */
private String searchMessageCode;
/* 메세지 유형 (EMAIL/SMS 등) */
private String searchMessageType;
/* 수신자 이름 (MessageRequest.username 평문 컬럼 대상, LIKE 검색) */
private String searchRecipient;
/* 요청일시 기간 검색 (email/phone 은 암호화 저장이라 검색 제외) */
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate searchFromDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate searchToDate;
}
@@ -0,0 +1,117 @@
package com.eactive.eai.rms.onl.apim.masking;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 메세지 본문/메신저ID 패턴 기반 마스킹 유틸 (발송 내역 화면 전용).
*
* <p>본문(message) 안에 섞여 있는 개인정보/인증정보를 패턴으로 찾아 마스킹하고,
* 패턴 종류별로 색상 강조 {@code <span>} 으로 감싼 <b>안전한 HTML</b> 을 만든다.
* 매칭되지 않은 영역과 마스킹 결과 모두 HTML escape 하므로 본문에 스크립트가 있어도 무력화된다.</p>
*
* <pre>
* 이메일 test@gmail.com → &lt;span class="mask-email"&gt;**st@gmail.com&lt;/span&gt;
* 휴대폰(하이픈) 010-1234-5678 → &lt;span class="mask-phone"&gt;010-12**-56**&lt;/span&gt;
* 6자리+ 숫자 123456 → &lt;span class="mask-digits"&gt;123***&lt;/span&gt; (앞 3자리 제외)
* 메신저ID kakao_abcd → kakao_ab** (뒤 2자리)
* </pre>
*
* <p>실제 이메일/휴대폰 마스킹 규칙은 표준 구현
* {@link com.eactive.eai.common.util.MaskingUtils}(elink-online-core)에 위임한다.</p>
*/
public final class MessagePatternMaskingUtils {
private MessagePatternMaskingUtils() {}
private static final int DIGITS_KEEP_HEAD = 3;
/**
* 매칭 우선순위(이메일 &gt; 하이픈 휴대폰 &gt; 6자리+ 숫자)를 단일 패스로 보장하는 결합 정규식.
* 휴대폰 하이픈 형식이 "6자리 이상 숫자" 규칙에 이중 적용되지 않도록 한 Matcher 로 순회한다.
*/
private static final Pattern COMBINED = Pattern.compile(
"(?<email>[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})"
+ "|(?<phone>01[016789]-\\d{3,4}-\\d{4})"
+ "|(?<digits>\\d{6,})");
/** 본문 → escape + 패턴별 마스킹/색상강조 span 이 적용된 안전 HTML 반환. */
public static String maskAndHighlight(String raw) {
if (raw == null || raw.isEmpty()) {
return "";
}
Matcher m = COMBINED.matcher(raw);
StringBuilder sb = new StringBuilder();
int last = 0;
while (m.find()) {
sb.append(escapeHtml(raw.substring(last, m.start())));
String token = m.group();
if (m.group("email") != null) {
sb.append(span("mask-email", com.eactive.eai.common.util.MaskingUtils.maskEmail(token)));
} else if (m.group("phone") != null) {
sb.append(span("mask-phone", com.eactive.eai.common.util.MaskingUtils.maskPhoneNumber(token)));
} else {
sb.append(span("mask-digits", maskKeepHead(token, DIGITS_KEEP_HEAD)));
}
last = m.end();
}
sb.append(escapeHtml(raw.substring(last)));
return sb.toString();
}
/** 메신저 ID 마스킹: 뒤 2자리(2자 이하는 전체) 마스킹. */
public static String maskMessengerId(String id) {
if (id == null || id.isEmpty()) {
return id;
}
if (id.length() <= 2) {
return repeat('*', id.length());
}
return id.substring(0, id.length() - 2) + "**";
}
/** 앞 head 자리만 남기고 나머지를 '*' 로 마스킹. */
private static String maskKeepHead(String digits, int head) {
if (digits.length() <= head) {
return digits;
}
return digits.substring(0, head) + repeat('*', digits.length() - head);
}
private static String span(String cls, String inner) {
return "<span class=\"" + cls + "\">" + escapeHtml(inner) + "</span>";
}
/**
* 최소 HTML escape. {@code < > & " '} 만 변환하고 한글 등 비-ASCII 문자는 보존한다.
* (commons-lang {@code escapeHtml} 은 0x7F 초과 문자를 {@code &#NNN;} 로 변환해 한글이 깨지므로 직접 구현)
*/
private static String escapeHtml(String s) {
if (s == null || s.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '&': sb.append("&amp;"); break;
case '<': sb.append("&lt;"); break;
case '>': sb.append("&gt;"); break;
case '"': sb.append("&quot;"); break;
case '\'': sb.append("&#39;"); break;
default: sb.append(c);
}
}
return sb.toString();
}
private static String repeat(char c, int n) {
if (n <= 0) {
return "";
}
char[] a = new char[n];
Arrays.fill(a, c);
return new String(a);
}
}
@@ -0,0 +1,67 @@
package com.eactive.eai.rms.onl.apim.messagerequest;
import com.eactive.eai.rms.common.base.BaseAnnotationController;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.apim.messagerequest.MessageRequestUISearch;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.HashMap;
import java.util.Map;
/**
* 발송 내역(PTL_MESSAGE_REQUEST) 조회 화면 컨트롤러.
*
* <p>조회 전용 + PENDING→FAILED 상태 전환만 제공한다. (등록/수정/삭제 없음)</p>
*/
@Controller
@RequiredArgsConstructor
public class MessageRequestManController extends BaseAnnotationController {
private final MessageRequestManService messageRequestManService;
@GetMapping(value = "/onl/apim/messagerequest/messageRequestMan.view")
public void view() {
// 목록 view
}
@GetMapping(value = "/onl/apim/messagerequest/messageRequestMan.view", params = "cmd=DETAIL")
public String detailView() {
return "/onl/apim/messagerequest/messageRequestManDetail";
}
@PostMapping(value = "/onl/apim/messagerequest/messageRequestMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<MessageRequestUI>> selectList(
@SortDefault(sort = "requestDate", direction = Sort.Direction.DESC) Pageable pageable,
MessageRequestUISearch search) {
Page<MessageRequestUI> page = messageRequestManService.selectList(pageable, search);
return ResponseEntity.ok(new GridResponse<>(page));
}
@PostMapping(value = "/onl/apim/messagerequest/messageRequestMan.json", params = "cmd=LIST_INIT_COMBO")
public ResponseEntity<Map<String, Object>> initCombo() {
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("statusList", messageRequestManService.statusCombo());
resultMap.put("messageCodeList", messageRequestManService.messageCodeCombo());
return ResponseEntity.ok(resultMap);
}
@PostMapping(value = "/onl/apim/messagerequest/messageRequestMan.json", params = "cmd=DETAIL")
public ResponseEntity<MessageRequestUI> selectDetail(String id) {
return ResponseEntity.ok(messageRequestManService.selectDetail(id));
}
/** PENDING 건을 FAILED 로 전환 (감사 포인트: UPDATE_STATUS). */
@PostMapping(value = "/onl/apim/messagerequest/messageRequestMan.json", params = "cmd=UPDATE_STATUS")
public ResponseEntity<Void> updateStatus(String id) {
messageRequestManService.updateStatusToFailed(id);
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,106 @@
package com.eactive.eai.rms.onl.apim.messagerequest;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.eai.rms.common.base.BaseService;
import com.eactive.eai.rms.common.combo.ComboVo;
import com.eactive.eai.rms.data.entity.onl.apim.messagerequest.MessageRequestUISearch;
import com.eactive.eai.rms.onl.apim.masking.MaskingUtils;
import com.eactive.eai.rms.onl.apim.masking.MessagePatternMaskingUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
@Service
@Transactional(transactionManager = "transactionManagerForEMS")
public class MessageRequestManService extends BaseService {
// KST 한글 요일 포맷 (표시용 / 툴팁용)
private static final DateTimeFormatter FMT_SHORT = DateTimeFormatter.ofPattern("MM-dd(E) HH:mm", Locale.KOREA);
private static final DateTimeFormatter FMT_FULL = DateTimeFormatter.ofPattern("yyyy-MM-dd (EEE) HH:mm:ss", Locale.KOREA);
private final com.eactive.eai.rms.data.entity.onl.apim.messagerequest.MessageRequestService messageRequestService;
@Autowired
public MessageRequestManService(com.eactive.eai.rms.data.entity.onl.apim.messagerequest.MessageRequestService messageRequestService) {
this.messageRequestService = messageRequestService;
}
public Page<MessageRequestUI> selectList(Pageable pageable, MessageRequestUISearch search) {
return messageRequestService.findAll(pageable, search).map(this::convertToUI);
}
public MessageRequestUI selectDetail(String id) {
return convertToUI(messageRequestService.getById(id));
}
/** PENDING → FAILED 전환. */
public void updateStatusToFailed(String id) {
messageRequestService.updateStatusToFailedIfPending(id);
}
/** 엔티티 → 마스킹/포맷 적용된 UI 변환 (목록·상세 공통). */
private MessageRequestUI convertToUI(MessageRequest e) {
MessageRequestUI ui = new MessageRequestUI();
ui.setId(e.getId());
ui.setMessageCode(e.getMessageCode() != null ? e.getMessageCode().name() : null);
ui.setMessageCodeName(e.getMessageCode() != null ? e.getMessageCode().getDescription() : null);
ui.setSubject(e.getSubject());
ui.setMessageType(e.getMessageType());
// 본문: 패턴 마스킹 + 색상강조 안전 HTML
ui.setMessageHtml(MessagePatternMaskingUtils.maskAndHighlight(e.getMessage()));
// 수신자 마스킹 (email/phone 은 @Convert 로 이미 복호화된 평문)
ui.setUsername(MaskingUtils.maskName(e.getUsername()));
ui.setEmail(MaskingUtils.maskEmailId(e.getEmail()));
ui.setPhone(MaskingUtils.maskPhoneNumber(e.getPhone()));
ui.setMessengerId(MessagePatternMaskingUtils.maskMessengerId(e.getMessengerId()));
ui.setUmsUid(e.getUmsUid());
ui.setRequestStatus(e.getRequestStatus());
ui.setEaiInterfaceId(e.getEaiInterfaceId());
ui.setServiceId(e.getServiceId());
ui.setRequestDateShort(fmt(e.getRequestDate(), FMT_SHORT));
ui.setRequestDateFull(fmt(e.getRequestDate(), FMT_FULL));
ui.setSentDateShort(fmt(e.getSentDate(), FMT_SHORT));
ui.setSentDateFull(fmt(e.getSentDate(), FMT_FULL));
return ui;
}
private static String fmt(LocalDateTime t, DateTimeFormatter f) {
return t == null ? "" : t.format(f);
}
/** 상태 검색 콤보 (PENDING/SENT/FAILED). */
public List<ComboVo> statusCombo() {
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED"})
.map(s -> {
ComboVo v = new ComboVo();
v.setCode(s);
v.setCodename(s);
return v;
}).collect(Collectors.toList());
}
/** 메세지 코드 검색 콤보 (enum name → 한글 설명). */
public List<ComboVo> messageCodeCombo() {
return Arrays.stream(MessageCode.values())
.map(c -> {
ComboVo v = new ComboVo();
v.setCode(c.name());
v.setCodename(c.getDescription());
return v;
}).collect(Collectors.toList());
}
}
@@ -0,0 +1,42 @@
package com.eactive.eai.rms.onl.apim.messagerequest;
import lombok.Data;
/**
* 발송 내역(PTL_MESSAGE_REQUEST) 화면 응답 DTO.
*
* <p>개인정보는 모두 마스킹된 값으로 채워지며, 본문(messageHtml)은 패턴 마스킹 + 색상강조가 적용된
* 안전한 HTML 이다. 날짜는 표시용(short)/툴팁용(full) 두 가지 포맷 문자열로 제공한다.</p>
*/
@Data
public class MessageRequestUI {
private String id;
/* 제목/코드 — subject 가 없으면 화면에서 messageCode 로 대체 표기 */
private String messageCode; // MessageCode enum name (제목 마우스오버/대체용)
private String messageCodeName; // MessageCode.getDescription() 한글명
private String subject;
private String messageType; // EMAIL/SMS 등
/* 본문 — 패턴 마스킹 + span 색상강조된 안전 HTML */
private String messageHtml;
/* 수신자 (마스킹) */
private String username;
private String email;
private String phone;
private String messengerId;
/* 발송 메타 */
private String umsUid;
private String requestStatus; // PENDING/SENT/FAILED
private String eaiInterfaceId;
private String serviceId;
/* 요청/발송 시간 — 표시용(short) / 툴팁용(full) */
private String requestDateShort; // MM-dd(E) HH:mm
private String requestDateFull; // yyyy-MM-dd (EEE) HH:mm:ss
private String sentDateShort;
private String sentDateFull;
}
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.agreements.service.AgreementsService;
import com.eactive.apim.portal.file.entity.FileDetail;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.rms.common.base.BaseService;
import com.eactive.eai.rms.data.entity.man.user.UserInfoService;
import com.eactive.eai.rms.data.entity.onl.apim.portalterms.PortalTermsService;
@@ -49,7 +50,14 @@ public class PortalTermsManService extends BaseService {
}
private String findName(String userId) {
return userInfoService.getById(userId).getUsername();
if (StringUtils.isBlank(userId)) {
return "";
}
// 등록자/수정자 ID로 사용자를 찾지 못해도(삭제된 사용자, 레거시 시드 데이터 등)
// 화면 전체가 깨지지 않도록 방어적으로 조회한다. (PortalFaq/PortalInquiry 컨벤션과 동일)
return userInfoService.findById(userId)
.map(UserInfo::getUsername)
.orElse("Unknown");
}
public Page<PortalTermsUI> selectList(Pageable pageable, String searchAgreementsType) {
@@ -0,0 +1,135 @@
package com.eactive.eai.rms.onl.apim.masking;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* 메세지 본문/메신저ID 패턴 마스킹 + 색상강조 + XSS escape 검증.
*/
class MessagePatternMaskingUtilsTest {
// --- 이메일 ---
@Test
@DisplayName("본문 이메일 — mask-email span + 표준 이메일 마스킹")
void maskAndHighlight_email() {
assertEquals("<span class=\"mask-email\">**st1**@test.com</span>",
MessagePatternMaskingUtils.maskAndHighlight("test123@test.com"));
}
// --- 휴대폰(하이픈) ---
@Test
@DisplayName("본문 휴대폰(하이픈) — mask-phone span + 세그먼트별 뒤2자리 마스킹")
void maskAndHighlight_phoneHyphen() {
assertEquals("<span class=\"mask-phone\">010-12**-56**</span>",
MessagePatternMaskingUtils.maskAndHighlight("010-1234-5678"));
}
// --- 6자리 이상 숫자 (앞 3자리 제외 전체 마스킹) ---
@Test
@DisplayName("본문 6자리 숫자 — 앞 3자리 제외 전체 마스킹(mask-digits)")
void maskAndHighlight_digits6() {
assertEquals("<span class=\"mask-digits\">123***</span>",
MessagePatternMaskingUtils.maskAndHighlight("123456"));
}
@Test
@DisplayName("본문 하이픈 없는 휴대폰(11자리) — 6자리+ 숫자 규칙 적용")
void maskAndHighlight_phoneNoHyphen() {
assertEquals("<span class=\"mask-digits\">010********</span>",
MessagePatternMaskingUtils.maskAndHighlight("01012345678"));
}
@Test
@DisplayName("5자리 이하 숫자는 마스킹하지 않음")
void maskAndHighlight_digitsUnder6() {
assertEquals("12345", MessagePatternMaskingUtils.maskAndHighlight("12345"));
}
// --- 우선순위 / 혼합 ---
@Test
@DisplayName("하이픈 휴대폰이 숫자 규칙에 이중 적용되지 않음 (phone 우선)")
void maskAndHighlight_priority() {
String result = MessagePatternMaskingUtils.maskAndHighlight("010-1234-5678");
assertTrue(result.contains("mask-phone"));
assertFalse(result.contains("mask-digits"));
}
@Test
@DisplayName("텍스트 + 이메일/휴대폰/숫자 혼합 — 각 패턴별 span 생성, 일반 텍스트는 보존")
void maskAndHighlight_mixed() {
String result = MessagePatternMaskingUtils.maskAndHighlight(
"인증요청 test123@test.com 전화 010-1234-5678 코드 123456 끝");
assertTrue(result.contains("<span class=\"mask-email\">**st1**@test.com</span>"));
assertTrue(result.contains("<span class=\"mask-phone\">010-12**-56**</span>"));
assertTrue(result.contains("<span class=\"mask-digits\">123***</span>"));
assertTrue(result.contains("인증요청"));
assertTrue(result.contains(""));
// 원문 일부가 그대로 노출되지 않아야 함
assertFalse(result.contains("test123@"));
assertFalse(result.contains("010-1234-5678"));
assertFalse(result.contains("123456"));
}
// --- XSS / HTML escape ---
@Test
@DisplayName("매칭 외 영역의 HTML 태그는 escape 되어 무력화")
void maskAndHighlight_escapesHtml() {
assertEquals("&lt;b&gt;hi&lt;/b&gt;",
MessagePatternMaskingUtils.maskAndHighlight("<b>hi</b>"));
}
@Test
@DisplayName("script 태그 escape")
void maskAndHighlight_escapesScript() {
String result = MessagePatternMaskingUtils.maskAndHighlight("<script>alert(1)</script>");
assertFalse(result.contains("<script>"));
assertTrue(result.contains("&lt;script&gt;"));
}
@Test
@DisplayName("앰퍼샌드 escape")
void maskAndHighlight_escapesAmp() {
assertEquals("a&amp;b", MessagePatternMaskingUtils.maskAndHighlight("a&b"));
}
// --- null / empty ---
@Test
@DisplayName("null·빈문자 본문은 빈문자 반환")
void maskAndHighlight_nullEmpty() {
assertEquals("", MessagePatternMaskingUtils.maskAndHighlight(null));
assertEquals("", MessagePatternMaskingUtils.maskAndHighlight(""));
}
// --- 메신저 ID ---
@Test
@DisplayName("메신저ID — 뒤 2자리 마스킹")
void maskMessengerId_normal() {
assertEquals("kakao_ab**", MessagePatternMaskingUtils.maskMessengerId("kakao_abcd"));
}
@Test
@DisplayName("메신저ID — 2자 이하 전체 마스킹")
void maskMessengerId_short() {
assertEquals("**", MessagePatternMaskingUtils.maskMessengerId("ab"));
assertEquals("*", MessagePatternMaskingUtils.maskMessengerId("a"));
}
@Test
@DisplayName("메신저ID — null·빈문자 방어")
void maskMessengerId_nullEmpty() {
assertNull(MessagePatternMaskingUtils.maskMessengerId(null));
assertEquals("", MessagePatternMaskingUtils.maskMessengerId(""));
}
}