diff --git a/WebContent/WEB-INF/springapp-servlet.xml b/WebContent/WEB-INF/springapp-servlet.xml index bdcc5db..f3a55c6 100644 --- a/WebContent/WEB-INF/springapp-servlet.xml +++ b/WebContent/WEB-INF/springapp-servlet.xml @@ -140,4 +140,5 @@ + diff --git a/WebContent/WEB-INF/web.xml b/WebContent/WEB-INF/web.xml index 3c6941b..1416b0a 100644 --- a/WebContent/WEB-INF/web.xml +++ b/WebContent/WEB-INF/web.xml @@ -9,7 +9,7 @@ contextConfigLocation /WEB-INF/applicationContext.xml - + CrossScriptingFilter com.eactive.eai.rms.common.filter.CrossScriptingFilter @@ -18,7 +18,7 @@ CrossScriptingFilter /* - + encodingFilter org.springframework.web.filter.CharacterEncodingFilter diff --git a/WebContent/addon/summernote/summernote-custom.css b/WebContent/addon/summernote/summernote-custom.css new file mode 100644 index 0000000..b84bb22 --- /dev/null +++ b/WebContent/addon/summernote/summernote-custom.css @@ -0,0 +1,187 @@ +/** + * Summernote Custom Styles + * - 이미지 리사이징, 레이아웃, 다이얼로그 스타일 등 + */ + +/* 에디터 이미지 스타일 */ +.note-editable img { + max-width: 100%; + cursor: pointer; +} +.note-editable img.note-image-resizing { + outline: 2px solid #007bff; +} + +/* 이미지 리사이즈 핸들 스타일 */ +.note-control-sizing { + display: block !important; +} +.note-control-holder { + position: relative; +} +.note-control-holder .note-control-sizing { + position: absolute; + width: 10px; + height: 10px; + border: 1px solid #333; + background-color: #fff; +} + +/* 코드뷰 모드 가로 사이즈 고정 */ +.note-editor.note-frame { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + overflow: hidden !important; + display: block !important; +} +.note-editor .note-editing-area { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + overflow: hidden !important; +} +.note-editor .note-codable { + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + box-sizing: border-box !important; + resize: vertical !important; + white-space: pre-wrap !important; + word-wrap: break-word !important; + word-break: break-all !important; + overflow-x: hidden !important; + overflow-y: auto !important; + overflow-wrap: break-word !important; +} + +/* Summernote 부모 컨테이너 오버플로우 방지 */ +.table_row td .note-editor, +td .note-editor, +div .note-editor { + width: 100% !important; + max-width: 100% !important; +} + +/* 테이블 레이아웃 고정 - Summernote 포함 테이블 */ +.table_row { + table-layout: fixed !important; + width: 100% !important; +} + +/* Summernote가 있는 td 스타일 */ +td:has(.note-editor) { + overflow: hidden !important; + word-break: break-all !important; +} + +/* IE/구형 브라우저 대응 - summernote 래퍼 클래스 */ +.summernote-wrapper { + width: 100% !important; + max-width: 100% !important; + overflow: hidden !important; +} + +/* 이미지 다이얼로그 버튼 overflow 수정 */ +.note-image-dialog .note-modal-footer { + padding: 10px 15px !important; + overflow: visible !important; +} +.note-image-dialog .note-modal-content { + overflow: visible !important; +} +.note-image-dialog .note-btn { + margin: 0 !important; +} + +/* 이미지 업로드 안내 문구 스타일 */ +.image-upload-guide { + margin: 0 0 15px 0; + padding: 10px 12px; + background: #e8f4fd; + border: 1px solid #b8daff; + border-radius: 4px; + font-size: 12px; + color: #004085; + display: flex; + align-items: center; +} +.image-upload-guide i.material-icons { + font-size: 16px; + margin-right: 6px; + color: #0056b3; +} +.image-upload-guide strong { + color: #0056b3; +} + +/* 색상 팔레트 UI overflow 수정 */ +.note-editor .note-toolbar { + overflow: visible !important; +} +.note-editor .note-btn-group { + overflow: visible !important; +} +.note-editor .note-toolbar .note-color.open .note-dropdown-menu { + min-width: 340px !important; + overflow: visible !important; + display: flex !important; + flex-wrap: nowrap !important; +} +.note-editor .note-holder { + overflow: visible !important; +} + +/* 색상 팔레트 2컬럼 레이아웃 유지 */ +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette { + display: inline-block !important; + vertical-align: top !important; + width: auto !important; + min-width: 160px !important; + margin: 0 5px !important; + flex-shrink: 0 !important; +} +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette .note-palette-title { + margin: 0 0 5px 0 !important; + font-size: 12px !important; + font-weight: bold !important; +} +.note-editor .note-toolbar .note-color .note-dropdown-menu .note-palette .note-color-palette { + width: auto !important; +} + +/* 드롭다운 메뉴 위치 조정 */ +.note-editor .note-dropdown-menu { + z-index: 1050 !important; +} + +/* 전체화면 모드 배경색 */ +.note-editor.note-frame.fullscreen, +.note-editor.note-airframe.fullscreen { + background-color: #fff !important; +} +.note-editor.note-frame.fullscreen .note-editable, +.note-editor.note-airframe.fullscreen .note-editable { + background-color: #fff !important; +} + +/* 이미지 다이얼로그 수정 */ +.note-modal .note-modal-content { + overflow: hidden !important; +} +.note-modal .note-modal-footer { + overflow: hidden !important; + height: auto !important; + padding: 10px 15px !important; +} +.note-modal .note-modal-footer .note-btn { + float: right !important; + margin: 0 !important; +} +.note-modal .note-modal-footer::after { + content: "" !important; + display: table !important; + clear: both !important; +} diff --git a/WebContent/addon/summernote/summernote-custom.js b/WebContent/addon/summernote/summernote-custom.js new file mode 100644 index 0000000..dd3ab5c --- /dev/null +++ b/WebContent/addon/summernote/summernote-custom.js @@ -0,0 +1,530 @@ +/** + * Summernote Custom JavaScript + * - 에디터 초기화, 이미지 처리, 붙여넣기 처리 등 + */ + +/** + * 이미지 다이얼로그에 안내 문구 추가 + * @param {jQuery} $modal - 모달 jQuery 객체 + */ +function addImageUploadGuide($modal) { + // 이미 안내 문구가 있으면 무시 + if ($modal.find('.image-upload-guide').length) return; + + var guideHtml = + '
' + + 'info' + + '지원 형식: JPG, PNG, GIF, BMP, WEBP / 최대 크기: 10MB' + + '
'; + + // summernote-lite 다이얼로그 구조: .note-modal > .note-modal-content > .note-modal-body + var $modalBody = $modal.find('.note-modal-body'); + if ($modalBody.length) { + $modalBody.prepend(guideHtml); + } else { + // 대체 위치: 모달 콘텐츠의 첫 번째 자식 앞에 삽입 + var $modalContent = $modal.find('.note-modal-content'); + if ($modalContent.length) { + $modalContent.find('.note-modal-header').after(guideHtml); + } + } +} + +/** + * Summernote 에디터 공통 초기화 함수 + * - 기본: data-uri(Base64) 방식으로 이미지 처리 + * - useFileUpload: true 옵션 시 서버 업로드 방식 사용 + * - 이미지 리사이징 지원 + * @param {string} selector - 에디터 selector (예: '#contents') + * @param {object} customOptions - 추가 옵션 + * - useFileUpload: true면 서버 업로드 방식, false면 data-uri 방식 (기본값: false) + * - uploadUrl: 이미지 업로드 API URL (useFileUpload: true 시 필수) + * - imageViewUrl: 이미지 서빙 API URL (useFileUpload: true 시 필수) + */ +function initSummernote(selector, customOptions) { + customOptions = customOptions || {}; + var useFileUpload = customOptions.useFileUpload || false; + + // 기본 설정 + var defaultOptions = { + placeholder: customOptions.placeholder || '여기에 내용을 입력하세요.', + height: customOptions.height || 300, + lang: 'ko-KR', + toolbar: customOptions.toolbar || [ + ['font', ['bold', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + ['insert', ['link', 'picture']], + ['view', ['fullscreen', 'codeview', 'help']] + ], + fontSizes: ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '36'], + // 이미지 팝오버 설정 - 리사이징 지원 + popover: { + image: [ + ['resize', ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone']], + ['float', ['floatLeft', 'floatRight', 'floatNone']], + ['remove', ['removeMedia']] + ] + }, + callbacks: { + onInit: function() { + var $editable = $(selector).next('.note-editor').find('.note-editable'); + $editable.addClass('editor-content'); + $editable.on('keydown', function(e) { + if (e.keyCode === 8) { + e.stopPropagation(); + } + }); + + // 이미지 스타일 변경 감지 - text-align을 올바른 CSS로 변환 + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.type === 'attributes' && mutation.attributeName === 'style') { + var $img = $(mutation.target); + if ($img.is('img')) { + var style = $img.attr('style') || ''; + // text-align: center -> display: block; margin: auto + if (style.indexOf('text-align: center') !== -1 || style.indexOf('text-align:center') !== -1) { + $img.css({ + 'text-align': '', + 'display': 'block', + 'margin-left': 'auto', + 'margin-right': 'auto', + 'float': 'none' + }); + } + // text-align: left -> float: left + else if (style.indexOf('text-align: left') !== -1 || style.indexOf('text-align:left') !== -1) { + $img.css({ + 'text-align': '', + 'display': '', + 'margin-left': '', + 'margin-right': '', + 'float': 'left' + }); + } + // text-align: right -> float: right + else if (style.indexOf('text-align: right') !== -1 || style.indexOf('text-align:right') !== -1) { + $img.css({ + 'text-align': '', + 'display': '', + 'margin-left': '', + 'margin-right': '', + 'float': 'right' + }); + } + } + } + }); + }); + observer.observe($editable[0], { + attributes: true, + subtree: true, + attributeFilter: ['style'] + }); + + // 커스텀 onInit 콜백 호출 + if (customOptions.callbacks && customOptions.callbacks.onInit) { + customOptions.callbacks.onInit.call(this); + } + }, + // 이미지 업로드 처리 + onImageUpload: function(files) { + for (var i = 0; i < files.length; i++) { + if (useFileUpload) { + // 서버 업로드 방식 + uploadImageToServer(files[i], $(selector), customOptions); + } else { + // data-uri 방식 (기본) + insertImageAsDataUri(files[i], $(selector)); + } + } + }, + // 붙여넣기 시 선택 영역 삭제 처리 + onPaste: function(e) { + var clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + // 현재 선택 영역이 있으면 삭제 (전체 선택 후 붙여넣기 대응) + var selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + // 선택 영역 삭제 + selection.deleteFromDocument(); + } + + // 이미지 파일이 있는지 확인 + var items = clipboardData.items; + if (items) { + for (var i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + var imageFile = items[i].getAsFile(); + if (imageFile) { + e.preventDefault(); + e.stopPropagation(); + if (useFileUpload) { + uploadImageToServer(imageFile, $(selector), customOptions); + } else { + insertImageAsDataUri(imageFile, $(selector)); + } + return; + } + } + } + } + // 이미지가 아닌 경우 summernote 기본 동작 + }, + // 다이얼로그 열릴 때 - 이미지 다이얼로그 안내 문구 추가 + onDialogShown: function() { + // 방법1: .note-modal.open.note-image-dialog (클래스가 같은 요소에 있는 경우) + var $imageModal = $('.note-modal.open.note-image-dialog'); + if ($imageModal.length) { + addImageUploadGuide($imageModal); + return; + } + // 방법2: .note-modal.open 안에 .note-image-dialog가 있는 경우 + var $openModal = $('.note-modal.open'); + if ($openModal.find('.note-image-dialog').length || $openModal.find('[class*="image"]').length) { + addImageUploadGuide($openModal); + } + }, + // 이미지 삭제 시 서버 파일도 삭제 (컨텍스트 메뉴 "사진 삭제" 클릭 시) + onMediaDelete: function(target) { + if (useFileUpload) { + var $img = $(target); + var fileId = $img.attr('data-file-id'); + if (fileId) { + // 서버에 파일 삭제 요청 + deleteImageFromServer(fileId, customOptions); + } + } + } + } + }; + + // 커스텀 옵션 병합 (callbacks는 위에서 별도 처리) + var mergedOptions = $.extend(true, {}, defaultOptions, customOptions); + + // callbacks는 기본 콜백을 유지하면서 커스텀 콜백 추가 + mergedOptions.callbacks = defaultOptions.callbacks; + if (customOptions.callbacks) { + // onInit은 위에서 이미 처리됨 + // 다른 커스텀 콜백 추가 + for (var key in customOptions.callbacks) { + if (key !== 'onInit' && key !== 'onImageUpload' && key !== 'onPaste') { + mergedOptions.callbacks[key] = customOptions.callbacks[key]; + } + } + } + + $(selector).summernote(mergedOptions); +} + +/** + * 이미지를 Base64 data-uri로 변환하여 에디터에 삽입 + * @param {File} file - 이미지 파일 + * @param {jQuery} $editor - summernote 에디터 jQuery 객체 + */ +function insertImageAsDataUri(file, $editor) { + // 파일 크기 제한 (10MB) + var maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + alert('이미지 크기는 10MB를 초과할 수 없습니다.'); + return; + } + + // 이미지 타입 확인 + if (!file.type.match(/image\/(jpeg|jpg|png|gif|bmp|webp)/i)) { + alert('지원하지 않는 이미지 형식입니다. (jpeg, png, gif, bmp, webp만 지원)'); + return; + } + + var reader = new FileReader(); + reader.onload = function(e) { + var dataUri = e.target.result; + $editor.summernote('insertImage', dataUri, function($image) { + // 이미지 스타일 설정 + $image.css({ + 'max-width': '100%', + 'height': 'auto' + }); + $image.attr('data-filename', file.name); + }); + }; + reader.onerror = function() { + alert('이미지 읽기에 실패했습니다.'); + }; + reader.readAsDataURL(file); +} + +/** + * 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용) + * - 불필요한 Office 속성 제거 + * - 이미지 태그 정리 (alt, v:shapes 등 제거) + * @param {string} html - 원본 HTML + * @returns {string} 정리된 HTML + */ +function cleanPastedHtml(html) { + // 임시 div에서 HTML 파싱 + var $temp = $('
').html(html); + + // 이미지 태그 정리 + $temp.find('img').each(function() { + var $img = $(this); + var src = $img.attr('src'); + + // src가 없거나 file:// 프로토콜이면 제거 + if (!src || src.indexOf('file://') === 0) { + $img.remove(); + return; + } + + // 불필요한 속성 제거 (Office 관련) + var attributesToRemove = ['alt', 'v:shapes', 'o:title', 'style', 'class', 'id', 'name']; + attributesToRemove.forEach(function(attr) { + $img.removeAttr(attr); + }); + + // 깨끗한 스타일만 적용 + $img.css({ + 'max-width': '100%', + 'height': 'auto' + }); + }); + + // VML 태그 제거 (v:, o:, w: 등) + var cleanedHtml = $temp.html(); + cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/v:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/o:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?<\/w:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/]*>[\s\S]*?/gi, ''); + + // Office 네임스페이스 속성 제거 + cleanedHtml = cleanedHtml.replace(/\s*v:[a-z]+="[^"]*"/gi, ''); + cleanedHtml = cleanedHtml.replace(/\s*o:[a-z]+="[^"]*"/gi, ''); + + return cleanedHtml; +} + +/** + * 이미지를 서버에 업로드하고 플레이스홀더로 에디터에 삽입 + * @param {File} file - 이미지 파일 + * @param {jQuery} $editor - summernote 에디터 jQuery 객체 + * @param {object} options - 업로드 옵션 (uploadUrl, imageViewUrl 필수) + */ +function uploadImageToServer(file, $editor, options) { + // 파일 크기 제한 (10MB) + var maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + alert('이미지 크기는 10MB를 초과할 수 없습니다.'); + return; + } + + // 이미지 타입 확인 + if (!file.type.match(/image\/(jpeg|jpg|png|gif|bmp|webp)/i)) { + alert('지원하지 않는 이미지 형식입니다. (jpeg, png, gif, bmp, webp만 지원)'); + return; + } + + var formData = new FormData(); + formData.append('file', file); + formData.append('cmd', 'UPLOAD'); // 필터 인증 통과용 + + // 업로드 중 표시 + var $editable = $editor.next('.note-editor').find('.note-editable'); + var $loading = $('
이미지 업로드 중...
'); + $editable.append($loading); + + $.ajax({ + url: options.uploadUrl, + method: 'POST', + data: formData, + processData: false, + contentType: false, + xhrFields: { withCredentials: true }, + success: function(response) { + $loading.remove(); + + if (response.success) { + // 이미지 URL 생성 (쿼리 파라미터 방식) + var imageUrl = options.imageViewUrl + '?fileId=' + response.fileId + '&fileSn=' + response.fileSn; + + // 이미지 삽입 + $editor.summernote('insertImage', imageUrl, function($image) { + // 플레이스홀더 정보를 data 속성에 저장 + $image.attr('data-file-id', response.fileId); + $image.attr('data-file-sn', response.fileSn); + $image.attr('data-placeholder', response.placeholder); + $image.css({ + 'max-width': '100%', + 'height': 'auto' + }); + }); + } else { + alert('이미지 업로드 실패: ' + (response.error || '알 수 없는 오류')); + } + }, + error: function(xhr, status, error) { + $loading.remove(); + console.error('이미지 업로드 오류:', error); + alert('이미지 업로드 중 오류가 발생했습니다.'); + } + }); +} + +/** + * 에디터 내용에서 이미지 태그를 플레이스홀더로 변환 (저장 시 호출) + * @param {string} contents - HTML 내용 + * @returns {string} 플레이스홀더로 변환된 내용 + */ +function convertImagesToPlaceholders(contents) { + if (!contents) return contents; + + var $temp = $('
').html(contents); + + $temp.find('img[data-file-id]').each(function() { + var $img = $(this); + var fileId = $img.attr('data-file-id'); + var fileSn = $img.attr('data-file-sn') || '1'; + + // 이미지 속성 수집 + var attrs = []; + var width = $img.attr('width'); + var height = $img.attr('height'); + var style = $img.attr('style'); + + // width/height가 유효한 값일 때만 저장 (0, auto, 빈값 제외) + if (width && width !== 'auto' && width !== '0' && parseInt(width) > 0) { + attrs.push('width=' + String(width).replace('px', '')); + } + if (height && height !== 'auto' && height !== '0' && parseInt(height) > 0) { + attrs.push('height=' + String(height).replace('px', '')); + } + if (style && style.indexOf('width') === -1) { + // style에 width가 없는 경우만 저장 (중복 방지) + attrs.push('style=' + style); + } + + // 플레이스홀더 생성 + var placeholder = '{{IMG:' + fileId + ':' + fileSn; + if (attrs.length > 0) { + placeholder += '|' + attrs.join('|'); + } + placeholder += '}}'; + + // 이미지 태그를 플레이스홀더로 교체 + $img.replaceWith(placeholder); + }); + + return $temp.html(); +} + +/** + * 플레이스홀더를 이미지 태그로 변환 (불러올 때 호출) + * @param {string} contents - 플레이스홀더가 포함된 내용 + * @param {string} baseImageUrl - 이미지 서빙 베이스 URL (예: /onl/apim/editor/image/view/) + * @returns {string} 이미지 태그로 변환된 HTML + */ +function convertPlaceholdersToImages(contents, baseImageUrl) { + if (!contents) return contents; + + // 플레이스홀더 패턴: {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|속성}} + var pattern = /\{\{IMG:([a-f0-9\-]+):(\d+)(\|[^}]+)?\}\}/gi; + + return contents.replace(pattern, function(match, fileId, fileSn, attrsStr) { + var imgTag = '= 2) { + var key = kv[0]; + var value = kv.slice(1).join('='); // = 가 포함된 값 처리 + imgTag += ' ' + key + '="' + value + '"'; + if (key === 'style') hasStyle = true; + } + } + } + + // 기본 스타일 추가 + if (!hasStyle) { + imgTag += ' style="max-width:100%;height:auto;"'; + } + + imgTag += ' />'; + return imgTag; + }); +} + +/** + * 서버에서 이미지 파일 삭제 + * @param {string} fileId - 삭제할 파일 ID + * @param {object} options - 옵션 (uploadUrl에서 delete URL 유추) + */ +function deleteImageFromServer(fileId, options) { + if (!fileId || !options.uploadUrl) return; + + // uploadUrl에서 delete URL 유추: /upload.json -> /delete.json + var deleteUrl = options.uploadUrl.replace('/upload.json', '/delete.json'); + + $.ajax({ + url: deleteUrl, + method: 'POST', + data: { + cmd: 'DELETE', + fileId: fileId + }, + success: function(response) { + if (response.success) { + console.log('이미지 삭제 완료: ' + fileId); + } else { + console.warn('이미지 삭제 실패: ' + (response.error || '')); + } + }, + error: function(xhr, status, error) { + console.error('이미지 삭제 오류:', error); + } + }); +} + +/** + * 내용에서 파일 ID 목록 추출 (cleanup용) + * @param {string} contents - HTML 또는 플레이스홀더가 포함된 내용 + * @returns {Array} 파일 ID 배열 + */ +function extractFileIdsFromContent(contents) { + if (!contents) return []; + + var fileIds = []; + var seen = {}; + + // 플레이스홀더에서 추출 + var placeholderPattern = /\{\{IMG:([a-f0-9\-]+):\d+(\|[^}]+)?\}\}/gi; + var match; + while ((match = placeholderPattern.exec(contents)) !== null) { + var fileId = match[1]; + if (!seen[fileId]) { + seen[fileId] = true; + fileIds.push(fileId); + } + } + + // img 태그의 data-file-id에서 추출 + var $temp = $('
').html(contents); + $temp.find('img[data-file-id]').each(function() { + var fileId = $(this).attr('data-file-id'); + if (fileId && !seen[fileId]) { + seen[fileId] = true; + fileIds.push(fileId); + } + }); + + return fileIds; +} diff --git a/WebContent/css/editor-content.css b/WebContent/css/editor-content.css new file mode 100644 index 0000000..67817f9 --- /dev/null +++ b/WebContent/css/editor-content.css @@ -0,0 +1,383 @@ +/** + * Editor Content Styles (editor-content.css) + * + * Summernote 에디터로 작성된 HTML 콘텐츠의 공통 스타일 + * 관리자포탈/개발자포탈 양쪽에서 동일하게 사용 + * + * 사용법: + * - 관리자포탈: Summernote .note-editable에 .editor-content 클래스 추가 + * - 개발자포탈: 콘텐츠 wrapper에 .editor-content 클래스 추가 + * + * @version 1.0.0 + * @date 2025-12-30 + */ + +/* ========================================================================== + CSS Reset + 기본 설정 + ========================================================================== */ + +.editor-content { + all: revert; + font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; + font-size: 16px !important; + font-weight: 400 !important; + line-height: 1.8 !important; + color: #1A1A2E !important; + word-wrap: break-word; + text-align: left !important; +} + +/* ========================================================================== + 헤딩 (Headings) + ========================================================================== */ + +.editor-content h1 { + font-size: 20px !important; + font-weight: 700 !important; + margin: 32px 0 16px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h1:first-child { + margin-top: 0 !important; +} + +.editor-content h2 { + font-size: 18px !important; + font-weight: 700 !important; + margin: 24px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h3 { + font-size: 16px !important; + font-weight: 600 !important; + margin: 16px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h4, +.editor-content h5, +.editor-content h6 { + font-size: 16px !important; + font-weight: 600 !important; + margin: 16px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +/* ========================================================================== + 단락 (Paragraphs) + ========================================================================== */ + +.editor-content p { + margin-bottom: 16px !important; + line-height: 1.8 !important; + font-size: 16px !important; +} + +.editor-content p:last-child { + margin-bottom: 0 !important; +} + +/* ========================================================================== + 리스트 (Lists) - 글로벌 reset 대응 + ========================================================================== */ + +.editor-content ul { + list-style-type: disc !important; + padding-left: 32px !important; + margin: 16px 0 !important; +} + +.editor-content ol { + list-style-type: decimal !important; + padding-left: 32px !important; + margin: 16px 0 !important; +} + +.editor-content li { + margin-bottom: 8px !important; + line-height: 1.8 !important; + font-size: 16px !important; +} + +.editor-content li:last-child { + margin-bottom: 0 !important; +} + +/* 중첩 리스트 */ +.editor-content ul ul { + list-style-type: circle !important; + margin: 8px 0 !important; +} + +.editor-content ul ul ul { + list-style-type: square !important; +} + +.editor-content ol ol { + list-style-type: lower-alpha !important; + margin: 8px 0 !important; +} + +.editor-content ol ol ol { + list-style-type: lower-roman !important; +} + +/* ========================================================================== + 테이블 (Tables) + ========================================================================== */ + +.editor-content table { + width: 100% !important; + border-collapse: collapse !important; + margin: 24px 0 !important; + border: 1px solid #E2E8F0 !important; + border-radius: 8px !important; + overflow: hidden !important; + font-size: 14px !important; +} + +.editor-content th, +.editor-content td { + border: 1px solid #E2E8F0 !important; + padding: 8px 16px !important; + text-align: left !important; + vertical-align: top !important; +} + +.editor-content th { + background: #EFF6FF !important; + font-weight: 600 !important; + color: #1A1A2E !important; +} + +.editor-content tr:hover { + background: #F8FAFC !important; +} + +/* ========================================================================== + 링크 (Links) + ========================================================================== */ + +.editor-content a { + color: #0049b4 !important; + text-decoration: underline !important; + transition: color 0.3s ease !important; +} + +.editor-content a:hover { + color: #003080 !important; +} + +/* ========================================================================== + 인용문 (Blockquote) + ========================================================================== */ + +.editor-content blockquote { + border-left: 4px solid #0049b4 !important; + padding: 16px 24px !important; + margin: 24px 0 !important; + background: #F8FAFC !important; + font-style: italic !important; + color: #64748B !important; + border-radius: 0 8px 8px 0 !important; +} + +.editor-content blockquote p { + margin-bottom: 0 !important; +} + +/* ========================================================================== + 이미지 (Images) + ========================================================================== */ + +.editor-content img { + max-width: 100% !important; + height: auto !important; + border-radius: 8px !important; + margin-top: 16px !important; + margin-bottom: 16px !important; + /* margin-left, margin-right는 인라인 스타일로 정렬 제어 */ +} + +/* ========================================================================== + 코드 (Code) + ========================================================================== */ + +.editor-content code { + background: #F8FAFC !important; + padding: 2px 6px !important; + border-radius: 4px !important; + font-family: 'Fira Code', 'Courier New', monospace !important; + font-size: 0.9em !important; + color: #0049b4 !important; +} + +.editor-content pre { + background: #F8FAFC !important; + border: 1px solid #E2E8F0 !important; + border-radius: 8px !important; + padding: 16px !important; + overflow-x: auto !important; + margin: 16px 0 !important; +} + +.editor-content pre code { + background: transparent !important; + padding: 0 !important; + color: #1A1A2E !important; +} + +/* ========================================================================== + 구분선 (Horizontal Rule) + ========================================================================== */ + +.editor-content hr { + border: none !important; + border-top: 1px solid #E2E8F0 !important; + margin: 32px 0 !important; +} + +/* ========================================================================== + 텍스트 강조 (Text Emphasis) + ========================================================================== */ + +.editor-content strong, +.editor-content b { + font-weight: 700 !important; +} + +.editor-content em, +.editor-content i { + font-style: italic !important; +} + +.editor-content u { + text-decoration: underline !important; +} + +.editor-content s, +.editor-content strike, +.editor-content del { + text-decoration: line-through !important; +} + +.editor-content mark { + background-color: #FEF3C7 !important; + padding: 0 2px !important; +} + +.editor-content sub { + vertical-align: sub !important; + font-size: 0.8em !important; +} + +.editor-content sup { + vertical-align: super !important; + font-size: 0.8em !important; +} + +/* ========================================================================== + 반응형 (Responsive) + ========================================================================== */ + +@media (max-width: 768px) { + .editor-content { + font-size: 14px !important; + } + + .editor-content h1 { + font-size: 18px !important; + } + + .editor-content h2 { + font-size: 16px !important; + } + + .editor-content h3, + .editor-content h4, + .editor-content h5, + .editor-content h6 { + font-size: 14px !important; + } + + .editor-content p, + .editor-content li { + font-size: 14px !important; + } + + .editor-content table { + font-size: 12px !important; + } + + .editor-content th, + .editor-content td { + padding: 4px 8px !important; + } + + .editor-content ul, + .editor-content ol { + padding-left: 24px !important; + } + + .editor-content blockquote { + padding: 8px 16px !important; + } + + .editor-content pre { + padding: 8px !important; + } +} + +/* ========================================================================== + Summernote 이미지 업로드 UI + ========================================================================== */ + +/* 이미지 업로드 로딩 인디케이터 */ +.image-upload-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 73, 180, 0.9); + color: #fff; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* 이미지 다이얼로그 업로드 안내 */ +.image-upload-guide { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + color: #1E40AF; +} + +.image-upload-guide .material-icons { + font-size: 18px; + color: #3B82F6; +} + +.image-upload-guide strong { + font-weight: 600; +} diff --git a/WebContent/emergency.jsp b/WebContent/emergency.jsp index 04bba57..667d56a 100644 --- a/WebContent/emergency.jsp +++ b/WebContent/emergency.jsp @@ -6,16 +6,17 @@ diff --git a/WebContent/js/common-en.js b/WebContent/js/common-en.js index 0487b36..6a983ab 100644 --- a/WebContent/js/common-en.js +++ b/WebContent/js/common-en.js @@ -37,16 +37,14 @@ function isChromeBrowse() { } // find Context -function getContextName() -{ - // get context - var loc = location.href; - var sPos = loc.indexOf("/", 7) - var ePos = loc.indexOf("/", sPos+1) +function getContextName() { + // get context path from pathname (works with both http and https) + var pathname = location.pathname; + var pos = pathname.indexOf("/", 1); - if (ePos == -1) return "/"; - else return loc.substr(sPos, ePos-sPos + 1); -} + if (pos == -1) return pathname.endsWith("/") ? pathname : pathname + "/"; + else return pathname.substring(0, pos + 1); +} // return today : 20081231 function getToday() { diff --git a/WebContent/js/common-ko.js b/WebContent/js/common-ko.js index 8474645..59f170d 100644 --- a/WebContent/js/common-ko.js +++ b/WebContent/js/common-ko.js @@ -37,13 +37,12 @@ function isChromeBrowse() { // find Context function getContextName() { - // get context - var loc = location.href; - var sPos = loc.indexOf("/", 7) - var ePos = loc.indexOf("/", sPos + 1) + // get context path from pathname (works with both http and https) + var pathname = location.pathname; + var pos = pathname.indexOf("/", 1); - if (ePos == -1) return "/"; - else return loc.substr(sPos, ePos - sPos + 1); + if (pos == -1) return pathname.endsWith("/") ? pathname : pathname + "/"; + else return pathname.substring(0, pos + 1); } // return today : 20081231 diff --git a/WebContent/js/custom.js b/WebContent/js/custom.js index c71c8b0..e6bb511 100644 --- a/WebContent/js/custom.js +++ b/WebContent/js/custom.js @@ -149,7 +149,7 @@ jQuery.showModalDialog = function(options) { $().showModalDialog(options); }; var originalError = options.error; options.error = function(xhr, status, error) { if(xhr.status === 401) { - var location = getContextName() + '/'; + var location = getContextName(); comloadError(xhr, status, error, location); return; } diff --git a/WebContent/jsp/common/include/script.jsp b/WebContent/jsp/common/include/script.jsp index 873de95..1e96177 100644 --- a/WebContent/jsp/common/include/script.jsp +++ b/WebContent/jsp/common/include/script.jsp @@ -56,9 +56,12 @@ + + +"/> ", Pattern.CASE_INSENSITIVE), - Pattern.compile("src[\r\n]*=[\r\n]*\\\'(.*?)\\\'", - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), - Pattern.compile("src[\r\n]*=[\r\n]*\\\"(.*?)\\\"", - Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), Pattern.compile("", Pattern.CASE_INSENSITIVE), Pattern.compile("", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), @@ -46,6 +43,22 @@ class RequestWrapper extends HttpServletRequestWrapper { Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL) }; + // src 속성 화이트리스트 패턴 - data:image/만 허용 + private static final Pattern SRC_ATTR_PATTERN = Pattern.compile( + "(src\\s*=\\s*)([\"'])([^\"']*?)\\2", + Pattern.CASE_INSENSITIVE); + + // 허용되는 src 값 패턴 (화이트리스트) - data:image/만 허용 + private static final Pattern ALLOWED_SRC_PATTERN = Pattern.compile( + "^data:image/.*", + Pattern.CASE_INSENSITIVE); + + // XSS 문자 변환을 건너뛸 파라미터 목록 (리치 텍스트 에디터 콘텐츠 등) + // XSS 패턴 필터링은 적용하지만 <, > 등으로 변환하지 않음 + private static final String[] SKIP_CHAR_CONVERT_PARAMS = {"contents", "content", "description"}; + + // 현재 처리 중인 파라미터명 (cleanXSS에서 문자 변환 여부 결정에 사용) + private ThreadLocal currentParameter = new ThreadLocal<>(); public RequestWrapper(HttpServletRequest servletRequest) { super(servletRequest); @@ -60,8 +73,13 @@ class RequestWrapper extends HttpServletRequestWrapper { int count = values.length; String[] encodedValues = new String[count]; - for (int i = 0; i < count; i++) { - encodedValues[i] = cleanXSS(values[i]); + currentParameter.set(parameter); + try { + for (int i = 0; i < count; i++) { + encodedValues[i] = cleanXSS(values[i]); + } + } finally { + currentParameter.remove(); } return encodedValues; @@ -74,13 +92,19 @@ class RequestWrapper extends HttpServletRequestWrapper { for (Entry entry : map.entrySet()) { String[] values = entry.getValue(); + String paramName = entry.getKey(); if (values != null && values.length > 0) { String[] cloneValues = new String[values.length]; - for (int idx = 0; idx < values.length; idx++) { - cloneValues[idx] = cleanXSS(values[idx]); + currentParameter.set(paramName); + try { + for (int idx = 0; idx < values.length; idx++) { + cloneValues[idx] = cleanXSS(values[idx]); + } + } finally { + currentParameter.remove(); } - clonedMap.put(entry.getKey(), cloneValues); + clonedMap.put(paramName, cloneValues); } } @@ -95,7 +119,12 @@ class RequestWrapper extends HttpServletRequestWrapper { return value; } - return cleanXSS(value); + currentParameter.set(parameter); + try { + return cleanXSS(value); + } finally { + currentParameter.remove(); + } } @Override @@ -123,9 +152,15 @@ class RequestWrapper extends HttpServletRequestWrapper { // script 문자열을 완전히 제거 value = value.replaceAll("(?i)\\bscript\\b", ""); - StringBuilder sb = new StringBuilder(); - value = convertChars(value, sb); + // src 속성 화이트리스트 필터링 - 허용된 프로토콜만 통과 + value = filterSrcAttribute(value); + + // 리치 텍스트 파라미터는 문자 변환을 건너뛰고 XSS 패턴 필터링만 적용 + if (!shouldSkipCharConvert()) { + StringBuilder sb = new StringBuilder(); + value = convertChars(value, sb); + } // eval, javascript 등 스크립트 관련 문자열 제거 value = value.replaceAll("(?i)eval\\((.*?)\\)", ""); @@ -133,6 +168,54 @@ class RequestWrapper extends HttpServletRequestWrapper { return value; } + /** + * 현재 파라미터가 문자 변환을 건너뛸지 여부 확인 + * 리치 텍스트 에디터 콘텐츠 등은 HTML 태그가 필요하므로 < > 변환을 하지 않음 + */ + private boolean shouldSkipCharConvert() { + String param = currentParameter.get(); + if (param == null) { + return false; + } + for (String skipParam : SKIP_CHAR_CONVERT_PARAMS) { + if (StringUtils.equalsIgnoreCase(param, skipParam)) { + return true; + } + } + return false; + } + + /** + * src 속성을 화이트리스트 방식으로 필터링 + * 허용: data:image/ (Base64 이미지) + * 비허용: 그 외 모든 프로토콜 + */ + private String filterSrcAttribute(String value) { + if (value == null || !value.toLowerCase().contains("src")) { + return value; + } + + Matcher matcher = SRC_ATTR_PATTERN.matcher(value); + StringBuffer result = new StringBuffer(); + + while (matcher.find()) { + String srcPrefix = matcher.group(1); // src= + String quote = matcher.group(2); // " 또는 ' + String srcValue = matcher.group(3); // src 값 + + // 빈 값이거나 화이트리스트에 매칭되면 유지 + if (srcValue.isEmpty() || ALLOWED_SRC_PATTERN.matcher(srcValue).matches()) { + matcher.appendReplacement(result, Matcher.quoteReplacement(srcPrefix + quote + srcValue + quote)); + } else { + // 허용되지 않는 프로토콜은 src="" 로 대체 + matcher.appendReplacement(result, Matcher.quoteReplacement(srcPrefix + quote + quote)); + } + } + matcher.appendTail(result); + + return result.toString(); + } + // '(', ')', '"' 문자는 getMethod()가 "get"일 때만 변환됨. // 이는 gridData에서 사용되는 큰따옴표(")와 변환 함수에서 사용하는 괄호('(', ')')의 처리를 위함. private String convertChars(String value, StringBuilder sb) { diff --git a/src/main/java/com/eactive/eai/rms/common/interceptor/AuthorizeInterceptor.java b/src/main/java/com/eactive/eai/rms/common/interceptor/AuthorizeInterceptor.java index 51e25f0..963652e 100644 --- a/src/main/java/com/eactive/eai/rms/common/interceptor/AuthorizeInterceptor.java +++ b/src/main/java/com/eactive/eai/rms/common/interceptor/AuthorizeInterceptor.java @@ -7,6 +7,7 @@ import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.ClassUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; @@ -32,6 +33,14 @@ public class AuthorizeInterceptor implements HandlerInterceptor { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // InterceptorSkipController 구현 컨트롤러는 권한 체크 스킵 + if (handler instanceof HandlerMethod) { + Class clazz = ((HandlerMethod) handler).getBeanType(); + if (ClassUtils.isAssignable(clazz, InterceptorSkipController.class)) { + return true; + } + } + // 요청 URI, 사용자 ID, 서비스 타입, cmd 추출 String uri = request.getRequestURI(); String userId = SessionManager.getUserId(request); diff --git a/src/main/java/com/eactive/eai/rms/common/interceptor/SessionCheckInterceptor.java b/src/main/java/com/eactive/eai/rms/common/interceptor/SessionCheckInterceptor.java index a339126..1d9d9d2 100644 --- a/src/main/java/com/eactive/eai/rms/common/interceptor/SessionCheckInterceptor.java +++ b/src/main/java/com/eactive/eai/rms/common/interceptor/SessionCheckInterceptor.java @@ -67,9 +67,9 @@ public class SessionCheckInterceptor extends HandlerInterceptorAdapter { } else { //request.getRequestDispatcher("/monitoring/loginForm.do").forward(request, response); //request.getSession().setAttribute("redirectInProgress", true); - response.sendRedirect("/monitoring/rms/logout.do"); + response.sendRedirect(request.getContextPath() + "/rms/logout.do"); //response.sendRedirect("/monitoring/loginForm.do"); - + } } diff --git a/src/main/java/com/eactive/eai/rms/common/login/SessionDestructionListener.java b/src/main/java/com/eactive/eai/rms/common/login/SessionDestructionListener.java new file mode 100644 index 0000000..1fb4d46 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/common/login/SessionDestructionListener.java @@ -0,0 +1,46 @@ +package com.eactive.eai.rms.common.login; + +import javax.servlet.annotation.WebListener; +import javax.servlet.http.HttpSession; +import javax.servlet.http.HttpSessionEvent; +import javax.servlet.http.HttpSessionListener; + +import org.apache.log4j.Logger; + +@WebListener +public class SessionDestructionListener implements HttpSessionListener { + + private static final Logger logger = Logger.getLogger(SessionDestructionListener.class); + + @Override + public void sessionCreated(HttpSessionEvent se) { + + } + + /** + * 세션 타임아웃 등으로 소멸 시 세션정보 삭제 + */ + @Override + public void sessionDestroyed(HttpSessionEvent se) { + + try { + HttpSession session = se.getSession(); + + String userId = (String) session.getAttribute("userId"); + + // 로그인 한 사용자의 세션정보 정리 + if (userId != null) { + if (logger.isDebugEnabled()) { + logger.debug("[SessionListener] 세션만료 감지. 사용자 제거: " + userId); + } + + SessionManager.removeUserSession(userId); + } + + } catch (Exception e) { + + + logger.error("[SessionListener] 세션 정리 중 오류 발생", e); + } + } +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/authserver/ScopeEntityService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/authserver/ScopeEntityService.java index 2f42d64..89b2a1e 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/authserver/ScopeEntityService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/authserver/ScopeEntityService.java @@ -1,21 +1,31 @@ package com.eactive.eai.rms.data.entity.onl.authserver; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; import com.eactive.eai.data.entity.onl.authserver.QScopeEntity; import com.eactive.eai.data.entity.onl.authserver.ScopeEntity; import com.eactive.eai.data.entity.onl.authserver.ScopeEntityId; import com.eactive.eai.data.jpa.AbstractDataService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + @Service @Transactional public class ScopeEntityService extends AbstractDataService { - + + @PersistenceContext + private EntityManager entityManager; // AbstractDataService 의 entityManager가 private이라 접근 불가하여 별도 주입 + public void deleteByIdScopeId(String scopeId) { getJPAQueryFactory().delete(QScopeEntity.scopeEntity) .where(QScopeEntity.scopeEntity.id.scopeid.eq(scopeId)) .execute(); + + // 동일 트랜잭션 내에서 같은 ID로 재저장 시 충돌(EntityExistsException)을 방지 + entityManager.flush(); + entityManager.clear(); } } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/eaimsg/EAIMessageService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/eaimsg/EAIMessageService.java index 4bf2428..2aeb0e3 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/eaimsg/EAIMessageService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/eaimsg/EAIMessageService.java @@ -310,8 +310,6 @@ public class EAIMessageService extends AbstractDataService(result, pageable, result.size()); @@ -320,7 +318,7 @@ public class EAIMessageService extends AbstractDataService { + + private final InflowControlService inflowControlService; + private final InflowControlHistoryManMapper inflowControlHistoryManMapper; + + @Autowired + public InflowControlLogService(InflowControlService inflowControlService, InflowControlHistoryManMapper inflowControlHistoryManMapper) { + this.inflowControlService = inflowControlService; + this.inflowControlHistoryManMapper = inflowControlHistoryManMapper; + } public Page findAll(Pageable pageable, InflowControlHistoryManUISearch uiSearch) { QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog; @@ -25,6 +39,28 @@ public class InflowControlLogService return super.repository.findAll(booleanBuilder, pageable); } + public Page selectList(Pageable pageable, InflowControlHistoryManUISearch uiSearch) { + + Page tuples = inflowControlService.selectLogList(pageable, uiSearch); + + return tuples.map(tuple -> { + InflowControlLog inflowControlLog = tuple.get(QInflowControlLog.inflowControlLog); + + String groupName = tuple.get(QInflowControlGroup.inflowControlGroup.groupName); + + InflowControlHistoryManUI inflowControlHistoryManUI; + + inflowControlHistoryManUI = inflowControlHistoryManMapper.toVo(inflowControlLog); + + inflowControlHistoryManUI.setGroupname(groupName); + + return inflowControlHistoryManUI; + + }); + + + } + public Iterable findAll(InflowControlHistoryManUISearch uiSearch) { QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog; BooleanBuilder booleanBuilder = new BooleanBuilder(); diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/inflow/InflowControlService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/inflow/InflowControlService.java index 7c08a0a..15adc96 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/inflow/InflowControlService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/inflow/InflowControlService.java @@ -17,11 +17,16 @@ import com.eactive.eai.data.entity.onl.adapter.QAdapterGroup; import com.eactive.eai.data.entity.onl.inflow.InflowControl; import com.eactive.eai.data.entity.onl.inflow.InflowControlId; import com.eactive.eai.data.entity.onl.inflow.QInflowControl; +import com.eactive.eai.data.entity.onl.inflow.QInflowControlGroup; +import com.eactive.eai.data.entity.onl.inflow.QInflowControlLog; import com.eactive.eai.data.entity.onl.message.QEAIMessageEntity; import com.eactive.eai.data.jpa.AbstractDataService; +import com.eactive.eai.rms.onl.manage.inflow.history.InflowControlHistoryManUISearch; import com.eactive.eai.rms.onl.manage.inflow.inflow.InflowControlManMapper; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @Service @@ -168,5 +173,45 @@ public class InflowControlService extends AbstractDataService selectLogList(Pageable pageable, InflowControlHistoryManUISearch uiSearch) { + QInflowControlLog qlog = QInflowControlLog.inflowControlLog; + QInflowControlGroup qgroup = QInflowControlGroup.inflowControlGroup; + + JPAQuery jpaQuery = createBaseQuery(uiSearch); + + long totalCount = jpaQuery.select(QInflowControlLog.inflowControlLog.count()).fetchOne(); + + List inflowControlLog = jpaQuery + .select(qlog, qgroup.groupName) + .limit(pageable.getPageSize()) + .offset(pageable.getOffset()) + .orderBy(qlog.id.msgdpstyms.desc()) + .fetch(); + + return new PageImpl<>(inflowControlLog, pageable, totalCount); + } + + private JPAQuery createBaseQuery(InflowControlHistoryManUISearch uiSearch) { + QInflowControlLog qInflowConfrolLog = QInflowControlLog.inflowControlLog; + QInflowControlGroup qinflowControlGroup = QInflowControlGroup.inflowControlGroup; + + BooleanBuilder predicate = new BooleanBuilder(); + predicate + .and(qInflowConfrolLog.id.msgdpstyms + .between(uiSearch.getSearchStartDate() + "000000000", + uiSearch.getSearchEndDate() + "235959999")); + + return getJPAQueryFactory() + .select( + qInflowConfrolLog, + qinflowControlGroup.groupName + ) + .from(qInflowConfrolLog) + .leftJoin(qinflowControlGroup) + .on(qInflowConfrolLog.adptrbzwkgroupname.eq(qinflowControlGroup.groupId)) + .where(predicate); + + } } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiLogDTO.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiLogDTO.java new file mode 100644 index 0000000..618a124 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiLogDTO.java @@ -0,0 +1,17 @@ +package com.eactive.eai.rms.data.entity.onl.logger; + +import java.util.List; + +import lombok.Data; + +@Data +public class ApiLogDTO { + + private String apiServiceNumber; + + private String txDate; + + private List logs; + + private Long totalProcessingTime; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiMessageLogDTO.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiMessageLogDTO.java new file mode 100644 index 0000000..793da46 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/ApiMessageLogDTO.java @@ -0,0 +1,36 @@ +package com.eactive.eai.rms.data.entity.onl.logger; + +import java.util.List; +import java.util.Map; + +import lombok.Data; + +@Data +public class ApiMessageLogDTO { + + private String apiServiceNumber; + + private int processNumber; + + private String txDate; + + private String url; + + private String queryString; + + private String clientId; + + private String method; + + private Integer HttpStatus; + + private String header; + + private String body; + + private String exdata; + + private Long processingTime; + + private String keymgtmsgctnt; +} diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepository.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepository.java index 2b10fb0..b89448c 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepository.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Pageable; import com.eactive.eai.data.entity.onl.logger.EAILog; import com.eactive.eai.data.entity.onl.logger.EAILogId; +import java.util.List; import java.util.Map; public interface EAILogRepository { @@ -16,4 +17,6 @@ public interface EAILogRepository { Map countDetailedTransactions(Class clazz, EAILogSearch eaiLogSearch); + List getApiLog(Class class1, String apiServiceNumber); + } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java index fc78b9c..aad5b34 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogRepositoryImpl.java @@ -1,5 +1,19 @@ package com.eactive.eai.rms.data.entity.onl.logger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + import com.eactive.eai.data.entity.onl.logger.EAILog; import com.eactive.eai.data.entity.onl.logger.EAILogId; import com.eactive.eai.data.entity.onl.logger.QEAILog; @@ -11,18 +25,6 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.EntityPathBase; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; @Repository public class EAILogRepositoryImpl implements EAILogRepository { @@ -217,4 +219,14 @@ public class EAILogRepositoryImpl implements EAILogRepository { .fetchOne(); } + @Override + public List getApiLog(Class clazz, String apiServiceNumber) { + QEAILog qeaiLog = QEAILog.eAILog; + EntityPathBase entityPathBase = new EntityPathBase<>(clazz, qeaiLog.getMetadata().getName()); + + return new JPAQueryFactory(em).select(qeaiLog).from(entityPathBase) + .where(qeaiLog.id.eaisvcserno.trim().eq(apiServiceNumber)).orderBy(qeaiLog.id.logprcssserno.asc()) + .fetch(); + } + } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogService.java index 5742260..215687b 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogService.java @@ -19,4 +19,6 @@ public interface EAILogService { List selectLogList(HashMap paramMap); Map getTransactionCounts(EAILogSearch eaiLogSearch); + + ApiLogDTO getApiLog(String txDate, String apiServiceNumber); } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogServiceImpl.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogServiceImpl.java index 30a8d95..1c86c8d 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogServiceImpl.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/EAILogServiceImpl.java @@ -1,55 +1,132 @@ package com.eactive.eai.rms.data.entity.onl.logger; -import com.eactive.eai.data.RollingTable; -import com.eactive.eai.data.entity.onl.logger.EAILog; -import com.eactive.eai.data.entity.onl.logger.EAILogId; -import com.eactive.eai.rms.common.util.CommonUtil; -import com.eactive.eai.rms.data.entity.onl.apim.portalstatistics.PortalStatisticsVO; -import lombok.RequiredArgsConstructor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationContext; 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.util.HashMap; -import java.util.List; -import java.util.Map; +import com.eactive.eai.data.RollingTable; +import com.eactive.eai.data.entity.onl.logger.EAILog; +import com.eactive.eai.data.entity.onl.logger.EAILogId; +import com.eactive.eai.data.entity.onl.logger.HttpAdapterExtraLog; +import com.eactive.eai.rms.common.util.CommonUtil; +import com.eactive.eai.rms.data.entity.onl.apim.portalstatistics.PortalStatisticsVO; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Service @Transactional @RequiredArgsConstructor public class EAILogServiceImpl implements EAILogService { - private final EAILogRepository eaiLogRepository; - private final ApplicationContext applicationContext; - private final EAILogDAOForEMS eaiLogDAOForEMS; + private final EAILogRepository eaiLogRepository; + private final ApplicationContext applicationContext; + private final EAILogDAOForEMS eaiLogDAOForEMS; + private final HttpAdapterExtraLogService httpAdapterExtraLogService; - @Override - public Page findAll(Pageable pageable, EAILogSearch eaiLogSearch) { - long time = CommonUtil.getCalendar(eaiLogSearch.getSearchStartTime()).getTimeInMillis(); - EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); - Page eaiLogDtos = eaiLogRepository.findAll(eaiLog.getClass(), pageable, eaiLogSearch); - return eaiLogDtos; - } + @Override + public Page findAll(Pageable pageable, EAILogSearch eaiLogSearch) { + long time = CommonUtil.getCalendar(eaiLogSearch.getSearchStartTime()).getTimeInMillis(); + EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); + Page eaiLogDtos = eaiLogRepository.findAll(eaiLog.getClass(), pageable, eaiLogSearch); + return eaiLogDtos; + } - public Map getTransactionCounts(EAILogSearch eaiLogSearch){ - long time = CommonUtil.getCalendar(eaiLogSearch.getSearchStartTime()).getTimeInMillis(); - EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); - Map detailedCounts = eaiLogRepository.countDetailedTransactions(eaiLog.getClass(), eaiLogSearch); - return detailedCounts; - } + public Map getTransactionCounts(EAILogSearch eaiLogSearch) { + long time = CommonUtil.getCalendar(eaiLogSearch.getSearchStartTime()).getTimeInMillis(); + EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); + Map detailedCounts = eaiLogRepository.countDetailedTransactions(eaiLog.getClass(), eaiLogSearch); + return detailedCounts; + } - @Override - public EAILogDto getById(EAILogId eaiLogId) { - long time = CommonUtil.getCalendar(eaiLogId.getMsgdpstyms()).getTimeInMillis(); - EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); - return eaiLogRepository.getById(eaiLog.getClass(), eaiLogId); - } + @Override + public EAILogDto getById(EAILogId eaiLogId) { + long time = CommonUtil.getCalendar(eaiLogId.getMsgdpstyms()).getTimeInMillis(); + EAILog eaiLog = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); + return eaiLogRepository.getById(eaiLog.getClass(), eaiLogId); + } + public List selectLogList(HashMap paramMap) { + return eaiLogDAOForEMS.selectLogList(paramMap); + } - public List selectLogList(HashMap paramMap) { - return eaiLogDAOForEMS.selectLogList(paramMap); - } + @Override + public ApiLogDTO getApiLog(String txDate, String apiServiceNumber) { + + long time = CommonUtil.getCalendar(txDate).getTimeInMillis(); + EAILog eaiLogTargetObject = (EAILog) applicationContext.getBean(RollingTable.class, EAILog.class, time); + List eaiLogs = eaiLogRepository.getApiLog(eaiLogTargetObject.getClass(), apiServiceNumber); + eaiLogs.sort((l1, l2) -> l1.getId().getEaisvcserno().compareTo(l2.getId().getEaisvcserno())); + + ApiLogDTO apiLogDTO = new ApiLogDTO(); + apiLogDTO.setApiServiceNumber(apiServiceNumber); + eaiLogs.stream().findFirst().ifPresent(log -> apiLogDTO.setTxDate(log.getId().getMsgdpstyms())); + + List apiMessageLogDTOs = new LinkedList<>(); + + for (EAILog eaiLog : eaiLogs) { + ApiMessageLogDTO apiMessageLogDTO = new ApiMessageLogDTO(); + + apiMessageLogDTO.setApiServiceNumber(apiServiceNumber); + apiMessageLogDTO.setTxDate(eaiLog.getMsgprcssyms()); + apiMessageLogDTO.setProcessNumber(Integer.parseInt(eaiLog.getId().getLogprcssserno())); + apiMessageLogDTO.setQueryString(""); + apiMessageLogDTO.setClientId(eaiLog.getClientid()); + apiMessageLogDTO.setKeymgtmsgctnt(eaiLog.getKeymgtmsgctnt()); + + String body = StringUtils.substringAfter(eaiLog.getBzwkdatactnt(), '|'); + body = StringUtils.substringBeforeLast(body, "|"); + + apiMessageLogDTO.setBody(body); + apiMessageLogDTOs.add(apiMessageLogDTO); + } + + Map extraLogs = httpAdapterExtraLogService.findByGuid(apiServiceNumber); + for (ApiMessageLogDTO apiMessageLogDTO : apiMessageLogDTOs) { + HttpAdapterExtraLog httpAdapterExtraLog = extraLogs.get(apiMessageLogDTO.getProcessNumber()); + + if (httpAdapterExtraLog != null) { + apiMessageLogDTO.setHeader(httpAdapterExtraLog.getHeaderContent()); + apiMessageLogDTO.setMethod(httpAdapterExtraLog.getHttpMethod()); + apiMessageLogDTO.setHttpStatus(httpAdapterExtraLog.getHttpStatus()); + apiMessageLogDTO.setUrl(httpAdapterExtraLog.getUrl()); + } + } + + // 처리 시간 계산 + for (int i = 0; i < apiMessageLogDTOs.size(); i++) { + ApiMessageLogDTO dto = apiMessageLogDTOs.get(i); + EAILog eaiLog = eaiLogs.get(i); + + // msgprcssyms - msgdpstyms + long receivedTime = CommonUtil.getCalendar(eaiLog.getId().getMsgdpstyms()).getTimeInMillis(); + long processedTime = CommonUtil.getCalendar(eaiLog.getMsgprcssyms()).getTimeInMillis(); + long processingTime = processedTime - receivedTime; + dto.setProcessingTime(processingTime); + } + + // 전체 처리 시간 계산 (첫 수신 시각 ~ 마지막 처리 시각) + if (!eaiLogs.isEmpty()) { + long firstReceivedTime = CommonUtil.getCalendar(eaiLogs.get(0).getId().getMsgdpstyms()).getTimeInMillis(); + long lastProcessedTime = CommonUtil.getCalendar(eaiLogs.get(eaiLogs.size() - 1).getMsgprcssyms()).getTimeInMillis(); + long totalProcessingTime = lastProcessedTime - firstReceivedTime; + apiLogDTO.setTotalProcessingTime(totalProcessingTime); + } + + apiLogDTO.setLogs(apiMessageLogDTOs); + return apiLogDTO; + } } diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/HttpAdapterExtraLogService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/HttpAdapterExtraLogService.java index 2fa2c86..6441b7c 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/HttpAdapterExtraLogService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/logger/HttpAdapterExtraLogService.java @@ -1,16 +1,28 @@ package com.eactive.eai.rms.data.entity.onl.logger; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + import javax.transaction.Transactional; import org.springframework.stereotype.Service; import com.eactive.eai.data.entity.onl.logger.HttpAdapterExtraLog; import com.eactive.eai.data.entity.onl.logger.HttpAdapterExtraLogId; +import com.eactive.eai.data.entity.onl.logger.QHttpAdapterExtraLog; import com.eactive.eai.data.jpa.AbstractDataService; +import com.querydsl.core.types.dsl.BooleanExpression; @Service @Transactional public class HttpAdapterExtraLogService extends AbstractDataService { + public Map findByGuid(String guid) { + BooleanExpression predicate = QHttpAdapterExtraLog.httpAdapterExtraLog.id.guid.eq(guid); + return StreamSupport.stream(repository.findAll(predicate).spliterator(), false) + .collect(Collectors.toMap(log -> log.getId().getServiceProcessNumber(), log -> log)); + } + } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/apigroup/ui/ApiGroupUISearch.java b/src/main/java/com/eactive/eai/rms/onl/apim/apigroup/ui/ApiGroupUISearch.java index c2c4716..47bf7e5 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/apigroup/ui/ApiGroupUISearch.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/apigroup/ui/ApiGroupUISearch.java @@ -7,4 +7,5 @@ public class ApiGroupUISearch { private String searchGroupName; private String searchGroupDesc; private String searchDisplayYn; + private String searchApiFullPath; } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageController.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageController.java new file mode 100644 index 0000000..bc64478 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageController.java @@ -0,0 +1,155 @@ +package com.eactive.eai.rms.onl.apim.portalterms; + +import com.eactive.eai.rms.common.base.BaseAnnotationController; +import com.eactive.eai.rms.common.interceptor.InterceptorSkipController; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.HashMap; +import java.util.Map; + +/** + * 에디터 이미지 컨트롤러 + * - summernote 에디터에서 사용하는 이미지 업로드/서빙 API + * - InterceptorSkipController: 인터셉터의 상세 검증 우회 (필터에서 기본 로그인 체크는 수행됨) + */ +@Controller +@RequestMapping("/onl/apim/editor/image") +public class EditorImageController extends BaseAnnotationController implements InterceptorSkipController { + + private final EditorImageService editorImageService; + + @Autowired + public EditorImageController(EditorImageService editorImageService) { + this.editorImageService = editorImageService; + } + + /** + * 이미지 업로드 API + * - summernote에서 이미지 선택/붙여넣기 시 호출 + * - 즉시 DB에 저장하고 플레이스홀더 정보 반환 + * + * @param file MultipartFile 이미지 파일 + * @return { success: true, fileId: "uuid", fileSn: 1, placeholder: "{{IMG:uuid:1}}" } + */ + @PostMapping("/upload.json") + @ResponseBody + public ResponseEntity> uploadImage(@RequestParam("file") MultipartFile file) { + Map result = new HashMap<>(); + + try { + // 파일 검증 + if (file.isEmpty()) { + result.put("success", false); + result.put("error", "파일이 비어있습니다."); + return ResponseEntity.badRequest().body(result); + } + + // 이미지 타입 검증 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + result.put("success", false); + result.put("error", "이미지 파일만 업로드 가능합니다."); + return ResponseEntity.badRequest().body(result); + } + + // 파일 크기 검증 (10MB) + long maxSize = 10 * 1024 * 1024; + if (file.getSize() > maxSize) { + result.put("success", false); + result.put("error", "파일 크기는 10MB를 초과할 수 없습니다."); + return ResponseEntity.badRequest().body(result); + } + + // 이미지 저장 + EditorImageDTO saved = editorImageService.saveImage(file); + + // 성공 응답 + result.put("success", true); + result.put("fileId", saved.getFileId()); + result.put("fileSn", saved.getFileSn()); + result.put("placeholder", "{{IMG:" + saved.getFileId() + ":" + saved.getFileSn() + "}}"); + + return ResponseEntity.ok(result); + + } catch (Exception e) { + logger.error("이미지 업로드 중 오류 발생", e); + result.put("success", false); + result.put("error", "이미지 업로드 중 오류가 발생했습니다: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); + } + } + + /** + * 이미지 서빙 API + * - 브라우저에서 이미지 표시용 (관리자포탈) + * - 캐시 설정으로 성능 최적화 + * + * @param fileId 파일 ID (PTL_FILE.FILE_ID) + * @param fileSn 파일 순번 (PTL_FILE_DETAIL.FILE_SN) + * @return 이미지 바이너리 데이터 + */ + @GetMapping("/view.do") + public ResponseEntity viewImage( + @RequestParam String fileId, + @RequestParam(defaultValue = "1") int fileSn) { + try { + EditorImageDTO image = editorImageService.getImage(fileId, fileSn); + + if (image == null || image.getImageData() == null) { + return ResponseEntity.notFound().build(); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(image.getContentType())); + headers.setContentLength(image.getFileSize()); + headers.setCacheControl("public, max-age=86400"); // 1일 캐시 + headers.set("Content-Disposition", "inline"); + + return new ResponseEntity<>(image.getImageData(), headers, HttpStatus.OK); + + } catch (Exception e) { + logger.error("이미지 조회 중 오류 발생: fileId=" + fileId + ", fileSn=" + fileSn, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * 이미지 삭제 API + * - 에디터에서 이미지 삭제 시 호출 + * - DB에서 파일 삭제 + * + * @param fileId 파일 ID (PTL_FILE.FILE_ID) + * @return { success: true } + */ + @PostMapping("/delete.json") + @ResponseBody + public ResponseEntity> deleteImage(@RequestParam String fileId) { + Map result = new HashMap<>(); + + try { + if (fileId == null || fileId.isEmpty()) { + result.put("success", false); + result.put("error", "파일 ID가 필요합니다."); + return ResponseEntity.badRequest().body(result); + } + + editorImageService.deleteImage(fileId); + + result.put("success", true); + return ResponseEntity.ok(result); + + } catch (Exception e) { + logger.error("이미지 삭제 중 오류 발생: fileId=" + fileId, e); + result.put("success", false); + result.put("error", "이미지 삭제 중 오류가 발생했습니다: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result); + } + } +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageDTO.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageDTO.java new file mode 100644 index 0000000..584e3fe --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageDTO.java @@ -0,0 +1,46 @@ +package com.eactive.eai.rms.onl.apim.portalterms; + +import lombok.Data; + +/** + * 에디터 이미지 DTO + * - summernote 에디터에서 업로드된 이미지 정보를 담는 DTO + */ +@Data +public class EditorImageDTO { + + /** + * 파일 ID (PTL_FILE.FILE_ID) + */ + private String fileId; + + /** + * 파일 순번 (PTL_FILE_DETAIL.FILE_SN) + */ + private int fileSn; + + /** + * 원본 파일명 + */ + private String originalFileName; + + /** + * 파일 확장자 + */ + private String fileExtension; + + /** + * 파일 크기 (bytes) + */ + private int fileSize; + + /** + * Content-Type (MIME 타입) + */ + private String contentType; + + /** + * 이미지 바이너리 데이터 (조회 시에만 사용) + */ + private byte[] imageData; +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageService.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageService.java new file mode 100644 index 0000000..2ffade4 --- /dev/null +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/EditorImageService.java @@ -0,0 +1,231 @@ +package com.eactive.eai.rms.onl.apim.portalterms; + +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 lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 에디터 이미지 서비스 + * - summernote 에디터에서 사용하는 이미지 업로드/조회/삭제 서비스 + * - FileService를 래핑하여 이미지 전용 기능 제공 + */ +@Service +@Transactional(transactionManager = "transactionManagerForEMS") +@Slf4j +public class EditorImageService { + + /** + * 플레이스홀더 패턴: {{IMG:fileId:fileSn}} 또는 {{IMG:fileId:fileSn|속성}} + * - fileId: UUID 형식 (예: 550e8400-e29b-41d4-a716-446655440000) + * - fileSn: 정수 (예: 1) + * - 속성: 선택적 (예: |width=300|height=200|style=max-width:100%) + */ + private static final Pattern PLACEHOLDER_PATTERN = + Pattern.compile("\\{\\{IMG:([a-f0-9\\-]+):(\\d+)(\\|[^}]+)?\\}\\}"); + + private final FileService fileService; + + @Autowired + public EditorImageService(FileService fileService) { + this.fileService = fileService; + } + + /** + * 이미지 저장 + * + * @param file MultipartFile 이미지 파일 + * @return EditorImageDTO (fileId, fileSn 포함) + * @throws IOException 파일 처리 중 오류 발생 시 + */ + public EditorImageDTO saveImage(MultipartFile file) throws IOException { + String fileName = file.getOriginalFilename(); + String contentType = file.getContentType(); + byte[] data = file.getBytes(); + + // FileService를 통해 저장 + FileInfo fileInfo = fileService.createSingleFileFromBytes(fileName, contentType, data); + FileDetail detail = fileInfo.getFileDetails().get(0); + + // DTO로 변환하여 반환 + EditorImageDTO dto = new EditorImageDTO(); + dto.setFileId(fileInfo.getFileId()); + dto.setFileSn(detail.getFileSn()); + dto.setOriginalFileName(detail.getOriginalFileName()); + dto.setFileExtension(detail.getFileExtension()); + dto.setFileSize(detail.getFileSize()); + + return dto; + } + + /** + * 이미지 조회 + * + * @param fileId 파일 ID + * @param fileSn 파일 순번 + * @return EditorImageDTO (imageData 포함) + * @throws IOException 파일 조회 중 오류 발생 시 + */ + @Transactional(readOnly = true) + public EditorImageDTO getImage(String fileId, int fileSn) throws IOException { + FileDetail detail = fileService.getFileDetailByIds(fileId, fileSn); + byte[] data = fileService.retrieveFileContent(detail); + + EditorImageDTO dto = new EditorImageDTO(); + dto.setFileId(fileId); + dto.setFileSn(fileSn); + dto.setOriginalFileName(detail.getOriginalFileName()); + dto.setFileExtension(detail.getFileExtension()); + dto.setFileSize(detail.getFileSize()); + dto.setContentType(getContentType(detail.getFileExtension())); + dto.setImageData(data); + + return dto; + } + + /** + * 본문에서 사용 중인 파일 ID 추출 + * + * @param contents 본문 내용 (플레이스홀더 포함) + * @return 사용 중인 파일 ID Set + */ + public Set extractFileIds(String contents) { + Set fileIds = new HashSet<>(); + if (contents == null || contents.isEmpty()) { + return fileIds; + } + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(contents); + while (matcher.find()) { + fileIds.add(matcher.group(1)); // fileId + } + return fileIds; + } + + /** + * 이미지 삭제 + * + * @param fileId 삭제할 파일 ID + */ + public void deleteImage(String fileId) { + try { + fileService.deleteFile(fileId); + log.info("Deleted image: {}", fileId); + } catch (Exception e) { + log.warn("Failed to delete image: {}", fileId, e); + throw e; + } + } + + /** + * 미사용 이미지 정리 + * - 원래 저장된 이미지 중 현재 본문에서 사용되지 않는 이미지 삭제 + * + * @param usedFileIds 본문에서 사용 중인 파일 ID 목록 + * @param originalFileIds 원래 저장된 모든 파일 ID 목록 + */ + public void cleanupUnusedImages(Set usedFileIds, Set originalFileIds) { + if (originalFileIds == null || originalFileIds.isEmpty()) { + return; + } + + for (String fileId : originalFileIds) { + if (!usedFileIds.contains(fileId)) { + try { + fileService.deleteFile(fileId); + log.info("Deleted unused image: {}", fileId); + } catch (Exception e) { + // 삭제 실패 시 로그만 남기고 계속 진행 + log.warn("Failed to delete unused image: {}", fileId, e); + } + } + } + } + + /** + * 플레이스홀더를 실제 이미지 태그로 변환 + * + * @param contents 본문 내용 (플레이스홀더 포함) + * @param baseUrl 이미지 서빙 베이스 URL (예: /onl/apim/editor/image/view/) + * @return 이미지 태그가 포함된 HTML + */ + public String convertPlaceholdersToHtml(String contents, String baseUrl) { + if (contents == null || contents.isEmpty()) { + return contents; + } + + StringBuffer result = new StringBuffer(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(contents); + + while (matcher.find()) { + String fileId = matcher.group(1); + String fileSn = matcher.group(2); + String attrs = matcher.group(3); // |width=300|height=200 형태 + + StringBuilder img = new StringBuilder(); + img.append(""); + matcher.appendReplacement(result, Matcher.quoteReplacement(img.toString())); + } + matcher.appendTail(result); + + return result.toString(); + } + + /** + * 파일 확장자를 Content-Type으로 변환 + * + * @param extension 파일 확장자 + * @return MIME 타입 + */ + private String getContentType(String extension) { + if (extension == null) { + return "application/octet-stream"; + } + + switch (extension.toLowerCase()) { + case "jpg": + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "gif": + return "image/gif"; + case "bmp": + return "image/bmp"; + case "webp": + return "image/webp"; + case "svg": + return "image/svg+xml"; + default: + return "application/octet-stream"; + } + } +} diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManController.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManController.java index 143c876..2582606 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManController.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManController.java @@ -13,12 +13,16 @@ 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 org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Controller @@ -72,14 +76,22 @@ public class PortalTermsManController extends BaseAnnotationController { } @PostMapping(value = "/onl/apim/portalterms/portalTermsMan.json", params = "cmd=INSERT") - public ResponseEntity insert(PortalTermsUI portalTermsUI) throws IOException { - portalTermsManService.insert(portalTermsUI); + public ResponseEntity insert(PortalTermsUI portalTermsUI, + @RequestParam(value = "attachedFile", required = false) MultipartFile attachedFile) throws IOException { + portalTermsManService.insert(portalTermsUI, attachedFile); return ResponseEntity.ok().build(); } @PostMapping(value = "/onl/apim/portalterms/portalTermsMan.json", params = "cmd=UPDATE") - public ResponseEntity update(PortalTermsUI portalTermsUI) throws IOException { - portalTermsManService.update(portalTermsUI); + public ResponseEntity update(PortalTermsUI portalTermsUI, String usedFileIds, + @RequestParam(value = "attachedFile", required = false) MultipartFile attachedFile, + @RequestParam(value = "deleteAttachment", defaultValue = "false") boolean deleteAttachment) throws IOException { + // 사용 중인 파일 ID 목록 파싱 + Set usedFileIdSet = new HashSet<>(); + if (usedFileIds != null && !usedFileIds.isEmpty()) { + usedFileIdSet.addAll(Arrays.asList(usedFileIds.split(","))); + } + portalTermsManService.update(portalTermsUI, usedFileIdSet, attachedFile, deleteAttachment); return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManService.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManService.java index f75dda4..1c969ef 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManService.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsManService.java @@ -4,15 +4,24 @@ import com.eactive.apim.portal.agreements.entity.AgreementType; import com.eactive.apim.portal.agreements.entity.Agreements; import com.eactive.apim.portal.agreements.entity.AgreementsId; 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.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; import org.apache.commons.lang.StringEscapeUtils; +import org.apache.commons.lang3.StringUtils; 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 org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; @Service @Transactional(transactionManager = "transactionManagerForEMS") @@ -21,16 +30,22 @@ public class PortalTermsManService extends BaseService { private final PortalTermsUIMapper portalTermsUIMapper; private final AgreementsService agreementsService; private final UserInfoService userInfoService; + private final EditorImageService editorImageService; + private final FileService fileService; @Autowired public PortalTermsManService(PortalTermsService portalTermsService, PortalTermsUIMapper portalTermsUIMapper, AgreementsService agreementsService, - UserInfoService userInfoService){ + UserInfoService userInfoService, + EditorImageService editorImageService, + FileService fileService){ this.portalTermsService = portalTermsService; this.portalTermsUIMapper = portalTermsUIMapper; this.agreementsService = agreementsService; this.userInfoService = userInfoService; + this.editorImageService = editorImageService; + this.fileService = fileService; } private String findName(String userId) { @@ -55,25 +70,90 @@ public class PortalTermsManService extends BaseService { portalTermsUI.setCreatedByName(findName(agreements.getCreatedBy())); portalTermsUI.setLastModifiedByName(findName(agreements.getLastModifiedBy())); portalTermsUI.setName(agreements.getAgreementsType().getDescription()); + + // 첨부파일 정보 조회 + if (StringUtils.isNotBlank(agreements.getAttachedFileId())) { + try { + FileInfo fileInfo = fileService.findById(agreements.getAttachedFileId()); + if (fileInfo != null && !fileInfo.getFileDetails().isEmpty()) { + FileDetail fileDetail = fileInfo.getFileDetails().get(0); + portalTermsUI.setAttachedFileName(fileDetail.getOriginalFileName() + "." + fileDetail.getFileExtension()); + portalTermsUI.setAttachedFileSize((long) fileDetail.getFileSize()); + } + } catch (Exception e) { + // 파일 조회 실패 시 무시 + logger.warn("첨부파일 조회 실패: {}", agreements.getAttachedFileId(), e); + } + } + return portalTermsUI; } - public void insert(PortalTermsUI portalTermsUI) { + public void insert(PortalTermsUI portalTermsUI, MultipartFile attachedFile) throws IOException { portalTermsUI.setName(AgreementType.fromCode(portalTermsUI.getAgreementsType()).getDescription()); portalTermsUI.setContents(StringEscapeUtils.unescapeHtml(portalTermsUI.getContents())); Agreements agreements = portalTermsUIMapper.toEntity(portalTermsUI); + + // 첨부파일 저장 + if (attachedFile != null && !attachedFile.isEmpty()) { + FileInfo fileInfo = fileService.createOrUpdateSingleFile(null, attachedFile, attachedFile.getOriginalFilename()); + agreements.setAttachedFileId(fileInfo.getFileId()); + } + agreementsService.save(agreements); } - public void update(PortalTermsUI portalTermsUI) { + public void update(PortalTermsUI portalTermsUI, Set usedFileIds, + MultipartFile attachedFile, boolean deleteAttachment) throws IOException { Agreements agreements = agreementsService.findById(portalTermsUI.getAgreementsType(), portalTermsUI.getRevision()); + + // 원래 내용에서 사용 중이던 파일 ID 추출 + Set originalFileIds = editorImageService.extractFileIds(agreements.getContents()); + + // 내용 업데이트 agreements.setName(AgreementType.fromCode(portalTermsUI.getAgreementsType()).getDescription()); agreements.setContents(StringEscapeUtils.unescapeHtml(portalTermsUI.getContents())); agreements.setPublishedOn(portalTermsUI.getPublishedOn()); + + // 첨부파일 처리 + String oldAttachedFileId = agreements.getAttachedFileId(); + + if (deleteAttachment && StringUtils.isNotBlank(oldAttachedFileId)) { + // 첨부파일 삭제 요청 + fileService.deleteFile(oldAttachedFileId); + agreements.setAttachedFileId(null); + } else if (attachedFile != null && !attachedFile.isEmpty()) { + // 새 첨부파일 업로드 + FileInfo fileInfo = fileService.createOrUpdateSingleFile( + oldAttachedFileId, attachedFile, attachedFile.getOriginalFilename()); + agreements.setAttachedFileId(fileInfo.getFileId()); + } + agreementsService.save(agreements); + + // 미사용 이미지 정리 (저장 후 비동기적으로 처리) + editorImageService.cleanupUnusedImages(usedFileIds, originalFileIds); } public void delete(String agreementsType, Integer revision) { + // 삭제 전 사용 중인 파일 ID 추출 + Agreements agreements = agreementsService.findById(agreementsType, revision); + Set fileIdsToDelete = editorImageService.extractFileIds(agreements.getContents()); + String attachedFileId = agreements.getAttachedFileId(); + + // 약관 삭제 agreementsService.delete(agreementsType, revision); + + // 관련 이미지 모두 삭제 + editorImageService.cleanupUnusedImages(new HashSet<>(), fileIdsToDelete); + + // 첨부파일 삭제 + if (StringUtils.isNotBlank(attachedFileId)) { + try { + fileService.deleteFile(attachedFileId); + } catch (Exception e) { + logger.warn("첨부파일 삭제 실패: {}", attachedFileId, e); + } + } } } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsUI.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsUI.java index eced443..af0fe4f 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsUI.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalterms/PortalTermsUI.java @@ -52,4 +52,13 @@ public class PortalTermsUI { private String searchAgreementsType; + /* 첨부파일 ID */ + private String attachedFileId; + + /* 첨부파일명 (조회용) */ + private String attachedFileName; + + /* 첨부파일 크기 (조회용) */ + private Long attachedFileSize; + } diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/group/InflowGroupControlManController.java b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/group/InflowGroupControlManController.java index ab4fe96..c3e31bf 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/group/InflowGroupControlManController.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/group/InflowGroupControlManController.java @@ -7,6 +7,14 @@ import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import com.eactive.eai.agent.command.CommonCommand; +import com.eactive.eai.rms.common.base.OnlBaseAnnotationController; +import com.eactive.eai.rms.common.combo.ComboService; +import com.eactive.eai.rms.common.combo.ComboVo; +import com.eactive.eai.rms.common.vo.GridResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,14 +24,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; -import com.eactive.eai.agent.command.CommonCommand; -import com.eactive.eai.rms.common.base.OnlBaseAnnotationController; -import com.eactive.eai.rms.common.combo.ComboService; -import com.eactive.eai.rms.common.combo.ComboVo; -import com.eactive.eai.rms.common.vo.GridResponse; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - @Controller public class InflowGroupControlManController extends OnlBaseAnnotationController { @@ -78,7 +78,7 @@ public class InflowGroupControlManController extends OnlBaseAnnotationController * 그룹 저장 (INSERT) */ @RequestMapping(value = "/onl/admin/inflow/inflowGroupControlMan.json", params = "cmd=INSERT") - public String insert(HttpServletRequest request, HttpServletResponse response, InflowGroupControlManUI ui, + public ResponseEntity insert(HttpServletRequest request, HttpServletResponse response, InflowGroupControlManUI ui, @RequestParam(value = "interfaceListJson", required = false) String interfaceListJson) throws Exception { parseInterfaceList(ui, interfaceListJson); String modifiedBy = getSessionUserId(request); @@ -88,14 +88,14 @@ public class InflowGroupControlManController extends OnlBaseAnnotationController CommonCommand command = new CommonCommand("com.eactive.eai.agent.inflow.ReloadInflowGroupControlCommand", ui.getGroupId()); agentUtilService.broadcast(command); - return null; + return ResponseEntity.ok().build(); } /** * 그룹 저장 (UPDATE) */ @RequestMapping(value = "/onl/admin/inflow/inflowGroupControlMan.json", params = "cmd=UPDATE") - public String update(HttpServletRequest request, HttpServletResponse response, InflowGroupControlManUI ui, + public ResponseEntity update(HttpServletRequest request, HttpServletResponse response, InflowGroupControlManUI ui, @RequestParam(value = "interfaceListJson", required = false) String interfaceListJson) throws Exception { parseInterfaceList(ui, interfaceListJson); String modifiedBy = getSessionUserId(request); @@ -105,21 +105,21 @@ public class InflowGroupControlManController extends OnlBaseAnnotationController CommonCommand command = new CommonCommand("com.eactive.eai.agent.inflow.ReloadInflowGroupControlCommand", ui.getGroupId()); agentUtilService.broadcast(command); - return null; + return ResponseEntity.ok().build(); } /** * 그룹 삭제 */ @RequestMapping(value = "/onl/admin/inflow/inflowGroupControlMan.json", params = "cmd=DELETE") - public String delete(HttpServletRequest request, HttpServletResponse response, String groupId) throws Exception { + public ResponseEntity delete(HttpServletRequest request, HttpServletResponse response, String groupId) throws Exception { service.deleteGroup(groupId); // Agent Command 전송 CommonCommand command = new CommonCommand("com.eactive.eai.agent.inflow.RemoveInflowGroupControlCommand", groupId); agentUtilService.broadcast(command); - return null; + return ResponseEntity.ok().build(); } /** diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManController.java b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManController.java index da9d0b3..4473bb2 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManController.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManController.java @@ -31,6 +31,13 @@ public class InflowControlHistoryManController extends OnlBaseAnnotationControll return "/onl/admin/inflow/inflowControlHistoryMan"; } +// @RequestMapping(value = "/onl/admin/inflow/inflowControlHistoryMan.json", params = "cmd=LIST") +// public ResponseEntity> selectList(HttpServletRequest request, +// Pageable pageable, InflowControlHistoryManUISearch uiSearch) { +// Page uiPage = service.selectList(pageable, uiSearch); +// return ResponseEntity.ok(new GridResponse<>(uiPage)); +// } + @RequestMapping(value = "/onl/admin/inflow/inflowControlHistoryMan.json", params = "cmd=LIST") public ResponseEntity> selectList(HttpServletRequest request, Pageable pageable, InflowControlHistoryManUISearch uiSearch) { diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManService.java b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManService.java index 2aa632b..8ee1410 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManService.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManService.java @@ -23,8 +23,13 @@ public class InflowControlHistoryManService extends BaseService { @Autowired InflowControlHistoryManMapper mapper; +// public Page selectList(Pageable pageable, InflowControlHistoryManUISearch uiSearch) { +// return service.findAll(pageable, uiSearch).map(mapper::toVo); +// } + + public Page selectList(Pageable pageable, InflowControlHistoryManUISearch uiSearch) { - return service.findAll(pageable, uiSearch).map(mapper::toVo); + return service.selectList(pageable, uiSearch); } public List> selectListToExcel(InflowControlHistoryManUISearch uiSearch) { diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManUI.java b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManUI.java index 9b1bfb6..d63736c 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManUI.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/inflow/history/InflowControlHistoryManUI.java @@ -66,6 +66,12 @@ public class InflowControlHistoryManUI { */ @JsonProperty("THRESHOLDTIMEUNIT") private String thresholdtimeunit; + + /** + * 그룹명 + */ + @JsonProperty("GROUPNAME") + private String groupname; } diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/rule/transform2/Transform2Service.java b/src/main/java/com/eactive/eai/rms/onl/manage/rule/transform2/Transform2Service.java index a667814..cc2e444 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/rule/transform2/Transform2Service.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/rule/transform2/Transform2Service.java @@ -171,7 +171,8 @@ public class Transform2Service extends BaseService { private String genConversionSourceItems(String srcLayoutName, String cnvsncmdname) { - Pattern pattern = Pattern.compile(srcLayoutName + "(\\w|\\[|\\]|\\*|\\.|-|:)*"); + // \uAC00-\uD7A3 : 한글 전체 ('가' ~ '힣') + Pattern pattern = Pattern.compile(srcLayoutName + "[\\w\\.\\[\\]\\*\\-:\\uAC00-\\uD7A3]*"); Matcher matcher = pattern.matcher(cnvsncmdname); StringBuilder result = new StringBuilder(); diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManController.java b/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManController.java index ec5f5ce..6482828 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManController.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManController.java @@ -9,6 +9,7 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -52,7 +53,7 @@ public class StdMessageManController extends BaseController { @PostMapping(value = "/onl/admin/service/stdMessageMan.json", params = "cmd=LIST") public ResponseEntity> selectList(Pageable pageable, String searchBzwkSvcKeyName, String searchEaiSvcCode, String searchEaiSendRecv, String searchEaiDirection) { - List map = service + Page map = service .selectList(pageable, searchBzwkSvcKeyName, searchEaiSvcCode, searchEaiSendRecv, searchEaiDirection); return ResponseEntity.ok(new GridResponse<>(map)); diff --git a/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManService.java b/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManService.java index 15d84a6..6ecbf58 100644 --- a/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManService.java +++ b/src/main/java/com/eactive/eai/rms/onl/manage/serviceinfo/stdmessage/StdMessageManService.java @@ -16,6 +16,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,14 +60,13 @@ public class StdMessageManService { @Autowired private ExtendedColumnDefinitionUIMapper extendedColumnDefinitionUIMapper; - public List selectList(Pageable pageable, String searchBzwkSvcKeyName, String searchEaiSvcCode, + public Page selectList(Pageable pageable, String searchBzwkSvcKeyName, String searchEaiSvcCode, String searchEaiSendRecv, String searchEaiDirection) { - return standardMessageInfoService - .findAll(pageable, searchBzwkSvcKeyName, searchEaiSvcCode, searchEaiSendRecv, searchEaiDirection) - .stream() - .map(stdMessageUIMapper::toVo) - .collect(Collectors.toList()); + Page pageResult = standardMessageInfoService + .findAll(pageable, searchBzwkSvcKeyName, searchEaiSvcCode, searchEaiSendRecv, searchEaiDirection); + + return pageResult.map(stdMessageUIMapper::toVo); } diff --git a/src/main/java/com/eactive/eai/rms/onl/transaction/apim/ApiInterfaceService.java b/src/main/java/com/eactive/eai/rms/onl/transaction/apim/ApiInterfaceService.java index f46f749..4fecc3f 100644 --- a/src/main/java/com/eactive/eai/rms/onl/transaction/apim/ApiInterfaceService.java +++ b/src/main/java/com/eactive/eai/rms/onl/transaction/apim/ApiInterfaceService.java @@ -553,7 +553,7 @@ public class ApiInterfaceService extends OnlBaseService { restOption.setMethod(outboundHttpMethod); restOption.setExtraPath(outboundRestPath); - String pathType = outboundRestPath.matches(".*\\{[a-zA-Z]+\\}.*") ? "variableUrlRequest" : "simpleRequest"; + String pathType = outboundRestPath.matches(".*\\{[_a-zA-Z]+\\}.*") ? "variableUrlRequest" : "simpleRequest"; restOption.setType(pathType); if (StringUtils.isNotBlank(contentType)) { @@ -1180,17 +1180,54 @@ public class ApiInterfaceService extends OnlBaseService { apiInterfaceUI.setInboundRestPath(newInboundRestPath); if ("Y".equals(apiInterfaceUI.getTransformYn())) { - apiInterfaceUI.setInboundRequestLayout(changeToTargetLayoutUI(layoutManService.getLayoutUiToExport(apiInterfaceUI.getInboundRequestLayout()), newBizCd, newApiInterfaceId)); - apiInterfaceUI.setOutboundRequestLayout(changeToTargetLayoutUI(layoutManService.getLayoutUiToExport(apiInterfaceUI.getOutboundRequestLayout()), newBizCd, newApiInterfaceId)); - apiInterfaceUI.setOutboundResponseLayout(changeToTargetLayoutUI(layoutManService.getLayoutUiToExport(apiInterfaceUI.getOutboundResponseLayout()), newBizCd, newApiInterfaceId)); - apiInterfaceUI.setInboundResponseLayout(changeToTargetLayoutUI(layoutManService.getLayoutUiToExport(apiInterfaceUI.getInboundResponseLayout()), newBizCd, newApiInterfaceId)); - - apiInterfaceUI.setRequestTransform(changeToTargetTransformUI(transform2Service.getTransformUiToExport(apiInterfaceUI.getRequestTransform()), newBizCd, newApiInterfaceId, apiInterfaceUI.getInboundRequestLayout(), apiInterfaceUI.getOutboundRequestLayout())); - apiInterfaceUI.setResponseTransform(changeToTargetTransformUI(transform2Service.getTransformUiToExport(apiInterfaceUI.getResponseTransform()), newBizCd, newApiInterfaceId, apiInterfaceUI.getOutboundResponseLayout(), apiInterfaceUI.getInboundResponseLayout())); + + String newInReq = processLayoutIfExist(apiInterfaceUI.getInboundRequestLayout(), newBizCd, newApiInterfaceId); + String newOutReq = processLayoutIfExist(apiInterfaceUI.getOutboundRequestLayout(), newBizCd, newApiInterfaceId); + String newOutRes = processLayoutIfExist(apiInterfaceUI.getOutboundResponseLayout(), newBizCd, newApiInterfaceId); + String newInRes = processLayoutIfExist(apiInterfaceUI.getInboundResponseLayout(), newBizCd, newApiInterfaceId); + + apiInterfaceUI.setInboundRequestLayout(newInReq); + apiInterfaceUI.setOutboundRequestLayout(newOutReq); + apiInterfaceUI.setOutboundResponseLayout(newOutRes); + apiInterfaceUI.setInboundResponseLayout(newInRes); + + if (newInReq != null && newOutReq != null) { + apiInterfaceUI.setRequestTransform( + changeToTargetTransformUI( + transform2Service.getTransformUiToExport(apiInterfaceUI.getRequestTransform()), + newBizCd, newApiInterfaceId, newInReq, newOutReq + ) + ); + } + + if (newOutRes != null && newInRes != null) { + apiInterfaceUI.setResponseTransform( + changeToTargetTransformUI( + transform2Service.getTransformUiToExport(apiInterfaceUI.getResponseTransform()), + newBizCd, newApiInterfaceId, newOutRes, newInRes + ) + ); + } + } importApiInterfaceUi(apiInterfaceUI, importDupliCheck); return apiInterfaceUI; } + + /* + * 복제시 레이아웃 정보가 있는 경우에만 변환 로직을 수행 + */ + private String processLayoutIfExist(String layoutId, String newBizCd, String newApiInterfaceId) { + + if (layoutId == null || layoutId.isEmpty()) { + return null; + } + + LayoutUI layoutEntity = layoutManService.getLayoutUiToExport(layoutId); + + return changeToTargetLayoutUI(layoutEntity, newBizCd, newApiInterfaceId); + + } } diff --git a/src/main/java/com/eactive/ext/kjb/statistics/통계테이블_설계.md b/src/main/java/com/eactive/ext/kjb/statistics/통계테이블_설계.md new file mode 100644 index 0000000..3fb40b5 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/statistics/통계테이블_설계.md @@ -0,0 +1,83 @@ +# API Gateway 통계 테이블 설계 + +## 요구사항 정리 + +### 수집 차원 +- **시간 단위**: 분 단위 +- **분류 차원**: API명, API Gateway Instance, ClientId, Scope, 업무구분코드 (값이 없을 경우 'NONE' 으로 대체) + +### 메트릭 +- **처리 건수**: + - 총 건수 + - 성공 건수 + - 오류 건수 (Timeout / System Error / Business Error) +- **응답시간 통계**: + - 평균 응답시간 + - 최소 응답시간 + - 최대 응답시간 +- **추가 검토사항**: + - P50, P95 백분위 응답시간 + +### 집계 방식 +- **집계 주기**: 매분 배치 집계 +- **백분위 계산**: HdrHistogram 라이브러리 사용 (근사값, 메모리 효율적) + +--- + +## 테이블 DDL + +```sql +-- API Gateway 분단위 통계 테이블 +CREATE TABLE API_STATS_MINUTE ( + STAT_TIME TIMESTAMP(0) NOT NULL, -- 통계 시간 (분 단위, 초는 00) + API_NAME VARCHAR2(100) NOT NULL, -- API명 + GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID + CLIENT_ID VARCHAR2(100) NOT NULL, -- 클라이언트 ID + SCOPE VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 스코프 + BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드 + + -- 건수 메트릭 + TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL, + TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL, + SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL, + + -- 응답시간 메트릭 (ms, 소수점 3자리) + AVG_RESP_TIME NUMBER(10,3) DEFAULT 0, + MIN_RESP_TIME NUMBER(10,3) DEFAULT 0, + MAX_RESP_TIME NUMBER(10,3) DEFAULT 0, + P50_RESP_TIME NUMBER(10,3) DEFAULT 0, + P95_RESP_TIME NUMBER(10,3) DEFAULT 0, + + -- 관리 정보 + REG_DTIME TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + UPD_DTIME TIMESTAMP DEFAULT SYSTIMESTAMP, + + CONSTRAINT PK_API_STATS_MINUTE PRIMARY KEY ( + STAT_TIME, API_NAME, GW_INSTANCE_ID, CLIENT_ID, SCOPE, BIZ_DIV_CODE + ) +); + +-- 코멘트 +COMMENT ON TABLE API_STATS_MINUTE IS 'API Gateway 분단위 처리 통계'; +COMMENT ON COLUMN API_STATS_MINUTE.STAT_TIME IS '통계시간(분단위, 초는 00초로 절사)'; +COMMENT ON COLUMN API_STATS_MINUTE.API_NAME IS 'API 명칭'; +COMMENT ON COLUMN API_STATS_MINUTE.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID'; +COMMENT ON COLUMN API_STATS_MINUTE.CLIENT_ID IS '클라이언트 ID'; +COMMENT ON COLUMN API_STATS_MINUTE.SCOPE IS '스코프 (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MINUTE.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)'; +COMMENT ON COLUMN API_STATS_MINUTE.TOTAL_CNT IS '총 처리 건수'; +COMMENT ON COLUMN API_STATS_MINUTE.SUCCESS_CNT IS '성공 건수'; +COMMENT ON COLUMN API_STATS_MINUTE.TIMEOUT_CNT IS 'Timeout 오류 건수'; +COMMENT ON COLUMN API_STATS_MINUTE.SYSTEM_ERR_CNT IS '시스템 오류 건수'; +COMMENT ON COLUMN API_STATS_MINUTE.BIZ_ERR_CNT IS '업무 오류 건수'; +COMMENT ON COLUMN API_STATS_MINUTE.AVG_RESP_TIME IS '평균 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MINUTE.MIN_RESP_TIME IS '최소 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MINUTE.MAX_RESP_TIME IS '최대 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MINUTE.P50_RESP_TIME IS '50 백분위 응답시간(ms)'; +COMMENT ON COLUMN API_STATS_MINUTE.P95_RESP_TIME IS '95 백분위 응답시간(ms)'; +``` + +--- + diff --git a/src/main/java/com/eactive/ext/kjb/web/ApiLogUI.java b/src/main/java/com/eactive/ext/kjb/web/ApiLogUI.java new file mode 100644 index 0000000..7572680 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/web/ApiLogUI.java @@ -0,0 +1,17 @@ +package com.eactive.ext.kjb.web; + +import java.util.List; + +import lombok.Data; + +@Data +public class ApiLogUI { + + private String apiServiceNumber; + + private String txDate; + + private List logs; + + private Long totalProcessingTime; +} diff --git a/src/main/java/com/eactive/ext/kjb/web/ApiMessageLogUI.java b/src/main/java/com/eactive/ext/kjb/web/ApiMessageLogUI.java new file mode 100644 index 0000000..7e2989a --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/web/ApiMessageLogUI.java @@ -0,0 +1,35 @@ +package com.eactive.ext.kjb.web; + +import java.util.Map; + +import lombok.Data; + +@Data +public class ApiMessageLogUI { + + private String apiServiceNumber; + + private int processNumber; + + private String txDate; + + private String url; + + private String queryString; + + private String clientId; + + private String method; + + private Integer HttpStatus; + + private Map header; + + private Object body; + + private String exdata; + + private Long processingTime; + + private String guid; +} diff --git a/src/main/java/com/eactive/ext/kjb/web/ApiTransactionLogController.java b/src/main/java/com/eactive/ext/kjb/web/ApiTransactionLogController.java new file mode 100644 index 0000000..c18c317 --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/web/ApiTransactionLogController.java @@ -0,0 +1,142 @@ +package com.eactive.ext.kjb.web; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +import com.eactive.eai.rms.common.datasource.DataSourceContextHolder; +import com.eactive.eai.rms.common.datasource.DataSourceTypeManager; +import com.eactive.eai.rms.common.interceptor.InterceptorSkipController; +import com.eactive.eai.rms.data.entity.onl.logger.ApiLogDTO; +import com.eactive.eai.rms.data.entity.onl.logger.ApiMessageLogDTO; +import com.eactive.eai.rms.data.entity.onl.logger.EAILogService; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ApiTransactionLogController implements InterceptorSkipController { + + private final EAILogService eaiLogService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @GetMapping("/api/transaction/log.do") + @PostMapping("/api/transaction/log.do") + public ResponseEntity getApiLog(String txDate, String apiServiceNumber) { + DataSourceContextHolder.setDataSourceType(DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW)); + + // txDate가 없으면 오늘 날짜 설정 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + LocalDate searchDate; + if (StringUtils.hasText(txDate)) { + searchDate = LocalDate.parse(txDate, formatter); + } else { + searchDate = LocalDate.now(); + } + + // 최대 10일 전까지 조회 시도 + ApiLogDTO dto = null; + for (int i = 0; i < 10; i++) { + String searchDateStr = searchDate.format(formatter); + dto = eaiLogService.getApiLog(searchDateStr, apiServiceNumber); + + // 결과가 있으면 종료 + if (dto != null && dto.getLogs() != null && !dto.getLogs().isEmpty()) { + break; + } + + // 하루 전으로 이동 + searchDate = searchDate.minusDays(1); + } + + return ResponseEntity.ok(convertToUI(dto)); + } + + private ApiLogUI convertToUI(ApiLogDTO dto) { + ApiLogUI ui = new ApiLogUI(); + ui.setApiServiceNumber(dto.getApiServiceNumber()); + ui.setTxDate(dto.getTxDate()); + ui.setTotalProcessingTime(dto.getTotalProcessingTime()); + + if (dto.getLogs() != null) { + List uiLogs = dto.getLogs().stream().map(this::convertMessageToUI) + .collect(Collectors.toList()); + ui.setLogs(uiLogs); + } + + return ui; + } + + private ApiMessageLogUI convertMessageToUI(ApiMessageLogDTO dto) { + ApiMessageLogUI ui = new ApiMessageLogUI(); + ui.setApiServiceNumber(dto.getApiServiceNumber()); + ui.setProcessNumber(dto.getProcessNumber()); + ui.setTxDate(dto.getTxDate()); + ui.setUrl(dto.getUrl()); + ui.setQueryString(dto.getQueryString()); + ui.setClientId(dto.getClientId()); + ui.setMethod(dto.getMethod()); + ui.setHttpStatus(dto.getHttpStatus()); + ui.setExdata(dto.getExdata()); + ui.setProcessingTime(dto.getProcessingTime()); + ui.setGuid(dto.getKeymgtmsgctnt()); + + // header: JSON 문자열 → Map + if (dto.getHeader() != null) { + try { + List> headerList = objectMapper.readValue(dto.getHeader(), + new TypeReference>>() { + }); + + Map headerMap = new HashMap<>(); + for (Map headerItem : headerList) { + String name = headerItem.get("name"); + String value = headerItem.get("value"); + if (name != null) { + headerMap.put(name, value); + } + } + + // header에서 client*id 패턴 추출 (정규식, ignoreCase) + for (Map.Entry entry : headerMap.entrySet()) { + if (entry.getKey() != null && entry.getKey().matches("(?i)client.*id")) { + ui.setClientId(entry.getValue()); + break; + } + } + + ui.setHeader(headerMap); + } catch (Exception e) { + log.error("Failed to parse header JSON: {}", dto.getHeader(), e); + } + } + + // body: JSON 문자열 → Object + if (dto.getBody() != null) { + try { + Object bodyObject = objectMapper.readValue(dto.getBody(), Object.class); + ui.setBody(bodyObject); + } catch (Exception e) { + // JSON 파싱 실패 시 원본 문자열 사용 + ui.setBody(dto.getBody()); + } + } + + return ui; + } + +} diff --git a/src/main/java/com/eactive/ext/kjb/web/package-info.java b/src/main/java/com/eactive/ext/kjb/web/package-info.java new file mode 100644 index 0000000..03ec7ba --- /dev/null +++ b/src/main/java/com/eactive/ext/kjb/web/package-info.java @@ -0,0 +1 @@ +package com.eactive.ext.kjb.web; \ No newline at end of file diff --git a/src/main/resources/com/eactive/eai/apim/portalstatistics/EAILog-oracle.xml b/src/main/resources/com/eactive/eai/apim/portalstatistics/EAILog-oracle.xml index ed1d6ea..18b6ca6 100644 --- a/src/main/resources/com/eactive/eai/apim/portalstatistics/EAILog-oracle.xml +++ b/src/main/resources/com/eactive/eai/apim/portalstatistics/EAILog-oracle.xml @@ -32,7 +32,7 @@ FROM ( SELECT CLIENTID AS CLIENT_ID, - RAND() AS REQUEST_URI, + DBMS_RANDOM.VALUE AS REQUEST_URI, '$startTime$' AS STATISTICS_TIME, EAISVCNAME AS API_ID, NULL AS API_NAME, @@ -49,7 +49,7 @@ UNION ALL SELECT CLIENTID AS CLIENT_ID, - RAND() AS REQUEST_URI, + DBMS_RANDOM.VALUE AS REQUEST_URI, '$startTime$' AS STATISTICS_TIME, EAISVCNAME AS API_ID, NULL AS API_NAME, @@ -59,8 +59,8 @@ ORGID AS ORG_ID, ORGNAME AS ORG_NAME, CASE - WHEN SUBSTRING(RSPNSERRCDNAME, 1, 2) = 'RE' THEN 0 - WHEN SUBSTRING(RSPNSERRCDNAME, 1, 2) = 'FE' THEN 0 + WHEN SUBSTR(RSPNSERRCDNAME, 1, 2) = 'RE' THEN 0 + WHEN SUBSTR(RSPNSERRCDNAME, 1, 2) = 'FE' THEN 0 ELSE 1 END AS END_COUNT, 0 AS START_COUNT