Merge branch 'master' into feats/security

# Conflicts:
#	src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java
#	src/main/resources/templates/views/apps/webhook/webhookList.html
#	src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html
#	src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html
This commit is contained in:
Rinjae
2026-08-27 13:23:15 +09:00
150 changed files with 7280 additions and 558 deletions
@@ -0,0 +1,17 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
-->
<h1>개인정보수집동의서</h1>
<p><strong>(주)제주은행 귀중</strong></p>
<p>귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.</p>
<h2>개인정보</h2>
<ul>
<li><strong>일반 개인정보:</strong> 성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호</li>
</ul>
<p>□ 동의하지 않음  □ 동의함</p>
<p>※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.</p>
<p>※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.</p>
<h3>위 개인정보 수집·이용에 동의하십니까?</h3>
<p>제정일자: 2026. 99. 99. 개정</p>
@@ -0,0 +1,19 @@
개인정보수집동의서
(주)제주은행 귀중
귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.
개인정보
ㄴ 일반 개인정보
성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호
□ 동의하지 않음 □ 동의함
※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.
※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.
위 개인정보 수집·이용에 동의하십니까?
제정일자: 2026. 99. 99. 개정
+162
View File
@@ -0,0 +1,162 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h2/h3/p)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨(붙여넣기 시 badTags 정리 대상 없음).
약관탭(.content-body.editor-content), 회원가입 동의창, admin .note-editable.editor-content 3곳 모두
이 파일 안에서는 정의하지 않는 h2/h3/p 스타일은 공용 editor-content.css 가 담당한다.
-->
<h2>제1장 총칙</h2>
<h3>제1조 (목적)</h3>
<p>본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.</p>
<h3>제2조 (용어의 정의)</h3>
<p>&nbsp;&nbsp;① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자</p>
<h3>제3조 (서비스의 내용)</h3>
<p>&nbsp;&nbsp;① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)</p>
<p>&nbsp;&nbsp;② 거래성 API 서비스(환전, 이체 등)</p>
<p>&nbsp;&nbsp;③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”</p>
<h3>제4조 (약관의 효력 및 변경)</h3>
<p>&nbsp;&nbsp;① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.</p>
<p>&nbsp;&nbsp;② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.</p>
<h2>제2장 서비스 이용계약</h2>
<h3>제5조 (서비스 이용신청)</h3>
<p>&nbsp;&nbsp;① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.</p>
<p>&nbsp;&nbsp;② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.</p>
<h3>제6조 (서비스 이용신청 승인 및 거절)</h3>
<p>&nbsp;&nbsp;① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용신청기관”의 사업모델에 위법성이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “이용신청기관”의 재무안정성이 위험한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우</p>
<p>&nbsp;&nbsp;③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.</p>
<h2>제3장 서비스의 이용</h2>
<h3>제7조 (서비스 이용개시)</h3>
<p>“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.</p>
<h3>제8조 (서비스 이용시간)</h3>
<p>&nbsp;&nbsp;① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 은행의 시스템을 점검하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우</p>
<p>&nbsp;&nbsp;② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.</p>
<h3>제9조 (서비스 변경 및 중단)</h3>
<p>&nbsp;&nbsp;① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우</p>
<p>&nbsp;&nbsp;③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<h3>제10조 (서비스 이용 제한)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 감독당국으로부터 중지 권고가 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. “이용기관”이 휴업 또는 폐업을 하는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우</p>
<p>&nbsp;&nbsp;② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.</p>
<h3>제11조 (서비스 이용 해지)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 감독당국으로부터 해지권고가 있는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 최근 12개월간 “시스템” 이용 실적이 없는 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우</p>
<p>&nbsp;&nbsp;② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.</p>
<p>&nbsp;&nbsp;③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.</p>
<h2>제4장 계약당사자의 의무 및 손해배상 등</h2>
<h3>제12조 (은행의 의무)</h3>
<p>&nbsp;&nbsp;① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.</p>
<p>&nbsp;&nbsp;③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
<h3>제13조 (약관 외 준칙)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.</p>
<p>&nbsp;&nbsp;⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.</p>
<p>&nbsp;&nbsp;⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.</p>
<p>&nbsp;&nbsp;⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.</p>
<p>&nbsp;&nbsp;⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 다른 “이용기관”의 아이디를 부정 사용하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 선량한 풍속, 기타 사회질서를 해하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. 타인의 명예를 훼손하거나 모욕하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 타인의 지식재산권 등의 권리를 침해하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;6. 해킹행위 또는 컴퓨터 바이러스의 유포행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위</p>
<p>&nbsp;&nbsp;⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
<h2>제5장 수수료</h2>
<h3>제14조 (수수료)</h3>
<p>&nbsp;&nbsp;① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.</p>
<p>&nbsp;&nbsp;⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.</p>
<p>&nbsp;&nbsp;⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.</p>
<h2>제6장 보칙</h2>
<h3>제15조 (최종사용자에 대한 고지 의무)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.</p>
<h3>제16조 (부정 접속 방지)</h3>
<p>&nbsp;&nbsp;① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.</p>
<p>&nbsp;&nbsp;② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.</p>
<h3>제17조 (전산처리 절차)</h3>
<p>전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.</p>
<h3>제18조 (보안관리)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 개인정보 및 금융정보 암호화 운영현황</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 해킹 등에 대비한 정책사항</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 시스템 운영에 대한 정보보호 체계 등</p>
<p>&nbsp;&nbsp;② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.</p>
<h3>제19조 (기밀유지)</h3>
<p>&nbsp;&nbsp;① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.</p>
<p>&nbsp;&nbsp;② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.</p>
<h3>제20조 (손해배상 및 면책)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
<p>&nbsp;&nbsp;② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 접근매체의 위조나 변조로 발생한 사고</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고</p>
<p>&nbsp;&nbsp;③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.</p>
<p>&nbsp;&nbsp;④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;가. 누설·노출 또는 방치한 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우</p>
<h3>제21조 (이의제기 및 협조)</h3>
<p>&nbsp;&nbsp;① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.</p>
<p>&nbsp;&nbsp;② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.</p>
<p>&nbsp;&nbsp;③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.</p>
<h3>제22조 (거래기록·자료의 제공)</h3>
<p>&nbsp;&nbsp;① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.</p>
<p>&nbsp;&nbsp;③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.</p>
<p>&nbsp;&nbsp;④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.</p>
<p>&nbsp;&nbsp;⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;1. 전자적 장치를 통한 제공의 경우에는 즉시</p>
<p>&nbsp;&nbsp;&nbsp;&nbsp;2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부</p>
<h2>부칙</h2>
<p>본 약관은 2026. 99. 99.부터 시행합니다.</p>
+184
View File
@@ -0,0 +1,184 @@
제1장 총칙
제1조 (목적)
본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.
제2조 (용어의 정의)
① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.
1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API
2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것
3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템
4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트
5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)
6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자
7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객
8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자
제3조 (서비스의 내용)
① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)
② 거래성 API 서비스(환전, 이체 등)
③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”
제4조 (약관의 효력 및 변경)
① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.
② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.
제2장 서비스 이용계약
제5조 (서비스 이용신청)
① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.
② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.
제6조 (서비스 이용신청 승인 및 거절)
① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.
② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.
1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우
2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우
3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우
4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우
5. “이용신청기관”의 사업모델에 위법성이 있는 경우
6. “이용신청기관”의 재무안정성이 위험한 경우
7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우
8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우
③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.
제3장 서비스의 이용
제7조 (서비스 이용개시)
“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.
제8조 (서비스 이용시간)
① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.
1. 은행의 시스템을 점검하는 경우
2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우
3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우
4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우
5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우
② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.
제9조 (서비스 변경 및 중단)
① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.
② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.
1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우
2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우
3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우
4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우
③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
제10조 (서비스 이용 제한)
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.
1. 감독당국으로부터 중지 권고가 있는 경우
2. “이용기관”이 휴업 또는 폐업을 하는 경우
3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우
4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우
5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우
6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우
② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.
제11조 (서비스 이용 해지)
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.
1. 감독당국으로부터 해지권고가 있는 경우
2. 최근 12개월간 “시스템” 이용 실적이 없는 경우
3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우
4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우
② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.
③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.
제4장 계약당사자의 의무 및 손해배상 등
제12조 (은행의 의무)
① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.
② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.
③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.
④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
제13조 (약관 외 준칙)
① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.
② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.
③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.
④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.
⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.
⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.
⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.
⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.
1. 다른 “이용기관”의 아이디를 부정 사용하는 행위
2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위
3. 선량한 풍속, 기타 사회질서를 해하는 행위
4. 타인의 명예를 훼손하거나 모욕하는 행위
5. 타인의 지식재산권 등의 권리를 침해하는 행위
6. 해킹행위 또는 컴퓨터 바이러스의 유포행위
7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위
8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위
⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
제5장 수수료
제14조 (수수료)
① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.
② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.
③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.
④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.
⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.
⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.
제6장 보칙
제15조 (최종사용자에 대한 고지 의무)
① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.
② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.
③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.
④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.
제16조 (부정 접속 방지)
① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.
② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.
제17조 (전산처리 절차)
전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.
제18조 (보안관리)
① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.
1. 개인정보 및 금융정보 암호화 운영현황
2. 해킹 등에 대비한 정책사항
3. 시스템 운영에 대한 정보보호 체계 등
② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.
③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.
제19조 (기밀유지)
① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.
② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.
제20조 (손해배상 및 면책)
① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
1. 접근매체의 위조나 변조로 발생한 사고
2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고
3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고
③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.
④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.
1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)
2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우
3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우
4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우
가. 누설·노출 또는 방치한 행위
나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위
5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우
제21조 (이의제기 및 협조)
① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.
② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.
③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.
④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.
제22조 (거래기록·자료의 제공)
① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.
② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.
③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.
④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.
⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.
1. 전자적 장치를 통한 제공의 경우에는 즉시
2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부
부칙
본 약관은 2026. 99. 99.부터 시행합니다.
+20
View File
@@ -0,0 +1,20 @@
<!--
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
-->
<h1>알림수신동의서</h1>
<p><strong>(주)제주은행 귀중</strong></p>
<p>귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.</p>
<h2>발송정보</h2>
<ul>
<li><strong>수집·이용 항목:</strong> 휴대폰 번호</li>
<li><strong>발송 목적:</strong> Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송</li>
<li><strong>보유·이용 기간:</strong> 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기</li>
</ul>
<p>□ 동의하지 않음  □ 동의함</p>
<p>※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.</p>
<p>※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.</p>
<p>※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.</p>
<h3>위 알림 수신에 동의하십니까?</h3>
<p>제정일자: 2026. 99. 99. 제정</p>
+22
View File
@@ -0,0 +1,22 @@
알림수신동의서
(주)제주은행 귀중
귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.
발송정보
ㄴ 수집·이용 항목: 휴대폰 번호
ㄴ 발송 목적: Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송
ㄴ 보유·이용 기간: 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기
□ 동의하지 않음 □ 동의함
※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.
※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.
※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.
위 알림 수신에 동의하십니까?
제정일자: 2026. 99. 99. 제정
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
/**
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
}
@@ -15,6 +15,10 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
@@ -79,12 +83,17 @@ public class ApiController {
}
@GetMapping
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
model.addAttribute("apis", searchResult.get("apis"));
model.addAttribute("apis", apiPage.getContent());
model.addAttribute("page", apiPage);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
@@ -94,6 +103,19 @@ public class ApiController {
return "apps/apis/mainApiList";
}
/**
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
}
@GetMapping("/testbed/api")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
private String displayRoleCode;
private String apiGroupName;
private String apiGroupId;
}
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) {
api.setMainIcon(service.getMainIcon());
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName());
}
});
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
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.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor
public class ApiServiceController {
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
private final ApiServiceService apiServiceService;
@@ -52,4 +65,28 @@ public class ApiServiceController {
return "apps/apiservice/apiServiceDetail";
}
/**
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
* 브라우저 캐시로 재사용시킨다.
*/
@GetMapping("/{id}/icon")
public ResponseEntity<byte[]> icon(@PathVariable String id) {
String mainIcon = apiServiceService.getMainIcon(id);
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
if (matcher == null || !matcher.matches()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
.build();
}
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(matcher.group(1)))
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
.body(imageBytes);
}
}
@@ -120,5 +120,14 @@ public class ApiServiceService {
.orElse(null);
}
/**
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
*/
public String getMainIcon(String id) {
return apiServiceRepository.findById(id)
.map(ApiGroup::getMainIcon)
.orElse(null);
}
}
@@ -41,7 +41,13 @@ public class ApprovalService {
approval.setApprovalType(ApprovalType.APP);
approval.setTargetId(request.getId());
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
String clientName = request.getClientName();
String clientNamePart = clientName == null || clientName.trim().isEmpty()
? "" : " [" + clientName.trim() + "]";
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
+ " " + request.getType().getDescription() + " 승인");
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
.collect(Collectors.toList());
}
private static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
/**
* org 축과 role 축은 각자 독립적으로 선택적 제한이다(비어있으면 그 축은 통과) — 둘을 AND로 합친다.
* (과거 OR 합성은 한쪽 축이 비어있으면 그 축이 무조건 true가 되어 다른 쪽 제한을 무력화시키는
* 결함이 있었다 — 예: displayOrg 만 설정하고 displayRoleCode 를 비워두면 roleMatch 가 로그인
* 여부만으로 true 가 되어 조직 제한이 사실상 적용되지 않았다.)
*/
static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
/** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
}
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds);
if (resendAvailableAt.isAfter(now)) {
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
throw new AuthNumberException(
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
remainingSeconds));
}
});
}
@@ -1,6 +1,8 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker.Outcome;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
@@ -11,6 +13,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
@@ -30,6 +34,7 @@ public class StepUpPasswordController {
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +49,8 @@ public class StepUpPasswordController {
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
@@ -52,12 +58,21 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
passwordConfirmFailureTracker.reset(session);
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
// 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response);
if (outcome.isForcedLogout()) {
return outcome.isAccountLocked()
? "redirect:/login?pwFailExceeded=1&locked=1"
: "redirect:/login?pwFailExceeded=1";
}
model.addAttribute("error", outcome.getMessage());
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.community.partnership.controller;
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
import com.eactive.apim.portal.common.security.WriteRateLimitService;
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import java.io.IOException;
@@ -20,10 +22,13 @@ import javax.validation.Valid;
public class PartnershipApplicationController {
private final PartnershipApplicationFacade partnershipApplicationFacade;
private final WriteRateLimitService writeRateLimitService;
@Autowired
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
WriteRateLimitService writeRateLimitService){
this.partnershipApplicationFacade = partnershipApplicationFacade;
this.writeRateLimitService = writeRateLimitService;
}
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
return "apps/community/mainPartnershipForm";
}
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
redirectAttributes.addFlashAttribute("error",
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
return "redirect:/partnership";
}
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
@@ -61,4 +73,23 @@ public class PartnershipApplicationController {
return "redirect:/partnership";
}
/**
* 본인이 작성한 피드백/개선요청 1건 삭제.
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
*/
@PostMapping("/{id}/delete")
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth&redirect=/partnership";
}
try {
partnershipApplicationFacade.deleteMyApplication(id);
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
}
return "redirect:/partnership";
}
}
@@ -3,9 +3,12 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
@@ -16,4 +19,26 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
* 등가 조회가 가능한 이유는 위와 동일하다.
*/
List<PartnershipApplication> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
/**
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
*/
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
/**
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
*/
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
}
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
}
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service
@RequiredArgsConstructor
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
@Override
public void deleteMyApplication(String id) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
throw new IllegalArgumentException("로그인이 필요합니다.");
}
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
PartnershipApplication target = partnershipApplicationService
.findOwnedByCreatedBy(id, user.getId())
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
if (StringUtils.isNotBlank(target.getFileId())) {
fileService.deleteFile(target.getFileId());
}
partnershipApplicationService.deletePartnershipApplication(target);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
/**
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
*/
@Transactional(readOnly = true)
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
}
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.delete(partnershipApplication);
}
}
@@ -3,6 +3,8 @@ package com.eactive.apim.portal.apps.community.qna.controller;
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
import com.eactive.apim.portal.common.security.WriteRateLimitService;
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
@@ -37,11 +39,14 @@ public class InquiryController {
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade;
private final WriteRateLimitService writeRateLimitService;
public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) {
InquiryCommentFacade inquiryCommentFacade,
WriteRateLimitService writeRateLimitService) {
this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade;
this.writeRateLimitService = writeRateLimitService;
}
/**
@@ -133,6 +138,13 @@ public class InquiryController {
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
}
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
if (writeRateLimitService.isExceeded(WriteTarget.INQUIRY)) {
redirectAttributes.addFlashAttribute("error",
writeRateLimitService.exceededMessage(WriteTarget.INQUIRY));
return REDIRECT_INQUIRY;
}
inquiryFacade.createInquiry(inquiryDTO, image);
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
@@ -3,14 +3,29 @@ package com.eactive.apim.portal.apps.community.qna.repository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
@EMSDataSource
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
@Transactional
long deleteByInquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<Inquiry> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
}
@@ -24,6 +24,8 @@ import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryReposito
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.user.entity.UserLog;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -74,6 +76,7 @@ public class LoginFinalizer {
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
private final PortalPropertyService portalPropertyService;
private final UserLogRepository userLogRepository;
/**
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
@@ -96,11 +99,19 @@ public class LoginFinalizer {
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
// 화면 표시용 "직전 로그인" 조회 — 이번 로그인 기록(logSuccess) 삽입 전에 조회해야
// 방금 로그인이 아닌 그 이전 성공 로그인을 얻는다.
Optional<UserLog> previousLogin = userLogRepository
.findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(rawUsername, true);
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
duplicate);
String contextPath = request.getContextPath();
HttpSession session = request.getSession();
previousLogin.ifPresent(log -> {
session.setAttribute("previousLoginDate", log.getLoginTime());
session.setAttribute("previousLoginIp", log.getIp());
});
applyPostLoginState(user, session, rawUsername, contextPath);
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
* 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/
@Data
@Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총
* API 이용 건수 (월누적)
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/
private int totalApiCount;
}
@@ -1,14 +1,20 @@
package com.eactive.apim.portal.apps.main.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
.filter(id -> id != null && !id.trim().isEmpty())
.collect(Collectors.toList());
if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
}
cachedStatistics = IndexStatisticsDTO.builder()
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
}
}
/**
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
*/
private long getMonthlyApiCallCount(List<String> clientIds) {
LocalDate today = LocalDate.now();
LocalDate monthStart = today.withDayOfMonth(1);
LocalDate dayEnd = today.minusDays(1);
long total = 0L;
if (!monthStart.isAfter(dayEnd)) {
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
}
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
total += nz(todaySummary.getTotalCount());
return total;
}
private static long nz(Long value) {
return value != null ? value : 0L;
}
/**
* PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -55,6 +56,7 @@ public class AccountController {
private final UserSessionService userSessionService;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
@@ -65,12 +67,18 @@ public class AccountController {
}
@PostMapping("/password/confirm")
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
String currentLoginId = SecurityUtil.getCurrentLoginId();
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
if (userFacade.verifyCurrentPassword(currentLoginId, inputPassword)) {
passwordConfirmFailureTracker.reset(session);
return ResponseEntity.ok(new ValidationResponse(true, "비밀번호가 확인되었습니다."));
}
String message = isPasswordCorrect ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다.";
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
// 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다.
PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage()));
}
@GetMapping("/password/verify")
@@ -96,15 +104,29 @@ public class AccountController {
}
@PostMapping("/password/verify")
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes,
HttpSession session, HttpServletRequest request, HttpServletResponse response, Model model) {
String currentLoginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
passwordConfirmFailureTracker.reset(session);
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// GET /password/change 와 동일하게 2FA 필요 여부를 내려준다.
// 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아
// 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다.
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
} else {
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
return "redirect:/password/verify";
}
// 무제한 시도 차단 — 임계 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다.
PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
if (outcome.isForcedLogout()) {
return outcome.isAccountLocked()
? "redirect:/login?pwFailExceeded=1&locked=1"
: "redirect:/login?pwFailExceeded=1";
}
redirectAttributes.addFlashAttribute("error", outcome.getMessage());
return "redirect:/password/verify";
}
@PostMapping("/password/change")
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
}
@PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
}
@PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@PasswordMatch(input = "password", confirm = "confirmPassword")
@Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/
private String password;
private String password2;
@CellPhone
private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
}
throw new IllegalArgumentException(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
}
// 약관 동의 정보 삭제
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
ValidationResponse checkPasswordMatch(String password, String password2);
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
@Override
public ValidationResponse checkPasswordMatch(String password, String password2) {
boolean isMatch = password.equals(password2);
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message);
}
@@ -34,7 +34,8 @@ public class PasswordService {
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
validatePasswordUpdate(user, newPassword, confirmPassword);
checkPasswordHistory(user.getLoginId(), newPassword);
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
checkPasswordHistory(user.getId(), newPassword);
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
if(histories.isEmpty()) {
@@ -85,6 +86,17 @@ public class PasswordService {
return result;
}
/**
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
*/
@Transactional
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
savePasswordHistory(userId, previousPasswordHash);
}
private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -115,26 +127,4 @@ public class PasswordService {
passwordHistoryRepository.save(newHistory);
}
private boolean isValidPassword(String password) {
if (password.length() < 8) {
return false;
}
boolean hasLetter = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (char c : password.toCharArray()) {
if (Character.isLetter(c)) {
hasLetter = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else if (!Character.isWhitespace(c)) {
hasSpecial = true;
}
}
return hasLetter && hasDigit && hasSpecial;
}
}
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override
@Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
@@ -162,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -1,53 +1,23 @@
package com.eactive.apim.portal.apps.user.validator;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고,
* 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다.
*/
@Component
@RequiredArgsConstructor
public class PasswordValidator {
public boolean isValidPassword(String password) {
if (password == null || password.isEmpty()) {
return false;
}
String tmpPw = password.toUpperCase();
return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw);
}
private final PasswordPolicyProperties passwordPolicyProperties;
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
PasswordRuleValidator validator = new PasswordRuleValidator();
return validator.isValid(password, loginId, mobileNumber);
}
private boolean isValidLengthAndCharacters(String password) {
final int MIN = 8;
final int MAX = 50;
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
return password.matches(REGEX);
}
private boolean containsInvalidPatterns(String password) {
final String SAMEPT = "(\\w)\\1\\1";
final String BLANKPT = "(\\s)";
if (password.matches(BLANKPT) || password.matches(SAMEPT)) {
return true;
}
return containsContinuousCharacters(password);
}
private boolean containsContinuousCharacters(String password) {
for (int i = 0; i < password.length() - 2; i++) {
if (isContinuous(password.charAt(i), password.charAt(i + 1), password.charAt(i + 2))) {
return true;
}
}
return false;
}
private boolean isContinuous(char a, char b, char c) {
return (b - a == 1) && (c - b == 1);
return validator.isValid(password, loginId, mobileNumber,
passwordPolicyProperties.isKeyboardSequenceBlocked());
}
}
@@ -12,6 +12,7 @@ import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty;
@@ -32,6 +33,9 @@ public class GlobalControllerAdvice {
@Autowired
private ClientGuardService clientGuardService;
@Autowired
private PasswordPolicyProperties passwordPolicyProperties;
@Autowired
private PortalPropertyService portalPropertyService;
@@ -111,6 +115,28 @@ public class GlobalControllerAdvice {
return clientGuardService.isDevtoolsGuardEnabled();
}
/**
* 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부.
* PortalProperty(Portal/password.keyboard-sequence.block)에서 조회.
* head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다.
*/
@ModelAttribute("passwordKeyboardSequenceBlock")
public boolean passwordKeyboardSequenceBlock() {
return passwordPolicyProperties.isKeyboardSequenceBlocked();
}
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/
@ModelAttribute("activeProfileBadge")
public String activeProfileBadge() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return null;
}
String[] activeProfiles = environment.getActiveProfiles();
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
}
/**
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
@@ -121,6 +147,61 @@ public class GlobalControllerAdvice {
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
}
/**
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
*/
@ModelAttribute("brandName")
public String brandName() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
}
/**
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
*/
@ModelAttribute("brandNameJosaGa")
public String brandNameJosaGa() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* brandName 뒤에 바로 붙는 보조사(은/는).
*/
@ModelAttribute("brandNameJosaEun")
public String brandNameJosaEun() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
*/
@ModelAttribute("brandLogoHeaderPath")
public String brandLogoHeaderPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
}
/**
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
*/
@ModelAttribute("brandLogoFooterPath")
public String brandLogoFooterPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
}
private boolean hasBatchim(String word) {
if (word == null || word.isEmpty()) {
return false;
}
char last = word.charAt(word.length() - 1);
if (last >= 0xAC00 && last <= 0xD7A3) {
return (last - 0xAC00) % 28 != 0;
}
return "AEIOUaeiou".indexOf(last) < 0;
}
/**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.common.exception;
/**
* 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단).
*
* <p>JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신
* flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.</p>
*/
public class TooManyWriteRequestsException extends RuntimeException {
public TooManyWriteRequestsException(String message) {
super(message);
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.groovy.util.Maps;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 계정 잠금 처리 단일 지점.
*
* <p>로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패
* (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountLockService {
private final PortalUserRepository portalUserRepository;
private final MessageHandlerService messageHandlerService;
/**
* 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다.
*
* <p>호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의
* {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이
* 잠그게 된다.</p>
*
* @param reason 알림 문구에 실을 잠금 사유
*/
@Transactional
public void lock(PortalUser user, String reason) {
if (user == null) {
return;
}
if ("Y".equals(user.getAccountLockYn())) {
return;
}
user.setAccountLockYn("Y");
portalUserRepository.save(user);
messageHandlerService.publishEvent(MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user), Maps.of("reason", reason));
log.warn("계정 잠금 처리 userId={} reason={}", user.getId(), reason);
}
}
@@ -0,0 +1,75 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 중요 페이지 진입용 비밀번호 재인증(본인확인)의 연속 실패 정책을 DB(PortalProperty)에서 조회한다.
*
* <ul>
* <li>{@code password.confirm.failure.limit} - 연속 실패 허용 횟수 (기본 {@value #DEFAULT_LIMIT})</li>
* <li>{@code password.confirm.failure.action} - 임계 초과 시 조치 ({@code LOGOUT} / {@code LOCK})</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordConfirmFailurePolicy {
private static final String GROUP = "Portal";
private static final String NAME_LIMIT = "password.confirm.failure.limit";
private static final String NAME_ACTION = "password.confirm.failure.action";
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 횟수 */
public static final int DEFAULT_LIMIT = 5;
/** 임계 초과 시 조치 */
public enum Action {
/** 세션만 강제 종료 (기본) */
LOGOUT,
/** 계정을 잠근 뒤 세션도 종료 — 재로그인 자체가 차단된다 */
LOCK;
public static final Action DEFAULT = LOGOUT;
public static Action from(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return DEFAULT;
}
try {
return Action.valueOf(raw.trim().toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("{} 값이 유효하지 않음('{}') - 기본값 {} 사용", NAME_ACTION, raw, DEFAULT);
return DEFAULT;
}
}
}
private final PortalPropertyService portalPropertyService;
/** 연속 실패가 이 값 이상이면 {@link #action()} 을 수행한다. */
public int limit() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME_LIMIT, String.valueOf(DEFAULT_LIMIT),
"본인확인(비밀번호 재인증) 연속 실패 허용 횟수 (이 값 이상 실패 시 조치)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed > 0) {
return parsed;
}
log.warn("{} 값이 0 이하({}) - 기본값 {} 사용", NAME_LIMIT, parsed, DEFAULT_LIMIT);
} catch (NumberFormatException e) {
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME_LIMIT, raw, DEFAULT_LIMIT);
}
return DEFAULT_LIMIT;
}
/** 임계 초과 시 조치 */
public Action action() {
return Action.from(portalPropertyService.getOrCreateProperty(
GROUP, NAME_ACTION, Action.DEFAULT.name(),
"본인확인 연속 실패 임계 초과 시 조치 (LOGOUT=강제 로그아웃, LOCK=계정 차단 후 로그아웃)"));
}
}
@@ -0,0 +1,131 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.PasswordConfirmFailurePolicy.Action;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Optional;
/**
* 중요 페이지 진입 시 비밀번호 재인증(본인확인)의 연속 실패를 추적하고 임계 초과 시 조치한다.
*
* <p>무차별 대입 방어. 카운터는 세션 attribute 로 유지하고, 임계 초과 시
* {@link PasswordConfirmFailurePolicy.Action} 에 따라 강제 로그아웃하거나 계정을 잠근다.
* step-up 확인 페이지·webhook·비밀번호 변경 진입이 모두 이 클래스를 쓴다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PasswordConfirmFailureTracker {
/** 연속 실패 횟수 세션 attribute 키 */
public static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
private final PasswordConfirmFailurePolicy policy;
private final UserSessionService userSessionService;
private final AccountLockService accountLockService;
private final PortalUserRepository portalUserRepository;
/** 실패 처리 결과 */
@Getter
public static class Outcome {
private final int failCount;
private final int limit;
private final boolean forcedLogout;
private final boolean accountLocked;
private final String message;
Outcome(int failCount, int limit, boolean forcedLogout, boolean accountLocked, String message) {
this.failCount = failCount;
this.limit = limit;
this.forcedLogout = forcedLogout;
this.accountLocked = accountLocked;
this.message = message;
}
}
/** 확인 성공 시 카운터 초기화 */
public void reset(HttpSession session) {
if (session != null) {
session.removeAttribute(ATTR_FAIL_COUNT);
}
}
/**
* 실패 1회를 기록하고, 임계에 도달했으면 정책에 따라 조치(로그아웃 또는 계정 잠금 후 로그아웃)한다.
* 조치가 수행되면 호출자는 로그인 화면으로 보내기만 하면 된다.
*/
public Outcome recordFailure(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
int limit = policy.limit();
int failCount = increment(session);
if (failCount < limit) {
return new Outcome(failCount, limit, false, false,
policy.action() == Action.LOCK
? "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 계정 이용이 제한됩니다."
: "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 자동 로그아웃됩니다.");
}
boolean locked = false;
if (policy.action() == Action.LOCK) {
locked = lockCurrentUser(limit);
}
forceLogout(session, request, response);
log.warn("본인확인 비밀번호 반복 실패로 {} 처리", locked ? "계정 차단" : "강제 로그아웃");
return new Outcome(failCount, limit, true, locked,
locked
? "비밀번호 확인 반복 실패로 계정이 차단되었습니다. 관리자에게 문의해 주세요."
: "비밀번호 확인 반복 실패로 로그아웃되었습니다.");
}
private int increment(HttpSession session) {
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
int next = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_FAIL_COUNT, next);
return next;
}
/**
* 현재 사용자를 잠근다. 로그인 실패 카운트도 임계치로 맞춰 두어야
* 세션을 새로 받아도 잠금 상태가 이어진다.
*/
private boolean lockCurrentUser(int limit) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return false;
}
Optional<PortalUser> found = portalUserRepository.findById(current.getId());
if (!found.isPresent()) {
return false;
}
PortalUser user = found.get();
if (user.getLoginFailureCount() == null || user.getLoginFailureCount() < limit) {
user.setLoginFailureCount(limit);
portalUserRepository.save(user);
}
accountLockService.lock(user, "본인확인 비밀번호 반복 실패로 인한 계정 차단");
return true;
}
private void forceLogout(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
// SecurityContextLogoutHandler 는 HTTP 세션만 무효화하므로 DB 세션 레코드를 먼저 정리한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
}
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 비밀번호 문자열 정책 중 DB(PortalProperty)로 on/off 하는 항목을 조회한다.
*
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
* 의 자동 생성이 동작하도록 한다.</p>
*
* <ul>
* <li>{@code password.keyboard-sequence.block} - 키보드 배열 연속 문자(qwe/asd 등) 사용 금지</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class PasswordPolicyProperties {
private static final String GROUP = "Portal";
private static final String NAME_KEYBOARD_SEQUENCE = "password.keyboard-sequence.block";
/** 프로퍼티 미존재/파싱 실패 시 기본값 (차단) */
public static final boolean DEFAULT_KEYBOARD_SEQUENCE_BLOCK = true;
private final PortalPropertyService portalPropertyService;
/** 키보드 배열 연속 문자 3자리 이상 사용 금지 여부 */
public boolean isKeyboardSequenceBlocked() {
return Boolean.parseBoolean(
portalPropertyService.getOrCreateProperty(GROUP, NAME_KEYBOARD_SEQUENCE,
String.valueOf(DEFAULT_KEYBOARD_SEQUENCE_BLOCK),
"키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 사용 금지 여부 (true/false)").trim());
}
}
@@ -0,0 +1,49 @@
package com.eactive.apim.portal.common.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 기동 시 보안 정책 PTL_PROPERTY 를 미리 생성한다.
*
* <p>PTL_PROPERTY 에는 (GROUP, NAME) 유니크 제약이 없어
* {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합하면 같은 키가 중복 INSERT 된다
* (중복 시 Hibernate 가 {@code More than one row with the given identifier} 로 실패한다).
* 특히 {@link PasswordPolicyProperties} 는 GlobalControllerAdvice 를 통해 <b>매 요청</b> 조회되므로
* 기동 직후 동시 접속에서 경합할 가능성이 높다.</p>
*
* <p>부팅 완료 시점에 단일 스레드로 한 번 조회해 두면 이후 요청은 항상 기존 행을 읽는다.
* {@code PortalPropertyDuplicateChecker} 보다 먼저 실행되도록 우선순위를 높인다.</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecurityPolicyPropertySeeder implements ApplicationListener<ApplicationReadyEvent> {
private final PasswordPolicyProperties passwordPolicyProperties;
private final WriteRateLimitPolicy writeRateLimitPolicy;
private final PasswordConfirmFailurePolicy passwordConfirmFailurePolicy;
@Override
@Transactional
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
boolean keyboardSequenceBlock = passwordPolicyProperties.isKeyboardSequenceBlocked();
int writeLimit = writeRateLimitPolicy.perMinuteLimit();
int confirmLimit = passwordConfirmFailurePolicy.limit();
PasswordConfirmFailurePolicy.Action confirmAction = passwordConfirmFailurePolicy.action();
log.info("[보안 정책] keyboard-sequence.block={}, write.rate-limit.per-minute={}, "
+ "password.confirm.failure.limit={}, password.confirm.failure.action={}",
keyboardSequenceBlock, writeLimit, confirmLimit, confirmAction);
} catch (Exception e) {
log.warn("[보안 정책] PTL_PROPERTY 사전 생성 실패 — 최초 요청 시 생성된다", e);
}
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 게시물·댓글·피드백 작성의 1분당 허용 건수를 DB(PortalProperty)에서 조회한다.
*
* <p>PTL_PROPERTY (group={@code Portal}, name={@code write.rate-limit.per-minute}) 값으로 제어한다.
* 허용 범위는 {@value #MIN_LIMIT}~{@value #MAX_LIMIT} 이며 벗어나거나 숫자가 아니면 기본값
* {@value #DEFAULT_LIMIT} 로 동작한다. {@code 0} 은 제한 없음을 뜻한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WriteRateLimitPolicy {
private static final String GROUP = "Portal";
private static final String NAME = "write.rate-limit.per-minute";
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 건수 */
public static final int DEFAULT_LIMIT = 1;
private static final int MIN_LIMIT = 0;
private static final int MAX_LIMIT = 10;
private final PortalPropertyService portalPropertyService;
/** 1분당 작성 허용 건수. 0 이면 제한하지 않는다. */
public int perMinuteLimit() {
String raw = portalPropertyService.getOrCreateProperty(
GROUP, NAME, String.valueOf(DEFAULT_LIMIT),
"1분당 게시물·댓글·피드백 작성 허용 건수 (" + MIN_LIMIT + "~" + MAX_LIMIT + ", 0=제한없음)");
try {
int parsed = Integer.parseInt(raw.trim());
if (parsed >= MIN_LIMIT && parsed <= MAX_LIMIT) {
return parsed;
}
log.warn("{} 값이 허용 범위({}~{}) 밖({}) - 기본값 {} 사용", NAME, MIN_LIMIT, MAX_LIMIT, parsed, DEFAULT_LIMIT);
} catch (NumberFormatException e) {
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME, raw, DEFAULT_LIMIT);
}
return DEFAULT_LIMIT;
}
}
@@ -0,0 +1,119 @@
package com.eactive.apim.portal.common.security;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 작성 요청 빈도 제한(무제한 요청 차단).
*
* <p>카운터는 대상별로 독립이며 저장소는 DB 다 — 세션 재발급·다중 탭으로 우회할 수 없다.
* 허용 건수는 {@link WriteRateLimitPolicy} 가 PortalProperty 에서 읽는다.</p>
*
* <p>판정은 "최신 N건을 뽑아 N번째가 1분 이내인가" 로 한다. {@code createdDate} 는
* {@code LocalDateTimeToStringConverter} 로 문자열 저장되므로 쿼리에서 범위 비교하지 않고
* Java 에서 비교한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WriteRateLimitService {
/** 빈도 판정 기준 구간 */
private static final Duration WINDOW = Duration.ofMinutes(1);
/** 빈도 제한 대상. 대상별로 카운터가 독립이다. */
public enum WriteTarget {
INQUIRY("Q&A 문의"),
INQUIRY_COMMENT("댓글"),
PARTNERSHIP("피드백/개선요청");
private final String label;
WriteTarget(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
private final WriteRateLimitPolicy writeRateLimitPolicy;
private final InquiryRepository inquiryRepository;
private final InquiryCommentRepository inquiryCommentRepository;
private final PartnershipApplicationRepository partnershipApplicationRepository;
/** 현재 로그인 사용자 기준으로 허용 빈도를 넘었는지 */
public boolean isExceeded(WriteTarget target) {
return isExceeded(target, currentUserId());
}
/** 허용 빈도를 넘었는지. userId 가 없으면(비인증) 판정하지 않는다. */
@Transactional(readOnly = true)
public boolean isExceeded(WriteTarget target, String userId) {
int limit = writeRateLimitPolicy.perMinuteLimit();
if (limit <= 0 || userId == null || userId.isEmpty()) {
return false;
}
List<? extends Auditable> recent = findRecent(target, userId, limit);
if (recent.size() < limit) {
return false;
}
LocalDateTime oldestOfWindow = recent.get(limit - 1).getCreatedDate();
boolean exceeded = oldestOfWindow != null && oldestOfWindow.isAfter(LocalDateTime.now().minus(WINDOW));
if (exceeded) {
log.warn("작성 빈도 제한 초과 target={} limit={}/분", target, limit);
}
return exceeded;
}
/** 허용 빈도를 넘었으면 예외. JSON 응답 컨트롤러 전용. */
public void assertNotExceeded(WriteTarget target) {
if (isExceeded(target)) {
throw new TooManyWriteRequestsException(exceededMessage(target));
}
}
/** 사용자에게 보여줄 차단 안내 문구 */
public String exceededMessage(WriteTarget target) {
return target.getLabel() + " 작성은 1분에 " + writeRateLimitPolicy.perMinuteLimit()
+ "건까지 가능합니다. 잠시 후 다시 시도해 주세요.";
}
/** 감사 필드 createdBy 와 동일한 식별자(PortalUser.id). 비인증이면 null. */
private static String currentUserId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user == null ? null : user.getId();
}
private List<? extends Auditable> findRecent(WriteTarget target, String userId, int limit) {
PageRequest page = PageRequest.of(0, limit);
switch (target) {
case INQUIRY:
return inquiryRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
case INQUIRY_COMMENT:
return inquiryCommentRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
case PARTNERSHIP:
return partnershipApplicationRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
default:
return Collections.emptyList();
}
}
}
@@ -1,7 +1,9 @@
package com.eactive.apim.portal.common.validator;
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import org.apache.commons.beanutils.PropertyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
@@ -26,6 +28,23 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
/** 키보드 배열 인접 판정 기준 길이 (기존 연속 문자/숫자 규칙과 동일하게 3자) */
private static final int KEYBOARD_SEQUENCE_LENGTH = 3;
/** 키보드 배열 행. 대문자 기준으로 비교한다. */
private static final String[] KEYBOARD_ROWS = {
"QWERTYUIOP",
"ASDFGHJKL",
"ZXCVBNM",
"1234567890"
};
/**
* 키보드 연속 문자 차단 여부. Spring 이 관리하는 경로(LocalValidatorFactoryBean)에서만 주입되며,
* 순수 {@code new} 로 생성된 경우 null 이라 기본값(차단)으로 동작한다.
*/
@Autowired(required = false)
private PasswordPolicyProperties passwordPolicyProperties;
@Override
public void initialize(PasswordRule constraintAnnotation) {
this.password = constraintAnnotation.password();
@@ -48,10 +67,21 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
return isValid(passwordValue, loginIdValue, mobileNumberValue, keyboardSequenceBlocked());
}
/** 주입이 안 된 경우(순수 new)에는 기본값인 차단으로 동작한다. */
private boolean keyboardSequenceBlocked() {
return passwordPolicyProperties == null
? PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK
: passwordPolicyProperties.isKeyboardSequenceBlocked();
}
public boolean isValid(String password, String loginId, String mobileNumber) {
return isValid(password, loginId, mobileNumber, PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK);
}
public boolean isValid(String password, String loginId, String mobileNumber, boolean blockKeyboardSequence) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
@@ -81,6 +111,11 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return false;
}
// 키보드 배열 연속 문자 체크 (PortalProperty 로 on/off)
if (blockKeyboardSequence && hasKeyboardSequence(password)) {
return false;
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
@@ -118,6 +153,29 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
return true;
}
/**
* 키보드 배열상 인접한 문자가 {@value #KEYBOARD_SEQUENCE_LENGTH} 자 이상 이어지는지 (정·역방향 모두).
*
* <p>같은 행(row) 안에서 좌우로 이어지는 경우만 본다. 예) {@code qwe}, {@code asd}, {@code trewq}.
* 클라이언트 {@code static/js/password-policy.js} 의 {@code hasKeyboardSequence} 와 동일 규칙이어야 한다.</p>
*/
public static boolean hasKeyboardSequence(String password) {
if (password == null || password.length() < KEYBOARD_SEQUENCE_LENGTH) {
return false;
}
String tmpPw = password.toUpperCase();
for (int i = 0; i <= tmpPw.length() - KEYBOARD_SEQUENCE_LENGTH; i++) {
String chunk = tmpPw.substring(i, i + KEYBOARD_SEQUENCE_LENGTH);
String reversed = new StringBuilder(chunk).reverse().toString();
for (String row : KEYBOARD_ROWS) {
if (row.contains(chunk) || row.contains(reversed)) {
return true;
}
}
}
return false;
}
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
public static boolean containsLoginIdLocalPart(String password, String loginId) {
if (password == null || loginId == null || loginId.isEmpty()) {
@@ -4,16 +4,13 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.security.AccountLockService;
import com.eactive.apim.portal.common.security.LoginLockPolicy;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import org.apache.groovy.util.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException;
@@ -47,18 +44,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
private final PortalUserRepository portalUserRepository;
private final PortalUserLogService userLogService;
private final MessageHandlerService messageHandlerService;
private final LoginLockPolicy loginLockPolicy;
private final AccountLockService accountLockService;
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
PortalUserLogService userLogService,
MessageHandlerService messageHandlerService,
LoginLockPolicy loginLockPolicy) {
LoginLockPolicy loginLockPolicy,
AccountLockService accountLockService) {
this.portalUserRepository = portalUserRepository;
this.userLogService = userLogService;
this.messageHandlerService = messageHandlerService;
this.loginLockPolicy = loginLockPolicy;
this.accountLockService = accountLockService;
}
@Override
@@ -79,16 +76,10 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
int lockCount = loginLockPolicy.lockCount();
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
if (user.getLoginFailureCount() >= lockCount) {
user.setAccountLockYn("Y");
// 계정 잠금 알림
messageHandlerService.publishEvent(
MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user),
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
}
portalUserRepository.save(user);
if (user.getLoginFailureCount() >= lockCount) {
accountLockService.lock(user, lockCount + "회 이상 로그인 실패로 인한 계정 잠금");
}
@@ -132,7 +132,10 @@ public class PortalConfigSecurity {
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
// /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식.
.ignoringRequestMatchers(
new AntPathRequestMatcher("/internal/menu/**"),
new AntPathRequestMatcher("/internal/test-cleanup/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
@Value("${app.web-resources.static-base:classpath:/static/}")
private String staticBase;
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
}
/**
@@ -27,13 +27,23 @@ import java.util.Optional;
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*
* <p>API 그룹으로 특정 법인에게만 공개된 API 의 이슈는, 그 API 를 조회할 수 없는 사용자에게는
* 이슈 자체(제목·요약·타임라인 포함)를 노출하지 않는다. 영향 API 가 하나도 없는 이슈(전사 공지성)는
* 그대로 노출한다. {@code visibleApiIds} 는 {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}
* 로 구한, 현재 사용자에게 공개된 API ID 집합이다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y'))"
+ " AND (NOT EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia"
+ " WHERE ia.incidentId = i.incidentId)"
+ " OR EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia2"
+ " WHERE ia2.incidentId = i.incidentId"
+ " AND ia2.apiId IN :visibleApiIds)) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
@@ -55,7 +65,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
@Param("closedStates") Collection<IncidentState> closedStates,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
@@ -66,7 +77,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now);
@Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
@@ -80,6 +92,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds,
Pageable pageable);
/**
@@ -92,7 +105,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("to") LocalDateTime to,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
@@ -106,7 +120,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("apiId") String apiId);
@Param("apiId") String apiId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 공개 상세 (P7)
@@ -114,5 +129,6 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
}
@@ -152,7 +152,8 @@ public class ApiCurrentStatusService {
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
@@ -178,7 +179,8 @@ public class ApiCurrentStatusService {
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now,
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList());
if (maintenances.isEmpty()) {
@@ -208,7 +210,8 @@ public class ApiCurrentStatusService {
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
@@ -16,6 +16,7 @@ import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
@@ -99,6 +100,19 @@ public class ApiStatusCatalogService {
return names;
}
/** IN 절 바인딩용 API ID 없을 리 없는 더미값. 실제 API ID 는 절대 이 값이 될 수 없다 */
private static final List<String> NO_VISIBLE_API = Collections.singletonList("__NO_VISIBLE_API__");
/**
* {@link ApiStatusIncidentQueryRepository} 의 {@code :visibleApiIds} 바인딩용.
* Oracle 은 빈 컬렉션으로 {@code IN ()} 을 만들면 구문 오류가 나므로, 조회 가능한 API 가
* 하나도 없을 때는 절대 매치되지 않는 더미값으로 대체한다.
*/
public Collection<String> getVisibleApiIdsForQuery() {
Map<String, String> names = getVisibleApiNames();
return names.isEmpty() ? NO_VISIBLE_API : names.keySet();
}
/**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
*
@@ -130,9 +130,10 @@ public class ApiStatusIssueHistoryService {
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
java.util.Collection<String> visibleApiIds = catalogService.getVisibleApiIdsForQuery();
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
? incidentQueryRepository.findVisibleOverlapping(from, to, visibleApiIds)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId, visibleApiIds);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
@@ -25,19 +25,22 @@ public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
return assembler.toActiveIncidents(incidents, now);
}
/** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now(),
catalogService.getVisibleApiIdsForQuery());
return assembler.toMaintenanceCards(incidents);
}
@@ -46,14 +49,14 @@ public class ApiStatusQueryService {
int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit))
catalogService.getVisibleApiIdsForQuery(), PageRequest.of(0, limit))
.getContent();
return assembler.toPastIssueCards(incidents);
}
/** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId)
return incidentQueryRepository.findVisibleById(incidentId, catalogService.getVisibleApiIdsForQuery())
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
}
}
@@ -34,6 +34,7 @@ public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusCatalogService catalogService;
/** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) {
@@ -43,7 +44,8 @@ public class ApiStatusUptimeService {
LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery());
List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) {
@@ -66,7 +68,8 @@ public class ApiStatusUptimeService {
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.community.qna.comment.controller;
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
import com.eactive.apim.portal.common.security.WriteRateLimitService;
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
@@ -32,6 +35,7 @@ import java.util.Map;
public class InquiryCommentController {
private final InquiryCommentFacade inquiryCommentFacade;
private final WriteRateLimitService writeRateLimitService;
@GetMapping("/{inquiryId}/comments")
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
@@ -42,6 +46,7 @@ public class InquiryCommentController {
@PostMapping("/{inquiryId}/comments")
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
@Valid @RequestBody InquiryCommentCreateRequest request) {
writeRateLimitService.assertNotExceeded(WriteTarget.INQUIRY_COMMENT);
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
inquiryId, request.getContent(), request.getVisibility(), current);
@@ -65,6 +70,12 @@ public class InquiryCommentController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
}
@ExceptionHandler(TooManyWriteRequestsException.class)
public ResponseEntity<Map<String, String>> handleTooManyRequests(TooManyWriteRequestsException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(errorBody("TOO_MANY_REQUESTS", ex.getMessage()));
}
private Map<String, String> errorBody(String code, String message) {
Map<String, String> body = new HashMap<>();
body.put("code", code);
@@ -1,9 +1,11 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
@@ -17,4 +19,18 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
+ " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@Param("delYn") String delYn);
@Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
@Transactional
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
/**
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 댓글을 최신순으로 조회한다(Pageable 로 건수 제한).
* createdBy 는 결정적 암호화라 평문 사용자 id 로 등가 조회가 가능하다.
* 삭제(delYn='Y')된 댓글도 포함한다 — 작성 후 지우는 방식의 우회를 막기 위함.
*/
List<InquiryComment> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
}
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 이력 1건. HttpSession(속성 {@code NOTI_TEST_HISTORY})에 리스트로 보관한다.
*
* <p>DB 테이블을 신설하지 않는 요구사항에 따라, 상태는 저장하지 않고 매 폴링마다
* {@code requestIds}로 PTL_MESSAGE_REQUEST를 다시 조회해 {@link #refreshStatus} 로 갱신한다.</p>
*/
@Getter
public class NotiTestHistoryEntry implements Serializable {
private static final long serialVersionUID = 1L;
private final String entryId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private final LocalDateTime requestedAt;
private final NotiTestTarget.Category category;
private final String targetLabel;
private final String messageCode;
private final NotiTestTarget.Strategy strategy;
private final String recipient;
private final List<String> messages;
private final List<String> requestIds;
private final Map<String, String> statuses = new LinkedHashMap<>();
private final String warning;
private final String errorMessage;
private NotiTestHistoryEntry(NotiTestTarget target, String recipient, NotiTestResult result) {
this.entryId = UUID.randomUUID().toString();
this.requestedAt = LocalDateTime.now();
this.category = target.getCategory();
this.targetLabel = target.getLabel();
this.messageCode = target.getMessageCode().name();
this.strategy = target.getStrategy();
this.recipient = recipient;
this.messages = result.getCreated().stream()
.map(MessageRequest::getMessage)
.distinct()
.collect(Collectors.toList());
this.requestIds = result.getCreated().stream().map(MessageRequest::getId).collect(Collectors.toList());
this.warning = result.getWarning();
this.errorMessage = result.getErrorMessage();
for (MessageRequest request : result.getCreated()) {
this.statuses.put(request.getId(), request.getRequestStatus());
}
}
public static NotiTestHistoryEntry of(NotiTestTarget target, String recipient, NotiTestResult result) {
return new NotiTestHistoryEntry(target, recipient, result);
}
/** 폴링 시 최신 상태로 덮어쓴다. 세션에 다시 저장하는 쪽은 호출부 책임이다. */
public void refreshStatus(Map<String, MessageRequest> byId) {
for (String id : requestIds) {
MessageRequest request = byId.get(id);
if (request != null) {
statuses.put(id, request.getRequestStatus());
}
}
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 알림 발송 테스트 페이지 관련 {@code PTL_PROPERTY} 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다({@link com.eactive.apim.portal.djb.swing.SwingNotifyProperties} 참고).
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를 생성하므로
* 별도 초기 데이터 없이도 동작하며, 운영자는 PTL_PROPERTY 값만 바꿔 재배포 없이 on/off·계정 추가가 가능하다.</p>
*/
@Component
@RequiredArgsConstructor
public class NotiTestProperties {
public static final String GROUP = "Portal";
public static final String KEY_ENABLED = "notification-test.enabled";
public static final String KEY_ALLOWED_EMAILS = "notification-test.allowed-emails";
private static final String LIST_DELIMITERS = "[,;\\r\\n]";
private final PortalPropertyService portalPropertyService;
public boolean isEnabled() {
String value = resolve(KEY_ENABLED, "false", "알림 발송 테스트 페이지 활성화 여부(true/false)");
return "true".equalsIgnoreCase(value == null ? null : value.trim());
}
public boolean isAllowed(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}
String allowList = resolve(KEY_ALLOWED_EMAILS, "",
"알림 발송 테스트 페이지 접근 허용 이메일 목록 (콤마/세미콜론/줄바꿈 구분)");
if (allowList == null || allowList.trim().isEmpty()) {
return false;
}
String target = email.trim();
for (String token : allowList.split(LIST_DELIMITERS)) {
String candidate = token.trim();
if (!candidate.isEmpty() && candidate.equalsIgnoreCase(target)) {
return true;
}
}
return false;
}
private String resolve(String key, String defaultValue, String description) {
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
}
}
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.template.entity.MessageRequest;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
/**
* {@link NotiTestService#send} 한 번 호출의 결과. 화면/세션에는 이 값을 그대로 담지 않고
* {@link NotiTestHistoryEntry}로 변환해서 보관한다(엔티티를 세션에 직렬화하지 않기 위함).
*/
@Getter
public class NotiTestResult {
private final boolean success;
private final List<MessageRequest> created;
private final String warning;
private final String errorMessage;
private NotiTestResult(boolean success, List<MessageRequest> created, String warning, String errorMessage) {
this.success = success;
this.created = created;
this.warning = warning;
this.errorMessage = errorMessage;
}
public static NotiTestResult of(List<MessageRequest> created, String warning) {
return new NotiTestResult(true, created == null ? new ArrayList<>() : created, warning, null);
}
public static NotiTestResult error(String errorMessage) {
return new NotiTestResult(false, new ArrayList<>(), null, errorMessage);
}
}
@@ -0,0 +1,121 @@
package com.eactive.apim.portal.djb.notitest;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.djb.swing.SwingMessageWriter;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import com.eactive.apim.portal.template.service.MessageSendService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지의 발송 로직. {@link NotiTestTarget.Strategy} 에 따라 세 갈래로 분기한다.
*
* <p>HANDLER 전략은 {@code MessageHandlerService.publishEvent}(이벤트버스, fire-and-forget)를 쓰지 않는다 —
* 반환값을 받을 방법이 없기 때문이다. 대신 핸들러의 {@code createEvent}만 재사용해 파라미터 매핑 로직은
* 그대로 살리고, {@code MessageSendService.sendMessage}를 직접 호출해 생성된 {@link MessageRequest} 목록을
* 돌려받는다. {@code MessageSendEvent} 구독자는 {@code MessageHandlerService.handleMessageSendEvent} 한
* 곳뿐이라 이 우회는 안전하다.</p>
*/
@Service
@RequiredArgsConstructor
public class NotiTestService {
private final MessageHandlerService messageHandlerService;
private final MessageSendService messageSendService;
private final SwingMessageWriter swingMessageWriter;
private final MessageTemplateRepository messageTemplateRepository;
public NotiTestResult send(String messageCodeName, Map<String, String> formParams) {
Optional<NotiTestTarget> targetOpt = NotiTestTarget.byMessageCode(messageCodeName);
if (!targetOpt.isPresent()) {
return NotiTestResult.error("알 수 없는 테스트 대상입니다: " + messageCodeName);
}
NotiTestTarget target = targetOpt.get();
Map<String, String> domainParams = extractDomainParams(target, formParams);
switch (target.getStrategy()) {
case HANDLER:
return sendViaHandler(target, buildRecipient(formParams), domainParams);
case DIRECT:
return sendDirect(target, buildRecipient(formParams), domainParams);
case BROADCAST:
return sendBroadcast(target, domainParams);
default:
return NotiTestResult.error("알 수 없는 발송 전략입니다: " + target.getStrategy());
}
}
private NotiTestResult sendViaHandler(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
MessageEventHandler handler = messageHandlerService.getHandlers().get(target.getMessageCode().name());
if (handler == null) {
// 카탈로그가 HANDLER 로 분류했는데 빈이 없는 방어적 상황(운영 배포 누락 등)
return NotiTestResult.error("핸들러 빈이 등록되어 있지 않습니다: " + target.getMessageCode());
}
Map<String, Object> rawParams = new HashMap<>(domainParams);
MessageSendEvent event = handler.createEvent(this, recipient, rawParams);
List<MessageRequest> created =
messageSendService.sendMessage(event.getMessageCode(), event.getRecipient(), event.getParams());
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendDirect(NotiTestTarget target, MessageRecipient recipient,
Map<String, String> domainParams) {
List<MessageRequest> created =
messageSendService.sendMessage(target.getMessageCode(), recipient, new HashMap<>(domainParams));
return NotiTestResult.of(created, additionalRecipientWarning(target));
}
private NotiTestResult sendBroadcast(NotiTestTarget target, Map<String, String> domainParams) {
Map<String, Object> params = new HashMap<>(domainParams);
List<MessageRequest> created = swingMessageWriter.write(target.getMessageCode(), params);
return NotiTestResult.of(created, null);
}
/** 실서비스 템플릿에 추가 수신자(PTL_MESSAGE_RECIPIENT)가 등록돼 있으면 테스트 발송에도 함께 나간다는 경고. */
private String additionalRecipientWarning(NotiTestTarget target) {
return messageTemplateRepository.findById(target.getMessageCode().name())
.map(t -> t.getAdditionalRecipients())
.filter(list -> list != null && !list.isEmpty())
.map(list -> "이 항목은 실서비스 템플릿을 사용합니다. 등록된 추가 수신자 "
+ list.size() + "명에게도 함께 발송됩니다.")
.orElse(null);
}
/** {@code param_<key>} 폼 필드를 읽되, 비어있으면 카탈로그의 샘플값으로 채운다. */
private Map<String, String> extractDomainParams(NotiTestTarget target, Map<String, String> formParams) {
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, String> sample : target.getSampleParams().entrySet()) {
String value = formParams.get("param_" + sample.getKey());
result.put(sample.getKey(), StringUtils.hasText(value) ? value.trim() : sample.getValue());
}
return result;
}
private MessageRecipient buildRecipient(Map<String, String> formParams) {
String email = trimToNull(formParams.get("recipientEmail"));
return MessageRecipient.builder()
.username(trimToNull(formParams.get("recipientUsername")))
.userId(email)
.email(email)
.phone(trimToNull(formParams.get("recipientPhone")))
.messengerId(trimToNull(formParams.get("recipientMessengerId")))
.build();
}
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}
@@ -0,0 +1,116 @@
package com.eactive.apim.portal.djb.notitest.catalog;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.Getter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 테스트 페이지가 다루는 16종 테스트 대상 카탈로그.
*
* <p>발송 경로가 코드 위치에 따라 세 갈래로 갈린다({@link Strategy} 참고). 항목을 추가/변경할 때는
* 이 enum 하나만 고치면 된다 — 컨트롤러/서비스/화면은 이 카탈로그를 그대로 순회한다.</p>
*/
@Getter
public enum NotiTestTarget {
// ---- 전략 HANDLER: elink-portal-common 의 MessageEventHandler 재사용 ----
EMAIL_VERIFICATION(Category.EMAIL, "이메일 인증코드", MessageCode.USER_VERIFICATION_EMAIL,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_VERIFICATION(Category.SMS, "SMS 인증코드", MessageCode.USER_VERIFICATION_MOBILEPHONE,
Strategy.HANDLER, params("authNumber", "123456")),
SMS_ACCOUNT_LOCKED(Category.SMS, "계정 잠김", MessageCode.USER_ACCOUNT_LOCKED,
Strategy.HANDLER, params("reason", "로그인 5회 실패")),
SMS_PASSWORD_CHANGED(Category.SMS, "비밀번호 변경", MessageCode.USER_PASSWORD_CHANGED,
Strategy.HANDLER, params()),
SMS_INVITATION(Category.SMS, "법인 내 초대", MessageCode.USER_INVITATION,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동",
"url", "https://example.com/invite",
"authNumber", "123456")),
SMS_INVITATION_CANCELED(Category.SMS, "법인 내 초대 취소", MessageCode.USER_INVITATION_CANCELED,
Strategy.HANDLER, params(
"corpName", "(주)테스트법인",
"managerName", "홍길동")),
// ---- 전략 DIRECT: eapim-admin 전용 핸들러(클래스패스 미공유) — MessageSendService 직접 호출 ----
SMS_ORG_APPROVED(Category.SMS, "서비스 가입 승인", MessageCode.MANAGER_WITH_ORG_REGISTER_APPROVED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인")),
SMS_ORG_REJECTED(Category.SMS, "서비스 가입 거절", MessageCode.MANAGER_WITH_ORG_REGISTER_REJECTED,
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인", "REASON", "서류 미비")),
SMS_APP_APPROVED(Category.SMS, "클라이언트 승인", MessageCode.APP_APPROVE,
Strategy.DIRECT, params("APP_NAME", "테스트앱")),
SMS_APP_REJECTED(Category.SMS, "클라이언트 거절", MessageCode.APP_REJECTED,
Strategy.DIRECT, params("APP_NAME", "테스트앱", "REASON", "정책 위반")),
SMS_ADMIN_LOGIN(Category.SMS, "관리자포탈 로그인", MessageCode.ADMIN_VERIFICATION_MOBILEPHONE,
Strategy.DIRECT, params("authNumber", "123456", "userName", "관리자")),
MSG_API_STATUS(Category.MESSENGER, "API 상태 감시", MessageCode.API_STATUS_CHANGED,
Strategy.DIRECT, params("message", "[테스트] API 상태 변화 알림")),
MSG_INFLOW_TOKEN(Category.MESSENGER, "이상 징후 감시", MessageCode.INFLOW_TOKEN_FAILED,
Strategy.DIRECT, params("message", "[테스트] 유량제어 토큰 획득 실패")),
// ---- 전략 BROADCAST: SwingMessageWriter 직접 호출(PTL_PROPERTY 역할의 내부직원 전원에게 발송) ----
MSG_QNA_CREATED(Category.MESSENGER, "QnA 질문등록", MessageCode.INQUIRY_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"writerName", "홍길동")),
MSG_QNA_COMMENT(Category.MESSENGER, "QnA 댓글 등록", MessageCode.INQUIRY_COMMENT_CREATED,
Strategy.BROADCAST, params(
"inquiryId", "TEST-0001",
"inquirySubject", "[테스트] 문의 제목",
"commentContent", "테스트 댓글입니다",
"writerName", "홍길동")),
MSG_PARTNERSHIP(Category.MESSENGER, "개선요청 새글 등록", MessageCode.PARTNERSHIP_CREATED,
Strategy.BROADCAST, params(
"partnershipId", "TEST-0001",
"bizSubject", "[테스트] 개선요청 제목",
"writerName", "홍길동")),
;
@Getter
public enum Category {
EMAIL("이메일"), SMS("SMS"), MESSENGER("메신저");
private final String label;
Category(String label) {
this.label = label;
}
}
public enum Strategy { HANDLER, DIRECT, BROADCAST }
private final Category category;
private final String label;
private final MessageCode messageCode;
private final Strategy strategy;
private final Map<String, String> sampleParams;
NotiTestTarget(Category category, String label, MessageCode messageCode, Strategy strategy,
Map<String, String> sampleParams) {
this.category = category;
this.label = label;
this.messageCode = messageCode;
this.strategy = strategy;
this.sampleParams = sampleParams;
}
public static Optional<NotiTestTarget> byMessageCode(String messageCodeName) {
return Arrays.stream(values())
.filter(t -> t.messageCode.name().equalsIgnoreCase(messageCodeName))
.findFirst();
}
private static Map<String, String> params(String... kv) {
Map<String, String> map = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
map.put(kv[i], kv[i + 1]);
}
return map;
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.djb.notitest.controller;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.notitest.NotiTestHistoryEntry;
import com.eactive.apim.portal.djb.notitest.NotiTestProperties;
import com.eactive.apim.portal.djb.notitest.NotiTestResult;
import com.eactive.apim.portal.djb.notitest.NotiTestService;
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 알림 발송 테스트 페이지.
*
* <p>PTL_PROPERTY({@link NotiTestProperties})로 기능 on/off 와 접근 허용 이메일 목록을 관리한다.
* 운영에서도 상시 켜둘 수 있도록 GNB 에는 노출하지 않고 직접 URL 로만 접근한다(page.yml/portal.pages
* 미등록). 발송 이력은 별도 테이블 없이 HttpSession 에 보관하고, 상태는 기존 PTL_MESSAGE_REQUEST 를
* 3초 폴링으로 재조회해 갱신한다.</p>
*/
@Slf4j
@Controller
@RequestMapping("//notitest")
@RequiredArgsConstructor
public class NotiTestController {
private static final String SESSION_HISTORY_KEY = "NOTI_TEST_HISTORY";
private static final int MAX_HISTORY = 50;
private final NotiTestProperties notiTestProperties;
private final NotiTestService notiTestService;
private final MessageRequestRepository messageRequestRepository;
/** 폼 + 이력 렌더 */
@GetMapping
public ModelAndView index(HttpSession session, HttpServletResponse response) {
if (!notiTestProperties.isEnabled()) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
}
if (!isAllowedForCurrentUser()) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return null;
}
PortalAuthenticatedUser me = SecurityUtil.getPortalAuthenticatedUser();
ModelAndView mav = new ModelAndView("djb/notitest/index");
mav.addObject("targetsByCategory", groupByCategory());
mav.addObject("history", getHistory(session));
mav.addObject("defaultUsername", me.getUserName());
mav.addObject("defaultEmail", me.getEmailAddr());
mav.addObject("defaultPhone", me.getMobileNumber());
return mav;
}
/** 발송(AJAX) */
@PostMapping("/send")
@ResponseBody
public ResponseEntity<?> send(@RequestParam Map<String, String> allParams, HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
String messageCode = allParams.get("messageCode");
if (!StringUtils.hasText(messageCode)) {
return ResponseEntity.badRequest().body(Collections.singletonMap("error", "messageCode is required"));
}
NotiTestTarget target = NotiTestTarget.byMessageCode(messageCode).orElse(null);
if (target == null) {
return ResponseEntity.badRequest()
.body(Collections.singletonMap("error", "알 수 없는 테스트 대상입니다: " + messageCode));
}
NotiTestResult result = notiTestService.send(messageCode, allParams);
NotiTestHistoryEntry entry = NotiTestHistoryEntry.of(target, resolveRecipient(target, allParams), result);
pushHistory(session, entry);
return ResponseEntity.ok(entry);
}
/** 상태 폴링(AJAX, 3초 간격) — 별도 테이블 없이 PTL_MESSAGE_REQUEST 만 재조회한다. */
@GetMapping("/status")
@ResponseBody
public ResponseEntity<?> status(HttpSession session) {
ResponseEntity<?> denied = guardResponse();
if (denied != null) {
return denied;
}
List<NotiTestHistoryEntry> history = getHistory(session);
Set<String> ids = history.stream()
.flatMap(h -> h.getRequestIds().stream())
.collect(Collectors.toSet());
if (!ids.isEmpty()) {
Map<String, MessageRequest> byId = messageRequestRepository.findAllById(ids).stream()
.collect(Collectors.toMap(MessageRequest::getId, Function.identity()));
history.forEach(h -> h.refreshStatus(byId));
session.setAttribute(SESSION_HISTORY_KEY, history);
}
return ResponseEntity.ok(history);
}
private ResponseEntity<?> guardResponse() {
if (!notiTestProperties.isEnabled()) {
return ResponseEntity.notFound().build();
}
if (!isAllowedForCurrentUser()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return null;
}
private boolean isAllowedForCurrentUser() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String email = user == null ? null : user.getEmailAddr();
boolean allowed = notiTestProperties.isAllowed(email);
if (!allowed) {
log.warn("알림 테스트 페이지 접근 거부 - email: {}", email);
}
return allowed;
}
private Map<NotiTestTarget.Category, List<NotiTestTarget>> groupByCategory() {
return Arrays.stream(NotiTestTarget.values())
.collect(Collectors.groupingBy(NotiTestTarget::getCategory, LinkedHashMap::new, Collectors.toList()));
}
private String resolveRecipient(NotiTestTarget target, Map<String, String> formParams) {
if (target.getStrategy() == NotiTestTarget.Strategy.BROADCAST) {
return "실제 담당자 전원";
}
String phone = formParams.get("recipientPhone");
String email = formParams.get("recipientEmail");
String messengerId = formParams.get("recipientMessengerId");
if (target.getCategory() == NotiTestTarget.Category.EMAIL && StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(phone)) {
return phone;
}
if (StringUtils.hasText(email)) {
return email;
}
if (StringUtils.hasText(messengerId)) {
return messengerId;
}
return "-";
}
@SuppressWarnings("unchecked")
private void pushHistory(HttpSession session, NotiTestHistoryEntry entry) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
history = (history == null) ? new ArrayList<>() : new ArrayList<>(history);
history.add(0, entry);
if (history.size() > MAX_HISTORY) {
history = new ArrayList<>(history.subList(0, MAX_HISTORY));
}
session.setAttribute(SESSION_HISTORY_KEY, history);
}
@SuppressWarnings("unchecked")
private List<NotiTestHistoryEntry> getHistory(HttpSession session) {
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
return (history == null) ? new ArrayList<>() : history;
}
}
@@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -42,38 +43,43 @@ public class SwingMessageWriter {
private final SwingNotifyProperties properties;
@Transactional
public void write(MessageCode code, Map<String, Object> params) {
public List<MessageRequest> write(MessageCode code, Map<String, Object> params) {
String role = properties.getTargetRole();
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
if (staffs == null || staffs.isEmpty()) {
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
return;
return new ArrayList<>();
}
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
if (template == null) {
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
return;
return new ArrayList<>();
}
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
code.name(), template.getEnableMessenger());
return;
return new ArrayList<>();
}
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
List<MessageRequest> created = new ArrayList<>();
for (UserInfo staff : staffs) {
try {
writeOne(code, template, params, staff, umsIds, policy);
MessageRequest saved = writeOne(code, template, params, staff, umsIds, policy);
if (saved != null) {
created.add(saved);
}
} catch (Exception e) {
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
}
}
return created;
}
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
private MessageRequest writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
EmployeeIdPolicy policy) {
@@ -83,10 +89,10 @@ public class SwingMessageWriter {
if (!properties.isEmployeeId(messengerId)) {
switch (policy) {
case REJECT:
return;
return null;
case REJECT_LOG:
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
return;
return null;
case SKIP:
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
STATUS_SKIPPED, messengerId, code.name());
@@ -124,8 +130,9 @@ public class SwingMessageWriter {
request.setRequestStatus(requestStatus);
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
messageRequestRepository.save(request);
MessageRequest saved = messageRequestRepository.save(request);
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
return saved;
}
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
@@ -0,0 +1,547 @@
package com.eactive.apim.portal.djb.testcleanup.controller;
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.djb.testcleanup.service.GroupRestrictedIncidentFixture;
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
* ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
* 이를 포함하지 않아 빈 자체가 없어 404.
*
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol>
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).</li>
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback).
* menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.</li>
* </ol>
*
* <p>파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며
* 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만
* 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.</p>
*
* <pre>curl -X POST -H 'X-Internal-Token: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/
@Slf4j
@Profile("playwright")
@RestController
@RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor
public class TestCleanupInternalController {
static final String PROP_GROUP = "Portal";
static final String PROP_ALLOW_IPS = "testcleanup.internal.allow-ips";
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
static final String PROP_ALLOW_IPS_DESCRIPTION =
"테스트 정리 내부 API(법인/계정/고아데이터 삭제) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
private final TestCleanupService testCleanupService;
private final OrphanCleanupService orphanCleanupService;
private final PortalPropertyService portalPropertyService;
private final InternalApiTokenService internalApiTokenService;
@PostMapping("/org")
public ResponseEntity<Map<String, Object>> deleteOrg(
@RequestParam String compRegNo, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(compRegNo)) {
return badRequest("compRegNo 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteOrgCascade(compRegNo);
log.info("테스트 정리(org) 실행 - compRegNo: {}, from: {}", compRegNo, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("compRegNo", compRegNo);
body.put("orgId", result.getTargetId());
body.put("orgFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/user")
public ResponseEntity<Map<String, Object>> deleteUser(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteUserCascade(email);
log.info("테스트 정리(user) 실행 - email: {}, from: {}", email, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다.
*/
@GetMapping("/user/exists")
public ResponseEntity<Map<String, Object>> checkUserExists(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.checkUserExists(email);
log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다.
* API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을
* 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다.
*/
@GetMapping("/user/status")
public ResponseEntity<Map<String, Object>> getUserStatus(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
Optional<PortalUser> userOpt = testCleanupService.findUserForStatus(email);
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", userOpt.isPresent());
if (userOpt.isPresent()) {
PortalUser user = userOpt.get();
PortalOrg org = user.getPortalOrg();
body.put("userId", user.getId());
body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name());
body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name());
body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name());
body.put("authCompletedYn", user.getAuthCompletedYn());
body.put("orgId", org == null ? null : org.getId());
body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name());
body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null
? null : org.getApprovalStatus().name());
}
log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}",
email, userOpt.isPresent(), request.getRemoteAddr());
return ResponseEntity.ok(body);
}
/**
* 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다.
* 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다.
*/
@PostMapping("/app-request")
public ResponseEntity<Map<String, Object>> cancelPendingTestAppRequests(
@RequestParam String email, @RequestParam String clientName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) {
return badRequest("email 과 clientName 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName);
log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}",
email, clientName, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("clientName", clientName);
body.put("appRequestFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4020 재실행 전, 해당 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다.
* 대상은 이메일(작성자)과 {@code 단위테스트*} 제목 접두사로 이중 한정한다.
*/
@PostMapping("/partnership")
public ResponseEntity<Map<String, Object>> deletePartnershipApplications(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deletePartnershipApplicationsByEmail(email, subjectPrefix);
log.info("테스트 정리(partnership) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("partnershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리), 해당 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 대상은 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사로 이중 한정한다.
*/
@PostMapping("/inquiry")
public ResponseEntity<Map<String, Object>> deleteInquiries(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInquiriesByEmail(email, subjectPrefix);
log.info("테스트 정리(inquiry) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("inquiryFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 실행 전, 지정 이메일 계정을 관리자 이메일로 조회한 법인의 ROLE_CORP_USER(법인개발자)로 준비한다.
* 이미 존재하면 재소속+비밀번호 재설정(heal), 없으면 신규 생성(create) — 두 경우 모두 응답한 비밀번호로
* 곧바로 로그인 가능한 상태가 된다.
*/
@PostMapping("/corp-developer")
public ResponseEntity<Map<String, Object>> ensureCorpDeveloper(
@RequestParam String managerEmail, @RequestParam String email, @RequestParam String password,
@RequestParam String mobile, @RequestParam String userName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(email) || StringUtils.isBlank(password)
|| StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("managerEmail, email, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result = testCleanupService.ensureTestCorpDeveloper(managerEmail, email, password, mobile, userName);
boolean created = result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0;
log.info("테스트 정리(corp-developer) 실행 - managerEmail: {}, email: {}, created: {}, from: {}",
managerEmail, email, created, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("managerEmail", managerEmail);
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("created", created);
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 그대로). 화면 가입 절차 없이 즉시 로그인
* 가능한 값으로 되돌리는 용도 — 공용 시드 계정의 비밀번호가 정책과 안 맞게 바뀐 경우 등.
*/
@PostMapping("/password")
public ResponseEntity<Map<String, Object>> resetPassword(
@RequestParam String email, @RequestParam String password, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
return badRequest("email 과 password 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.resetTestPassword(email, password);
log.info("테스트 정리(password) 실행 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개(법인전용) 필터 검증용 "다른 법인" 계정을 준비한다. 지정 사업자번호의
* 법인이 없으면 새로 만들고(승인 절차 없이 즉시 로그인 가능), 있으면 매니저 계정을 재소속·
* 비밀번호 재설정으로 되돌린다(heal). 정리는 기존 {@code /internal/test-cleanup/org} 를
* 그대로 재사용한다(같은 compRegNo).
*/
@PostMapping("/secondary-org")
public ResponseEntity<Map<String, Object>> ensureSecondaryOrg(
@RequestParam String compRegNo, @RequestParam String orgName, @RequestParam String managerEmail,
@RequestParam String password, @RequestParam String mobile, @RequestParam String userName,
HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(compRegNo) || StringUtils.isBlank(orgName) || StringUtils.isBlank(managerEmail)
|| StringUtils.isBlank(password) || StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("compRegNo, orgName, managerEmail, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result =
testCleanupService.ensureSecondaryTestOrg(compRegNo, orgName, managerEmail, password, mobile, userName);
log.info("테스트 정리(secondary-org) 실행 - compRegNo: {}, managerEmail: {}, from: {}",
compRegNo, managerEmail, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("compRegNo", compRegNo);
body.put("managerEmail", managerEmail);
body.put("orgId", result.getTargetId());
body.put("orgCreated", result.getDeletedCounts().getOrDefault("PTL_ORG_CREATED", 0L) > 0);
body.put("userCreated", result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0);
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개 필터 검증용 픽스처를 만든다 — {@code managerEmail} 소속 법인으로 현재
* 게시된 API 중 하나(동적 선택)를 제한하고, 그 API 에 영향을 준 종결 인시던트 1건을 만든다.
*/
@PostMapping("/group-restricted-incident")
public ResponseEntity<Map<String, Object>> ensureGroupRestrictedIncident(
@RequestParam String managerEmail, @RequestParam String subject,
@RequestParam(required = false) String summary, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(subject)) {
return badRequest("managerEmail 과 subject 는 필수입니다.");
}
GroupRestrictedIncidentFixture fixture;
try {
fixture = testCleanupService.ensureGroupRestrictedIncident(managerEmail, subject, summary);
} catch (IllegalArgumentException | IllegalStateException e) {
return badRequest(e.getMessage());
}
log.info("테스트 정리(group-restricted-incident) 실행 - managerEmail: {}, apiId: {}, incidentId: {}, from: {}",
managerEmail, fixture.getApiId(), fixture.getIncidentId(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", fixture.getApiId());
body.put("apiName", fixture.getApiName());
body.put("orgId", fixture.getOrgId());
body.put("incidentId", fixture.getIncidentId());
body.put("previousDisplayOrg", fixture.getPreviousDisplayOrg());
body.put("previousDisplayRoleCode", fixture.getPreviousDisplayRoleCode());
return ResponseEntity.ok(body);
}
/**
* {@code /group-restricted-incident} 로 만든 픽스처를 원복한다 — 정확한 apiId/incidentId 를
* 요구해 와일드카드 삭제가 없다.
*/
@PostMapping("/group-restricted-incident/revert")
public ResponseEntity<Map<String, Object>> revertGroupRestrictedIncident(
@RequestParam String apiId, @RequestParam Long incidentId,
@RequestParam(required = false) String previousDisplayOrg,
@RequestParam(required = false) String previousDisplayRoleCode, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(apiId) || incidentId == null) {
return badRequest("apiId 와 incidentId 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.revertGroupRestrictedIncident(
apiId, incidentId, previousDisplayOrg, previousDisplayRoleCode);
log.info("테스트 정리(group-restricted-incident revert) 실행 - apiId: {}, incidentId: {}, found: {}, from: {}",
apiId, incidentId, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", apiId);
body.put("incidentId", incidentId);
body.put("incidentFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
*/
@PostMapping("/invitation")
public ResponseEntity<Map<String, Object>> deleteInvitations(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile);
log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("invitationFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다.
*/
@PostMapping("/membership")
public ResponseEntity<Map<String, Object>> detachMembership(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile);
log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("membershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/orphans")
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
TestCleanupResult result = orphanCleanupService.sweep();
log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
private ResponseEntity<Map<String, Object>> checkGuards(HttpServletRequest request) {
if (!isAllowedIp(request)) {
return denied(HttpStatus.FORBIDDEN,
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
}
if (!hasValidToken(request)) {
return denied(HttpStatus.UNAUTHORIZED,
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
}
return null;
}
private Map<String, Object> baseBody() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "OK");
body.put("processedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return body;
}
private ResponseEntity<Map<String, Object>> badRequest(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "BAD_REQUEST");
body.put("message", message);
return ResponseEntity.badRequest().body(body);
}
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
Map<String, Object> denied = new LinkedHashMap<>();
denied.put("result", "DENIED");
denied.put("message", message);
return ResponseEntity.status(status).body(denied);
}
/**
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
*/
private boolean hasValidToken(HttpServletRequest request) {
String headerName = internalApiTokenService.ensureHeaderName();
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
log.warn("테스트 정리 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
return false;
}
return true;
}
/**
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
*/
private boolean isAllowedIp(HttpServletRequest request) {
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
log.warn("테스트 정리 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
return false;
}
return true;
}
private String resolveAllowIps() {
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
} catch (Exception e) {
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
return DEFAULT_ALLOW_IPS;
}
}
}
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import lombok.Value;
/**
* {@link TestCleanupService#ensureGroupRestrictedIncident} 결과. {@link TestCleanupResult}는
* "대상 존재 여부 + 테이블별 삭제건수"(Long) 형태라 이 작업의 응답(선택된 API/법인/원래 공개범위)엔
* 맞지 않아 전용 타입을 둔다. {@code previousDisplayOrg}/{@code previousDisplayRoleCode}는
* 원복({@link TestCleanupService#revertGroupRestrictedIncident})에 그대로 되돌려줘야 한다.
*/
@Value
public class GroupRestrictedIncidentFixture {
String apiId;
String apiName;
String orgId;
Long incidentId;
String previousDisplayOrg;
String previousDisplayRoleCode;
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/
@Slf4j
@Profile("playwright")
@Service
@Transactional
public class OrphanCleanupService {
private final Environment environment;
@PersistenceContext
private EntityManager entityManager;
public OrphanCleanupService(Environment environment) {
this.environment = environment;
}
public TestCleanupResult sweep() {
assertNonProdProfile();
TestCleanupResult result = TestCleanupResult.found(null);
// 자식 -> 부모 순서 (FK 제약은 없으나 논리적 정합성 유지 목적)
result.put("PTL_INQUIRY_COMMENT", exec(
"DELETE FROM PTL_INQUIRY_COMMENT WHERE INQUIRY_ID IN "
+ "(SELECT ID FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER))"));
result.put("PTL_INQUIRY", exec(
"DELETE FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_PARTNERSHIP_APPLICATION", exec(
"DELETE FROM PTL_PARTNERSHIP_APPLICATION WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", exec(
"DELETE FROM PTL_USER_PRIVACY_POLICY_AGREEMENT WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_PASSWORD_HISTORY", exec(
"DELETE FROM PTL_USER_PASSWORD_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT ID FROM PTL_USER)"));
result.put("PTL_USER_ROLE_HISTORY", exec(
"DELETE FROM PTL_USER_ROLE_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
result.put("PTL_USER_LOG", exec(
"DELETE FROM PTL_USER_LOG WHERE LOGIN_ID IS NOT NULL AND LOGIN_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
result.put("PTL_CREDENTIAL_API", exec(
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_CREDENTIAL", exec(
"DELETE FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG)"));
result.put("PTL_WEBHOOK_REQ_API", exec(
"DELETE FROM PTL_WEBHOOK_REQ_API WHERE WEBHOOK_REQ_ID IN "
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_WEBHOOK_REQ_EVENT", exec(
"DELETE FROM PTL_WEBHOOK_REQ_EVENT WHERE WEBHOOK_REQ_ID IN "
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
result.put("PTL_WEBHOOK_REQ", exec(
"DELETE FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
result.put("PTL_WEBHOOK_SEND_LOG", exec(
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
log.info("테스트 정리 - 고아 데이터 스윕 완료: {}", result.getDeletedCounts());
return result;
}
private long exec(String sql) {
return entityManager.createNativeQuery(sql).executeUpdate();
}
private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
}
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
/**
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/
@Profile("playwright")
@Repository
class TestCleanupNativeQueries {
@PersistenceContext
private EntityManager entityManager;
int deleteCredentialApiByOrgId(String orgId) {
return entityManager.createNativeQuery(
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID = :orgId)")
.setParameter("orgId", orgId)
.executeUpdate();
}
int deleteWebhookSendLogByOrgId(String orgId) {
return entityManager.createNativeQuery(
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID = :orgId")
.setParameter("orgId", orgId)
.executeUpdate();
}
}
@@ -0,0 +1,45 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 테스트 정리 API 응답용 결과 누적기. 대상 존재 여부 + 테이블별 삭제 건수를 담는다.
*/
public class TestCleanupResult {
private boolean found;
private String targetId;
private final Map<String, Long> deletedCounts = new LinkedHashMap<>();
public static TestCleanupResult notFound() {
return new TestCleanupResult();
}
public static TestCleanupResult found(String targetId) {
TestCleanupResult result = new TestCleanupResult();
result.found = true;
result.targetId = targetId;
return result;
}
public void put(String table, long count) {
deletedCounts.merge(table, count, Long::sum);
}
public void merge(TestCleanupResult other) {
other.deletedCounts.forEach(this::put);
}
public boolean isFound() {
return found;
}
public String getTargetId() {
return targetId;
}
public Map<String, Long> getDeletedCounts() {
return deletedCounts;
}
}
@@ -0,0 +1,631 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
* local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
*
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/
@Slf4j
@Profile("playwright")
@Service
@RequiredArgsConstructor
@Transactional
public class TestCleanupService {
private final Environment environment;
private final PortalOrgRepository portalOrgRepository;
private final PortalUserRepository portalUserRepository;
private final InquiryRepository inquiryRepository;
private final InquiryCommentRepository inquiryCommentRepository;
private final PartnershipApplicationRepository partnershipApplicationRepository;
private final UserRoleHistoryRepository userRoleHistoryRepository;
private final PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
private final UserLogRepository userLogRepository;
private final CredentialRepository credentialRepository;
private final AppRequestRepository appRequestRepository;
private final ApprovalService approvalService;
private final WebhookRequestRepository webhookRequestRepository;
private final WebhookRequestApiRepository webhookRequestApiRepository;
private final WebhookRequestEventRepository webhookRequestEventRepository;
private final WebhookService webhookService;
private final UserInvitationRepository userInvitationRepository;
private final FileService fileService;
private final TestCleanupNativeQueries nativeQueries;
private final PortalUserService portalUserService;
private final PasswordEncoder passwordEncoder;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final DjbApistatusIncidentRepository djbApistatusIncidentRepository;
private final DjbApistatusIncidentApiRepository djbApistatusIncidentApiRepository;
private final ApiSearchFacade apiSearchFacade;
/** notice-incident.spec.ts 기본 영향 API — 동시 실행 충돌을 피하려 자동선택에서 제외한다 */
private static final String RESERVED_DEFAULT_API_ID = "ANSTOSOBP00001S2";
/**
* 이 내부 API 는 Spring Security 인증 없이(IP+토큰 가드만으로) 호출되므로
* {@code AuditorAwareImpl}(SecurityUtil.getPortalAuthenticatedUser() 기반)이 항상
* empty 를 반환해 {@code @CreatedBy} 자동 채움이 동작하지 않는다. PTL_ORG/PTL_USER 는
* created_by 가 nullable 이라 문제없이 넘어가지만, DJB_APISTATUS_INCIDENT(_API) 는
* NOT NULL 이라 명시적으로 채워야 한다(ORA-01400).
*/
private static final String TEST_FIXTURE_CREATED_BY = "PLAYWRIGHT_TEST";
/**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
*/
public TestCleanupResult deleteOrgCascade(String compRegNo) {
assertNonProdProfile();
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
// 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다.
// cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다.
String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", "");
if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) {
orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo);
}
if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalOrg org = orgOpt.get();
String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId);
result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId));
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
for (PortalUser user : users) {
result.merge(deleteUserCascadeInternal(user));
}
result.put("PTL_CREDENTIAL_API", nativeQueries.deleteCredentialApiByOrgId(orgId));
result.put("PTL_CREDENTIAL", credentialRepository.deleteByOrgid(orgId));
Optional<WebhookRequest> webhook = webhookRequestRepository.findByOrgId(orgId);
if (webhook.isPresent()) {
Long webhookReqId = webhook.get().getId();
result.put("PTL_WEBHOOK_REQ_API", webhookRequestApiRepository.findByWebhookReqId(webhookReqId).size());
result.put("PTL_WEBHOOK_REQ_EVENT", webhookRequestEventRepository.findByWebhookReqId(webhookReqId).size());
webhookService.delete(webhookReqId, orgId);
result.put("PTL_WEBHOOK_REQ", 1L);
} else {
result.put("PTL_WEBHOOK_REQ_API", 0L);
result.put("PTL_WEBHOOK_REQ_EVENT", 0L);
result.put("PTL_WEBHOOK_REQ", 0L);
}
result.put("PTL_WEBHOOK_SEND_LOG", nativeQueries.deleteWebhookSendLogByOrgId(orgId));
portalOrgRepository.delete(org);
result.put("PTL_ORG", 1L);
log.info("테스트 정리 - 법인 완전삭제 완료: compRegNo={}, orgId={}, userCount={}", compRegNo, orgId, users.size());
return result;
}
/**
* 이메일로 계정을 찾아 단독 하드 삭제한다(org 소속 여부와 무관 — org 소유 CREDENTIAL/WEBHOOK 은 건드리지 않는다).
*/
public TestCleanupResult deleteUserCascade(String email) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
TestCleanupResult result = deleteUserCascadeInternal(user);
log.info("테스트 정리 - 계정 완전삭제 완료: userId={}", user.getId());
return result;
}
/**
* 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다.
*/
@Transactional(readOnly = true)
public TestCleanupResult checkUserExists(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email)
.map(user -> TestCleanupResult.found(user.getId()))
.orElseGet(TestCleanupResult::notFound);
}
/**
* E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다.
* 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다.
*/
@Transactional(readOnly = true)
public Optional<PortalUser> findUserForStatus(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email);
}
/**
* 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다.
* 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다.
*/
public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) {
assertNonProdProfile();
if (clientName == null || !clientName.startsWith("단위테스트앱-")) {
throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
long cancelled = 0L;
for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) {
if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState
|| request.getApproval().getApprovalStatus() instanceof ProcessingState)) {
continue;
}
approvalService.cancelAppApproval(request);
result = TestCleanupResult.found(request.getId());
cancelled++;
}
result.put("PTL_APP_REQUEST_CANCELLED", cancelled);
return result;
}
/**
* 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다.
*/
public TestCleanupResult deleteInvitationsByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile);
TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound();
result.put("PTL_USER_INVITATION", deleted);
return result;
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다.
* 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다.
*/
public TestCleanupResult detachUsersFromOrgByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) {
if (user.getPortalOrg() == null) {
continue;
}
user.setPortalOrg(null);
user.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(user);
result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_ORG_MEMBERSHIP", 1L);
}
return result;
}
/**
* 4020 재실행 전, 특정 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다(첨부파일 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 단위테스트} 로 시작하는 제목 접두사를 모두 요구한다.
*/
public TestCleanupResult deletePartnershipApplicationsByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("단위테스트")) {
throw new IllegalArgumentException("테스트 글 제목 접두사(단위테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<PartnershipApplication> targets =
partnershipApplicationRepository.findAllByCreatedByAndBizSubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_PARTNERSHIP_APPLICATION", 0L);
return empty;
}
long files = 0L;
for (PartnershipApplication target : targets) {
if (target.getFileId() != null && !target.getFileId().trim().isEmpty()) {
fileService.deleteFile(target.getFileId());
files++;
}
}
partnershipApplicationRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_PARTNERSHIP_APPLICATION", (long) targets.size());
result.put("PTL_FILE_INFO", files);
log.info("테스트 정리 - 피드백/개선요청 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리) 특정 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사를 모두 요구한다.
* 답변완료(RESPONDED)로 전환된 문의는 포털 자가삭제(PENDING + 작성자 전용)가 막히므로, 4010 종료 시점의
* 최종 정리도 이 API를 재사용한다.
*/
public TestCleanupResult deleteInquiriesByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("새글 작성 테스트")) {
throw new IllegalArgumentException("테스트 문의글 제목 접두사(새글 작성 테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<Inquiry> targets =
inquiryRepository.findAllByInquirer_IdAndInquirySubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_INQUIRY", 0L);
empty.put("PTL_INQUIRY_COMMENT", 0L);
return empty;
}
List<String> ids = targets.stream().map(Inquiry::getId).collect(Collectors.toList());
long comments = inquiryCommentRepository.deleteByInquiry_IdIn(ids);
inquiryRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY", (long) targets.size());
result.put("PTL_INQUIRY_COMMENT", comments);
log.info("테스트 정리 - 문의글 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 안정성을 위한 테스트 전용 API. 지정 이메일 계정을 관리자 이메일로 조회한 법인의
* ROLE_CORP_USER(법인개발자)로 만든다 — 이미 존재하면(다른 시나리오/수작업이 소속을 해제했더라도)
* 재소속·비밀번호 재설정으로 로그인 가능 상태를 보장하고(heal), 계정이 없으면 새로 만든다(create).
* 계정 생성/소속 전환 로직은 초대 수락 경로({@link PortalUserService#registerInvitedUser},
* {@link PortalUserService#updateUserToCorpUser})를 그대로 재사용한다.
*/
public TestCleanupResult ensureTestCorpDeveloper(
String managerEmail, String email, String password, String mobile, String userName) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Optional<PortalUser> existing = portalUserRepository.findPortalUserByEmailAddr(email);
boolean created;
PortalUser user;
if (existing.isPresent()) {
user = existing.get();
portalUserService.updateUserToCorpUser(user, org.getId());
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
created = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(email);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
user = portalUserService.registerInvitedUser(dto, org.getId());
created = true;
}
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_CREATED", created ? 1L : 0L);
result.put("PTL_USER_ATTACHED", created ? 0L : 1L);
log.info("테스트 정리 - 법인개발자 계정 준비 완료: email={}, orgId={}, created={}", email, org.getId(), created);
return result;
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 건드리지 않음). 계정이 아예 없으면 not-found.
* 4010 등에서 재사용하는 공용 계정(예: REG_CORP_LOGIN_ID)의 비밀번호가 클라이언트 정책(길이/복잡도)에
* 안 맞게 바뀌었을 때, 화면 가입 절차 없이 즉시 로그인 가능한 값으로 되돌리는 용도.
*/
public TestCleanupResult resetTestPassword(String email, String password) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
user.setLoginFailureCount(0);
user.setAccountLockYn("N");
portalUserRepository.save(user);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_PASSWORD_RESET", 1L);
log.info("테스트 정리 - 비밀번호 재설정 완료: email={}", email);
return result;
}
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
result.put("PTL_INQUIRY", inquiryRepository.deleteByInquirer_Id(user.getId()));
result.put("PTL_PARTNERSHIP_APPLICATION", partnershipApplicationRepository.deleteByCreatedBy(user.getId()));
// UserRoleHistory.userId 는 loginId 를 저장한다(다른 이력 테이블과 시맨틱이 반대이므로 혼동 주의).
result.put("PTL_USER_ROLE_HISTORY", userRoleHistoryRepository.deleteByUserId(user.getLoginId()));
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", portalUserPrivacyAgreementRepository.deleteByCreatedBy(user.getId()));
// UserPasswordHistory.userId 는 PortalUser.id 를 저장한다.
result.put("PTL_USER_PASSWORD_HISTORY", userPasswordHistoryRepository.deleteByUserId(user.getId()));
result.put("PTL_USER_LOG", userLogRepository.deleteByLoginId(user.getLoginId()));
portalUserRepository.delete(user);
result.put("PTL_USER", 1L);
return result;
}
/**
* API Status 그룹공개(법인전용) 필터를 Playwright 로 검증하려면 "다른 법인" 로그인 계정이
* 하나 더 필요하다. 지정 사업자번호(compRegNo)의 법인이 없으면 새로 만들고(관리자 승인 절차
* 없이 바로 ACTIVE/COMPLETED — {@link PortalUserService#createUserWithOrg} 가 신규 법인가입
* 경로에서 쓰는 것과 같은 즉시활성 경로를 재사용), 있으면 매니저 계정을 재소속·비밀번호
* 재설정으로 로그인 가능한 상태로 되돌린다(heal). 정리는 새 API 를 만들 필요 없이 기존
* {@link #deleteOrgCascade(String)}(compRegNo)를 그대로 재사용한다.
*/
public TestCleanupResult ensureSecondaryTestOrg(
String compRegNo, String orgName, String managerEmail, String password, String mobile, String userName) {
assertNonProdProfile();
Optional<PortalOrg> existingOrg = portalOrgRepository.findByCompRegNo(compRegNo);
boolean orgCreated = !existingOrg.isPresent();
PortalOrg org;
if (existingOrg.isPresent()) {
org = existingOrg.get();
} else {
PortalOrg created = new PortalOrg();
created.setId(UUID.randomUUID().toString());
created.setCompRegNo(compRegNo);
created.setOrgName(orgName);
created.setOrgStatus(PortalOrgEnums.OrgStatus.ACTIVE);
created.setApprovalStatus(PortalOrgEnums.ApprovalStatus.COMPLETED);
org = portalOrgRepository.save(created);
}
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(managerEmail);
boolean userCreated;
PortalUser user;
if (existingUser.isPresent()) {
user = existingUser.get();
user.setPortalOrg(org);
user.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
userCreated = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(managerEmail);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
// emailVerified=true — 가입 즉시 ACTIVE/COMPLETED (승인 대기 없음), registrationType="corporate" → ROLE_CORP_MANAGER
user = portalUserService.createUserWithOrg(dto, org, "corporate", true);
userCreated = true;
}
TestCleanupResult result = TestCleanupResult.found(org.getId());
result.put("PTL_ORG_CREATED", orgCreated ? 1L : 0L);
result.put("PTL_USER_CREATED", userCreated ? 1L : 0L);
log.info("테스트 정리 - 보조 법인 준비 완료: compRegNo={}, orgId={}, orgCreated={}, managerEmail={}, userCreated={}",
compRegNo, org.getId(), orgCreated, managerEmail, userCreated);
return result;
}
/**
* API Status 그룹공개 필터 Playwright 검증용 픽스처를 만든다. {@code managerEmail} 로 제한 대상
* 법인을 역산하므로(환경마다 org id 를 몰라도 됨), 이 환경에 실제로 게시된(displayYn=Y) API 중
* 하나를 동적으로 골라 그 API 의 {@code displayOrg} 를 해당 법인으로 제한한 뒤, 그 API 에 영향을
* 준 것으로 기록된 종결(RESOLVED) 인시던트 1건을 만든다 — API Status 메인(P6)과 전체 이력
* (P9/P10) 양쪽에서 동시에 검증 가능하도록 최근 시각으로 둔다.
*
* <p>특정 api_id 를 하드코딩하지 않는다 — 환경(dev/local/stage)마다 카탈로그가 다르기 때문이다.
* {@code notice-incident.spec.ts} 가 기본으로 쓰는 API 는 동시실행 충돌을 피하려 후보에서
* 제외한다(있으면).</p>
*
* <p>후보는 {@code PTL_API_SPEC_INFO.DISPLAY_YN='Y'} 만으로 고르지 않는다 — 포털 카탈로그
* 노출은 그것만으로 결정되지 않고 AGWAPP.API_GROUP_API 매핑까지 있어야 한다
* ({@link ApiStatusCatalogService#getSelectableApis()} 주석 참고). 그 조건까지 반영된 실제
* 카탈로그 조회 경로({@link ApiSearchFacade#searchApis})를 그대로 재사용해야, 뽑은 API 가
* {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}(= 이 검증이 실제로 통제하는
* visibleApiIds)에도 반드시 잡힌다. 이 메서드는 비인증 컨텍스트에서 호출되므로
* {@code searchApis} 결과는 이미 "공개범위 제한 없음"만 걸러져 있다(비로그인 판정 —
* {@code ApiPermissionFilter.checkApiPermissions} 의 !isAuthenticated 분기).</p>
*/
@SuppressWarnings("unchecked")
public GroupRestrictedIncidentFixture ensureGroupRestrictedIncident(
String managerEmail, String subject, String summary) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Object apisObj = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
List<ApiSpecInfoDto> catalog = apisObj instanceof List ? (List<ApiSpecInfoDto>) apisObj : Collections.emptyList();
if (catalog.isEmpty()) {
throw new IllegalStateException(
"이 환경에 공개범위 제한 없이 카탈로그에 노출된 API가 하나도 없습니다. "
+ "API 그룹에 편성된 API Spec을 하나 이상 게시해야 이 검증을 진행할 수 있습니다.");
}
String candidateApiId = catalog.stream()
.map(ApiSpecInfoDto::getApiId)
.filter(StringUtils::isNotBlank)
.filter(apiId -> !RESERVED_DEFAULT_API_ID.equals(apiId))
.findFirst()
.orElseGet(() -> catalog.get(0).getApiId());
ApiSpecInfo target = apiSpecInfoRepository.findById(candidateApiId)
.orElseThrow(() -> new IllegalStateException("카탈로그 API 스펙을 찾지 못했습니다: " + candidateApiId));
String previousDisplayOrg = target.getDisplayOrg();
String previousDisplayRoleCode = target.getDisplayRoleCode();
target.setDisplayOrg(org.getId());
apiSpecInfoRepository.save(target);
LocalDateTime now = LocalDateTime.now();
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(IncidentKind.INCIDENT);
incident.setState(IncidentState.RESOLVED);
incident.setTitle(subject);
incident.setSummary(StringUtils.defaultString(summary));
incident.setStartedAt(now.minusHours(1));
incident.setEndAt(now);
incident.setDraftYn("N");
incident.setCreatedBy(TEST_FIXTURE_CREATED_BY);
incident = djbApistatusIncidentRepository.save(incident);
DjbApistatusIncidentApi incidentApi = new DjbApistatusIncidentApi();
incidentApi.setIncidentId(incident.getIncidentId());
incidentApi.setApiId(target.getApiId());
incidentApi.setApiName(target.getApiName());
incidentApi.setCreatedBy(TEST_FIXTURE_CREATED_BY);
djbApistatusIncidentApiRepository.save(incidentApi);
log.info("테스트 정리 - 그룹전용 인시던트 준비 완료: apiId={}, orgId={}, incidentId={}, previousDisplayOrg={}",
target.getApiId(), org.getId(), incident.getIncidentId(), previousDisplayOrg);
return new GroupRestrictedIncidentFixture(target.getApiId(), target.getApiName(), org.getId(),
incident.getIncidentId(), previousDisplayOrg, previousDisplayRoleCode);
}
/**
* {@link #ensureGroupRestrictedIncident} 로 만든 픽스처를 원복한다 — 인시던트/영향API 행 삭제 +
* 대상 API 의 공개범위를 호출자가 넘긴 원래값으로 되돌린다. 정확한 {@code apiId}/{@code incidentId}
* 를 요구해 와일드카드 삭제가 없다.
*/
public TestCleanupResult revertGroupRestrictedIncident(
String apiId, Long incidentId, String previousDisplayOrg, String previousDisplayRoleCode) {
assertNonProdProfile();
Optional<DjbApistatusIncident> incidentOpt = djbApistatusIncidentRepository.findById(incidentId);
if (!incidentOpt.isPresent()) {
return TestCleanupResult.notFound();
}
djbApistatusIncidentApiRepository.deleteByIncidentId(incidentId);
djbApistatusIncidentRepository.deleteById(incidentId);
Optional<ApiSpecInfo> specOpt = apiSpecInfoRepository.findById(apiId);
if (specOpt.isPresent()) {
ApiSpecInfo spec = specOpt.get();
spec.setDisplayOrg(previousDisplayOrg);
spec.setDisplayRoleCode(previousDisplayRoleCode);
apiSpecInfoRepository.save(spec);
}
TestCleanupResult result = TestCleanupResult.found(String.valueOf(incidentId));
result.put("DJB_APISTATUS_INCIDENT", 1L);
result.put("DJB_APISTATUS_INCIDENT_API", 1L);
log.info("테스트 정리 - 그룹전용 인시던트 원복 완료: apiId={}, incidentId={}", apiId, incidentId);
return result;
}
private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
}
}
}
@@ -5,15 +5,20 @@ import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.djb.webhook.service.WebhookUrlValidator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -54,6 +59,8 @@ public class WebhookController {
private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
// 본인확인 연속 실패는 step-up 확인 페이지와 같은 카운터를 쓴다(화면을 바꿔가며 우회하지 못하게).
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
@ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() {
@@ -298,13 +305,13 @@ public class WebhookController {
@PostMapping("/verify-secret")
@ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> verifySecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -320,13 +327,13 @@ public class WebhookController {
@PostMapping("/regenerate-secret")
@ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> regenerateSecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -341,13 +348,13 @@ public class WebhookController {
@PostMapping("/delete")
@ResponseBody
public Map<String, Object> delete(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> delete(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -363,10 +370,9 @@ public class WebhookController {
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!bindingResult.hasFieldErrors("targetUrl")
&& !url.startsWith("http://") && !url.startsWith("https://")) {
if (!bindingResult.hasFieldErrors("targetUrl") && !WebhookUrlValidator.isValid(url)) {
bindingResult.rejectValue("targetUrl", "invalid.url",
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
"올바른 URL 형식이 아닙니다. http:// 또는 https:// 로 시작하는 전체 주소를 입력해주세요.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
@@ -379,6 +385,31 @@ public class WebhookController {
return appServiceFacade.verifyUserPassword(user, password);
}
/**
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
* 연속 실패가 임계치에 도달하면 정책({@code password.confirm.failure.action})에 따라 세션을 강제 종료하거나
* 계정을 차단하고 {@code forceLogout=true} 를 담는다 (무차별 대입 방어).
*/
private Map<String, Object> checkPassword(String password, HttpSession session,
HttpServletRequest request, HttpServletResponse response) {
if (verifyPassword(password)) {
passwordConfirmFailureTracker.reset(session);
return null;
}
PasswordConfirmFailureTracker.Outcome outcome =
passwordConfirmFailureTracker.recordFailure(session, request, response);
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", outcome.getMessage());
if (outcome.isForcedLogout()) {
result.put("forceLogout", true);
result.put("accountLocked", outcome.isAccountLocked());
}
return result;
}
private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookApiDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String name;
}
@@ -19,9 +19,12 @@ public class WebhookDTO implements Serializable {
private String userSecretMasked;
private String createdDate;
/** 구독 API ID 목록. */
/** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
@@ -28,10 +29,12 @@ public class WebhookRegistrationDTO implements Serializable {
private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 요청 헤더 그대로 echo 된다.
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
@@ -166,9 +170,8 @@ public class WebhookService {
}
private void validate(WebhookRegistrationDTO dto) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
if (!WebhookUrlValidator.isValid(dto.getTargetUrl())) {
throw new IllegalArgumentException("올바른 URL 형식이 아닙니다. http:// 또는 https:// 로 시작하는 전체 주소를 입력해주세요.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
@@ -199,8 +202,15 @@ public class WebhookService {
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
.collect(Collectors.toList());
dto.setApiIds(apiIds);
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
dto.setApis(apiIds.stream()
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
.collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap();
@@ -0,0 +1,30 @@
package com.eactive.apim.portal.djb.webhook.service;
import java.net.URI;
import java.net.URISyntaxException;
/**
* Webhook 수신 URL 형식 검증. 컨트롤러(필드 에러 표시)와 서비스(방어적 재검증) 양쪽에서 공용으로 쓴다.
*
* http/https 스킴 + host 존재까지 확인한다("http://" 만 있고 host 가 없는 값 등을 차단).
* 순수 문법 검증만 하며, 실제 도달 가능성(DNS/네트워크)은 검사하지 않는다.
*/
public final class WebhookUrlValidator {
private WebhookUrlValidator() {
}
public static boolean isValid(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
try {
URI uri = new URI(url.trim());
String scheme = uri.getScheme();
return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null && !uri.getHost().isEmpty();
} catch (URISyntaxException e) {
return false;
}
}
}
+2
View File
@@ -2,6 +2,8 @@ spring:
config:
activate:
on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa:
properties:
hibernate:
+17
View File
@@ -30,12 +30,25 @@ server:
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
forward-headers-strategy: native
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring:
config:
import:
- classpath:menu.yml
- classpath:roles.yml
profiles:
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
# 반드시 이 base 문서에 정의)
group:
dev: playwright
local_rinjaemac: playwright
data:
web:
pageable:
@@ -80,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security:
basic:
+19 -4
View File
@@ -19,6 +19,21 @@
<property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<!-- 파일 로그(ROLLING/ERROR_FILE 등 root 하위 appender) 레벨 JVM 파라미터 제어 -->
<!-- 사용: -DFILE_LOG_LEVEL=ERROR (전체 미남김: -DFILE_LOG_LEVEL=OFF) -->
<!-- 기본값: dev 프로파일 DEBUG, 그 외 INFO (기존 동작 유지) -->
<property name="FILE_LOG_LEVEL" value="${FILE_LOG_LEVEL:-INFO}"/>
<property name="FILE_LOG_LEVEL_DEV" value="${FILE_LOG_LEVEL:-DEBUG}"/>
<!-- API 테스터 감사 로그(eapim.portal.apitester.audit) on/off JVM 파라미터 제어 -->
<!-- 사용: -DAPI_TESTER_AUDIT_LOG_ENABLED=false / 기본값: true(켜짐, INFO) -->
<property name="API_TESTER_AUDIT_LOG_ENABLED" value="${API_TESTER_AUDIT_LOG_ENABLED:-true}"/>
<property name="__apiTesterAuditLevel.true" value="INFO"/>
<property name="__apiTesterAuditLevel.TRUE" value="INFO"/>
<property name="__apiTesterAuditLevel.false" value="OFF"/>
<property name="__apiTesterAuditLevel.FALSE" value="OFF"/>
<property name="API_TESTER_AUDIT_EFFECTIVE_LEVEL" value="${__apiTesterAuditLevel.${API_TESTER_AUDIT_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
@@ -95,23 +110,23 @@
</appender>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<logger name="eapim.portal.session" level="${FILE_LOG_LEVEL}" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<logger name="eapim.portal.apitester.audit" level="${API_TESTER_AUDIT_EFFECTIVE_LEVEL}" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<root level="INFO">
<root level="${FILE_LOG_LEVEL}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<springProfile name="dev">
<root level="DEBUG">
<root level="${FILE_LOG_LEVEL_DEV}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
@@ -1,4 +1,4 @@
title.html=DJBank Partner Portal
title.html={brand.name} API Portal
password.not.match=Password is not matched
authnumber.not.match=Auth number is not matched.
apim.route.register.file=File
@@ -1,4 +1,4 @@
title.html=DJBank \uD30C\uD2B8\uB108\uD3EC\uD0C8
title.html={brand.name} API \uD3EC\uD0C8
apim.route.register.file=\uCCA8\uBD80\uD30C\uC77C
field.required=\uD544\uC218 \uC785\uB825\uD56D\uBAA9\uC785\uB2C8\uB2E4.
companyName=\uD68C\uC0AC\uBA85
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+136 -49
View File
@@ -8,11 +8,13 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const paginationEl = document.getElementById('apiPagination');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let allApis = []; // 현재 카테고리 조회 결과 전체
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
let currentPage = 1;
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
clearCards();
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
applySearch();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
renderPagination(0);
});
}
// Render API cards
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
function clearCards() {
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
}
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
function applySearch() {
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
filteredApis = !term ? allApis : allApis.filter(function(api) {
const name = (api.apiName || '').toLowerCase();
const desc = (api.apiSimpleDescription || '').toLowerCase();
return name.includes(term) || desc.includes(term);
});
goToPage(1);
}
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
function goToPage(page) {
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), totalPages);
clearCards();
document.getElementById('apiResultCount').textContent = filteredApis.length;
if (filteredApis.length === 0) {
emptyState.style.display = 'block';
renderPagination(0);
updateSelectAllUI();
return;
}
emptyState.style.display = 'none';
const start = (currentPage - 1) * PAGE_SIZE;
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
renderPagination(filteredApis.length);
updateSelectAllUI();
}
// Render API cards (현재 페이지분)
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (!selectAllCheckbox) {
return;
}
if (visibleCards.length === 0) {
if (filteredApis.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
} else if (checkedCount === filteredApis.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
selectedApis.forEach(function(apiId) {
const apiData = allApis.find(a => a.apiId === apiId);
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiName = apiData ? apiData.apiName
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Search functionality
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
function renderPagination(totalItems) {
if (!paginationEl) {
return;
}
paginationEl.innerHTML = '';
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
if (totalPages <= 1) {
return;
}
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
function navLink(cls, iconHtml, targetPage, disabled) {
const a = document.createElement('a');
a.href = '#';
a.className = cls + (disabled ? ' disabled' : '');
a.innerHTML = iconHtml;
if (!disabled) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(targetPage);
});
}
return a;
}
function numLink(p) {
const a = document.createElement('a');
a.href = '#';
const isCurrent = p === currentPage;
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
a.textContent = String(p);
if (!isCurrent) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(p);
});
}
return a;
}
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const windowEnd = Math.min(totalPages, windowStart + 4);
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
paginationEl.appendChild(numLink(p));
}
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
}
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
applySearch();
});
}
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
loadApis(groupId);
});
});
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Select All checkbox event
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
filteredApis.forEach(function(api) {
if (isChecked) {
selectedApis.add(api.apiId);
} else {
selectedApis.delete(api.apiId);
}
});
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
document.querySelectorAll('.s2-api-card').forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
card.classList.toggle('selected', isChecked);
}
});
@@ -134,6 +134,14 @@
})
.then(function (res) {
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
// 작성 빈도 제한(무제한 요청 차단) — 서버가 내려준 안내 문구를 그대로 보여준다.
if (res.status === 429) {
return res.json()
.catch(function () { return {}; })
.then(function (body) {
throw new Error(body.message || '작성 빈도 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.');
});
}
if (!res.ok) throw new Error('댓글 등록에 실패했습니다.');
return res.json();
})
@@ -0,0 +1,197 @@
(function () {
'use strict';
var form = document.getElementById('ntForm');
if (!form) return;
var SEND_URL = '/djb/notitest/send';
var STATUS_URL = '/djb/notitest/status';
var POLL_INTERVAL_MS = 3000;
var radios = Array.prototype.slice.call(document.querySelectorAll('.nt-target-radio'));
var sendBtn = document.getElementById('ntSendBtn');
var hint = document.getElementById('ntSelectedHint');
var historyBody = document.getElementById('ntHistoryBody');
var emptyRow = document.getElementById('ntHistoryEmpty');
var STATUS_LABEL = {
PENDING: '대기', PROCESSING: '처리중', SENT: '발송완료', FAILED: '실패', SKIPPED: '스킵'
};
var STATUS_CLASS = {
PENDING: 'status-inactive', PROCESSING: 'status-processing',
SENT: 'status-completed', FAILED: 'status-pending', SKIPPED: 'status-inactive'
};
var CATEGORY_LABEL = { EMAIL: '이메일', SMS: 'SMS', MESSENGER: '메신저' };
function getCsrfToken() {
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
var meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
function fetchJson(url, options) {
options = options || {};
var headers = Object.assign({}, options.headers || {});
var method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
headers['X-XSRF-TOKEN'] = getCsrfToken();
}
return fetch(url, Object.assign({ credentials: 'same-origin' }, options, { headers: headers }));
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function aggregateStatus(entry) {
var ids = entry.requestIds || [];
if (ids.length === 0) {
return entry.errorMessage ? 'FAILED' : 'PENDING';
}
var statuses = ids.map(function (id) { return (entry.statuses || {})[id]; });
if (statuses.indexOf('FAILED') >= 0) return 'FAILED';
if (statuses.every(function (s) { return s === 'SENT' || s === 'SKIPPED'; })) return 'SENT';
if (statuses.indexOf('PROCESSING') >= 0) return 'PROCESSING';
return 'PENDING';
}
function renderRow(entry) {
var status = aggregateStatus(entry);
var label = STATUS_LABEL[status] || status;
var cls = STATUS_CLASS[status] || 'status-inactive';
var note = '';
if (entry.warning) {
note += '<div class="nt-row-note nt-row-warning">' + escapeHtml(entry.warning) + '</div>';
}
if (entry.errorMessage) {
note += '<div class="nt-row-note nt-row-error">' + escapeHtml(entry.errorMessage) + '</div>';
}
var messageText = (entry.messages || []).join('\n');
return '' +
'<tr data-entry-id="' + escapeHtml(entry.entryId) + '">' +
'<td>' + escapeHtml(entry.requestedAt) + '</td>' +
'<td>' + escapeHtml(CATEGORY_LABEL[entry.category] || entry.category) + '</td>' +
'<td>' + escapeHtml(entry.targetLabel) + note + '</td>' +
'<td>' + escapeHtml(entry.recipient) + '</td>' +
'<td class="nt-message-cell"><textarea class="nt-message-textarea" readonly rows="3">' +
escapeHtml(messageText) + '</textarea></td>' +
'<td><span class="status-badge-header ' + cls + '">' + label + '</span></td>' +
'</tr>';
}
function renderHistory(list) {
if (!list || list.length === 0) {
historyBody.innerHTML = '';
if (emptyRow) historyBody.appendChild(emptyRow);
return;
}
historyBody.innerHTML = list.map(renderRow).join('');
}
function prependEntry(entry) {
if (emptyRow && emptyRow.parentNode === historyBody) {
historyBody.removeChild(emptyRow);
}
historyBody.insertAdjacentHTML('afterbegin', renderRow(entry));
}
function selectedRadio() {
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return radios[i];
}
return null;
}
function paramsPanelFor(radio) {
return document.getElementById('params-' + radio.id.replace('target-', ''));
}
function onRadioChange() {
document.querySelectorAll('.nt-params').forEach(function (el) { el.style.display = 'none'; });
var radio = selectedRadio();
if (!radio) {
sendBtn.disabled = true;
hint.textContent = '왼쪽에서 발송할 항목을 선택하세요.';
return;
}
var panel = paramsPanelFor(radio);
if (panel) panel.style.display = '';
sendBtn.disabled = false;
var strategy = radio.getAttribute('data-strategy');
if (strategy === 'BROADCAST') {
hint.textContent = '이 항목은 테스트 대상을 임의로 지정할 수 없으며, 실제 담당 내부직원 전원에게 발송됩니다.';
} else if (strategy === 'DIRECT') {
hint.textContent = '이 항목은 실서비스 템플릿을 사용합니다. 추가 수신자가 등록되어 있으면 함께 발송될 수 있습니다.';
} else {
hint.textContent = '';
}
}
radios.forEach(function (r) { r.addEventListener('change', onRadioChange); });
form.addEventListener('submit', function (evt) {
evt.preventDefault();
var radio = selectedRadio();
if (!radio) return;
var strategy = radio.getAttribute('data-strategy');
if ((strategy === 'DIRECT' || strategy === 'BROADCAST') &&
!window.confirm('실제 수신자(추가 수신자 또는 담당 내부직원)에게 함께 발송될 수 있습니다. 계속하시겠습니까?')) {
return;
}
var params = new URLSearchParams();
params.set('messageCode', radio.value);
['recipientUsername', 'recipientEmail', 'recipientPhone', 'recipientMessengerId'].forEach(function (id) {
var el = document.getElementById(id);
if (el) params.set(id, el.value);
});
var panel = paramsPanelFor(radio);
if (panel) {
panel.querySelectorAll('input').forEach(function (input) {
params.set(input.name, input.value);
});
}
sendBtn.disabled = true;
fetchJson(SEND_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
})
.then(function (res) {
if (!res.ok) {
return res.json().catch(function () { return {}; }).then(function (body) {
throw new Error(body.error || ('발송 요청 실패: ' + res.status));
});
}
return res.json();
})
.then(function (entry) {
prependEntry(entry);
})
.catch(function (e) {
window.alert(e.message || '발송 중 오류가 발생했습니다.');
})
.then(function () {
sendBtn.disabled = false;
});
});
function poll() {
fetchJson(STATUS_URL)
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (list) { if (list) renderHistory(list); })
.catch(function () { /* 폴링 실패 시 다음 주기에 재시도 */ });
}
poll();
setInterval(poll, POLL_INTERVAL_MS);
})();
@@ -33,6 +33,33 @@
return false;
}
// 키보드 배열 인접 3자 검사 — 서버 PasswordRuleValidator.hasKeyboardSequence 와 동일 규칙
var KEYBOARD_SEQUENCE_LENGTH = 3;
var KEYBOARD_ROWS = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM', '1234567890'];
function hasKeyboardSequence(pw) {
if (!pw || pw.length < KEYBOARD_SEQUENCE_LENGTH) {
return false;
}
var s = pw.toUpperCase();
for (var i = 0; i <= s.length - KEYBOARD_SEQUENCE_LENGTH; i++) {
var chunk = s.substr(i, KEYBOARD_SEQUENCE_LENGTH);
var reversed = chunk.split('').reverse().join('');
for (var r = 0; r < KEYBOARD_ROWS.length; r++) {
if (KEYBOARD_ROWS[r].indexOf(chunk) !== -1 || KEYBOARD_ROWS[r].indexOf(reversed) !== -1) {
return true;
}
}
}
return false;
}
// 키보드 연속 문자 차단 여부 — 서버가 head 에 내려주는 PortalProperty 값. 미지정이면 차단(서버 기본값과 동일)
function keyboardSequenceBlocked() {
var flags = global.__PASSWORD_POLICY__;
return !(flags && flags.keyboardSequence === false);
}
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
@@ -42,6 +69,8 @@
nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); },
// 키보드 배열 연속 문자 금지 — PortalProperty 로 끄면 항상 통과
nokeyseq: function (pw) { return !keyboardSequenceBlocked() || !hasKeyboardSequence(pw); },
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
noid: function (pw, ctx) {
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
@@ -26,6 +26,9 @@
var startTs = 0;
var targetOpblock = null;
// Execute/응답 렌더 사이 또는 응답 렌더 직후에 "응답지우기"를 누른 경우,
// 지연된 렌더 작업이 지워진 패널을 다시 추가하지 않도록 세대 번호를 사용한다.
var responseRevision = 0;
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
var pendingExecute = false;
@@ -266,6 +269,7 @@
} catch (err) { console.error("djb validation error:", err); }
}
responseRevision += 1;
startTs = now();
pendingExecute = true;
var grid = gridFor(targetOpblock);
@@ -277,6 +281,24 @@
scrollToGrid(targetOpblock);
}
// Swagger UI의 "Clear"(.btn-clear)는 기본 라이브 응답만 제거한다. 포탈에서
// 별도 추가한 응답 그리드도 같은 operation 안에서 함께 제거한다.
function onClearResponseClick(e) {
var t = e.target;
if (!t || !t.closest) return;
var btn = t.closest(".btn.btn-clear");
if (!btn) return;
responseRevision += 1;
pendingExecute = false;
var opblock = btn.closest(".opblock");
if (opblock) {
var grids = opblock.querySelectorAll(".djb-response-grid");
grids.forEach(function (grid) { grid.remove(); });
}
if (targetOpblock === opblock) targetOpblock = null;
}
/**
* @param {Object} res Swagger UI responseInterceptor 결과
* { status, statusText, headers, text, data, ... }
@@ -290,6 +312,7 @@
if (!pendingExecute) return;
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
pendingExecute = false;
var renderRevision = responseRevision;
var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0;
var statusText = res.statusText || "";
@@ -301,6 +324,8 @@
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
setTimeout(function () {
// "응답지우기"가 클릭된 뒤 도착한 응답은 다시 표시하지 않는다.
if (renderRevision !== responseRevision) return;
var grid = gridFor(op);
if (!grid) return;
var kind = statusKind(status);
@@ -358,6 +383,7 @@
root.__djbRespBound = true;
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
root.addEventListener("click", onExecuteClick, true);
root.addEventListener("click", onClearResponseClick, true);
// window.ui 준비 시 네트워크 오류 감시 등록
watchNetworkErrors(global.ui);
}
@@ -30,6 +30,19 @@ body {
color: $text-dark;
background-color: $white;
overflow-x: hidden;
// 콘텐츠가 짧은 페이지에서도 footer 가 화면 하단에 붙도록(sticky footer).
// footer 는 .global-footer 의 margin-top:auto 로 밀려난다.
min-height: 100vh;
display: flex;
flex-direction: column;
// flex 컨테이너가 되면 자식이 축소(shrink)되거나 내부 콘텐츠 min-content 폭까지 늘어난다.
// block 레이아웃과 동일한 폭 계산이 되도록 고정한다(모바일 가로 스크롤 방지).
> * {
flex-shrink: 0;
width: 100%;
}
}
// Lists
@@ -17,6 +17,8 @@
&.authenticated {
gap: 0;
flex-direction: column;
align-items: flex-end;
}
@@ -41,6 +41,9 @@
position: fixed;
top: 16px;
right: 20px;
// body > * { width: 100% } 전역 리셋이 body 직계 자식인 이 플로팅 위젯에도 걸려
// 뷰포트 전체 폭으로 늘어나는 문제 방지 (fixed 위젯은 콘텐츠 크기만큼만 차지해야 함)
width: max-content;
z-index: 1000;
display: flex;
align-items: center;
@@ -163,6 +163,8 @@
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
// body(flex column) 기준으로 남은 공간을 위쪽 여백으로 흡수 → 짧은 페이지에서 화면 하단 고정
margin-top: auto;
.container {
max-width: 1200px;
@@ -137,7 +137,8 @@
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
// 로고 파일(PTL_PROPERTY brand.logo.header.path)마다 원본 비율이 달라 width는 auto로 두고
// height만 고정한다. flex 축소가 걸리면 그 auto width가 눌릴 수 있어 shrink는 막아둔다.
img { flex-shrink: 0; }
.mobile-logo-link {
@@ -156,6 +157,56 @@
}
}
// 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 화면 최좌측·최상단 floating 뱃지 / 모바일·태블릿: 최상단 floating 바
// 둘 다 fixed 라 헤더 레이아웃(로고·메뉴 정렬)에는 영향을 주지 않는다.
.env-badge {
display: inline-flex;
align-items: center;
position: fixed;
top: 0;
left: 0;
padding: 1px 6px;
// 좌상단 모서리에 붙으므로 우/하단만 둥글게
border-radius: 0 0 4px 0;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.5;
z-index: 1100;
pointer-events: none;
@media (max-width: 1024px) {
display: none;
}
}
.env-strip {
display: none;
@media (max-width: 1024px) {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 16px;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
z-index: 1100;
pointer-events: none;
}
}
// Logo Wrapper
.logo-wrapper {
display: flex;
@@ -195,7 +246,7 @@
img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
}
@@ -641,6 +692,10 @@
margin: 0;
text-align: left;
}
.last-login-info {
padding: 0;
}
}
}
@@ -1269,9 +1324,22 @@
}
}
.last-login-info {
font-family: $font-family-primary;
font-size: 9pt;
color: var(--text-gray);
white-space: nowrap;
padding: 0 6px;
line-height: 1.4;
}
// Mobile responsive
@media (max-width: 1024px) {
.header-user-info {
display: none; // Hide on mobile, use drawer instead
}
.auth-group .last-login-info {
display: none; // 데스크톱 상단바 전용. 모바일은 drawer(.welcome-user-info) 쪽에 별도 노출
}
}
+1
View File
@@ -73,6 +73,7 @@
@use 'pages/api-statistics' as *;
@use 'pages/webhook' as *;
@use 'pages/api-status' as *;
@use 'pages/noti-test' as *;
// 6. Themes
@use 'themes/dark' as *;
@@ -926,11 +926,59 @@
}
}
/* 하단 중앙 "API 사용 신청" 버튼 */
/* API 사용 신청은 클라이언트 신청 흐름에서 진행됨을 안내 */
.api-apply-footer {
display: flex;
justify-content: center;
margin-top: $spacing-xl;
align-items: center;
gap: 28px;
width: 100%;
margin-top: $spacing-3xl;
padding: $spacing-xl 44px;
border: 1px solid #d5e6f7;
border-radius: $border-radius-xl;
background: linear-gradient(100deg, #eaf4fd, #dfedfb);
&__body {
flex: 1;
min-width: 0;
}
&__title {
margin: 0;
color: #0d3264;
font-size: 22px;
font-weight: $font-weight-bold;
}
&__guide {
margin: 6px 0 0;
color: $text-gray;
font-size: 15px;
}
&__action {
flex: none;
margin-left: auto;
padding: 15px 30px;
font-size: 15px;
white-space: nowrap;
}
@media (max-width: $breakpoint-sm) {
flex-direction: column;
align-items: flex-start;
padding: $spacing-lg;
&__title {
font-size: $font-size-lg;
}
&__action {
justify-content: center;
width: 100%;
margin-left: 0;
}
}
}
.api-overview-card {
@@ -1351,4 +1399,4 @@
.api-card {
height: 285px;
}
}
}
@@ -1198,6 +1198,11 @@
padding-right: 20px;
flex: 1;
&:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) {
font-size: 14px;
}
@@ -0,0 +1,208 @@
// ============================================================
// 알림 발송 테스트 페이지 (/djb/notitest)
// PTL_PROPERTY 접근 제한되는 내부 도구 GNB 미노출
// ============================================================
@use '../abstracts/variables' as *;
// 전역 타이포가 h1~h6 $font-family-heading(OneShinhan 우선) 지정하므로
// 페이지는 범위 안에서 본문 폰트로 되돌린다(내부 도구 브랜드 서체 불필요).
.nt-page,
.nt-page h1,
.nt-page h2,
.nt-page h3 {
font-family: $font-family-primary;
}
.nt-page {
margin: 44px 0 60px;
.nt-header {
margin-bottom: 24px;
h1 {
margin: 0 0 8px;
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $text-dark;
}
}
.nt-desc {
margin: 0;
font-size: $font-size-sm;
color: $text-gray;
line-height: 1.5;
}
.nt-hint {
margin: 8px 0 0;
font-size: $font-size-xs;
color: $text-gray;
}
}
.nt-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: start;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.nt-catalog {
display: flex;
flex-direction: column;
gap: 16px;
}
.nt-category-card {
padding: 24px;
.nt-category-title {
margin: 0 0 12px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
}
.nt-target {
position: relative;
padding: 10px 0;
border-bottom: 1px solid $border-gray;
&:last-child {
border-bottom: none;
}
.nt-risk-badge {
margin-left: 8px;
}
}
.nt-params {
margin: 12px 0 4px 26px;
padding: 12px 16px;
background: $gray-bg;
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
.nt-param-field {
margin: 0;
min-width: 180px;
flex: 1 1 200px;
label {
display: block;
margin-bottom: 4px;
font-size: $font-size-xs;
color: $text-gray;
}
}
}
.nt-side {
position: sticky;
top: 96px;
}
.nt-recipient-card {
padding: 24px;
h2 {
margin: 0 0 8px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
.btn-block {
width: 100%;
margin-top: 8px;
}
}
.nt-history-card {
margin-top: 24px;
padding: 24px;
h2 {
margin: 0 0 4px;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-dark;
}
}
.nt-table-wrap {
margin-top: 12px;
overflow-x: auto;
table {
width: 100%;
border-collapse: collapse;
border: 1px solid $border-gray;
}
th, td {
padding: 10px 14px;
border: 1px solid $border-gray;
text-align: left;
font-size: $font-size-sm;
vertical-align: top;
white-space: nowrap;
}
thead th {
background: $light-bg;
font-weight: $font-weight-semibold;
color: $text-dark;
}
tbody td {
color: $text-gray;
}
}
.nt-message-cell {
min-width: 280px;
white-space: normal;
}
.nt-message-textarea {
width: 100%;
min-height: 64px;
padding: 8px 10px;
border: 1px solid $border-gray;
border-radius: 6px;
background: $gray-bg;
color: $text-dark;
font-family: $font-family-primary;
font-size: $font-size-xs;
line-height: 1.5;
resize: vertical;
&:focus {
outline: none;
border-color: $primary-blue;
}
}
.nt-row-note {
margin-top: 4px;
font-size: $font-size-xs;
}
.nt-row-warning {
color: $accent-orange;
}
.nt-row-error {
color: $accent-orange;
font-weight: $font-weight-medium;
}
@@ -102,6 +102,40 @@
color: #334155;
word-break: break-word;
}
// 본인 삭제 버튼(아코디언 펼친 상태에서만 노출)
.recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
&:hover {
background: #fef2f2;
border-color: #f87171;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
}
}
.page-title {

Some files were not shown because too many files have changed in this diff Show More