Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d044ebd157 | |||
| af40f0ba61 | |||
| d6b3a44d61 | |||
| e57f2fe72f | |||
| cecf6260a6 | |||
| 52707998d4 | |||
| 28017fd83c | |||
| e55b7a6bf2 | |||
| 1b6fed7701 | |||
| 033faa6714 | |||
| 758b3ebd7d | |||
| f07123904a | |||
| 2bc2a3d6e3 | |||
| b58893d098 | |||
| 80b072983f | |||
| a456ed9bb0 | |||
| 33c64972f8 | |||
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 | |||
| dfb37c18b9 | |||
| 5f5b23026f | |||
| 5c61fc3f24 | |||
| a7aa5bc98c | |||
| bf63701e68 | |||
| f26aae296a |
+67
-11
@@ -83,7 +83,10 @@ dependencies {
|
||||
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
|
||||
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
// Thymeleaf 3.1 코어가 #temporals 를 내장 제공 → java8time extras 를 함께 두면 표현식 객체가 중복 등록된다.
|
||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
||||
}
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
@@ -91,33 +94,40 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
implementation 'org.springframework:spring-expression:5.3.30'
|
||||
// spring-expression 개별 pin 제거: 아래 ext 의 spring-framework.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 이 남아 있으면 다른 spring-* 모듈보다 낮은 버전으로 고정되어 버전이 어긋난다.
|
||||
|
||||
implementation group: 'xalan', name: 'xalan', version: '2.7.3'
|
||||
|
||||
implementation 'org.hibernate:hibernate-envers:5.6.15.Final'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.0.0'
|
||||
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.4.0' // 3.0.0 은 thymeleaf 3.0 전용
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
|
||||
|
||||
implementation 'com.github.ua-parser:uap-java:1.5.3'
|
||||
// uap-java 제거: 소스 전체에 ua_parser 참조 0건이고, 1.5.3 은 snakeyaml 2.x 에서 삭제된
|
||||
// SafeConstructor() no-arg 를 호출해 NoSuchMethodError 를 낸다. 되살릴 경우 1.6.1 이상.
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
|
||||
implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1'
|
||||
implementation 'javax.servlet:javax.servlet-api:4.0.0'
|
||||
implementation 'org.jasypt:jasypt:1.9.3'
|
||||
implementation 'xerces:xercesImpl:2.12.2'
|
||||
|
||||
implementation 'org.apache.commons:commons-lang3:3.12.0'
|
||||
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
|
||||
implementation 'commons-net:commons-net:3.9.0'
|
||||
implementation('commons-beanutils:commons-beanutils:1.9.4') {
|
||||
// CVE-2025-48734 (PropertyUtilsBean 이 enum 의 declaredClass 프로퍼티 노출 → ClassLoader 접근/RCE).
|
||||
// 1.11.0 부터 SuppressPropertiesBeanIntrospector 가 기본 활성이라 declaredClass 접근이 차단된다.
|
||||
// 이 앱의 호출부(PasswordMatchValidator / PasswordRuleValidator / AuthNumberValidator)는
|
||||
// 어노테이션에 박힌 고정 프로퍼티명만 넘기므로 외부 입력 경로는 없지만 버전은 올려 둔다.
|
||||
// 1.11.0 = Java 8 바이트코드(major 52), PropertyUtils.getProperty/getNestedProperty API 동일.
|
||||
implementation('commons-beanutils:commons-beanutils:1.11.0') {
|
||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
|
||||
// jackson 개별 pin 제거: 아래 ext 의 jackson-bom.version 이 전 모듈을 일괄 관리한다.
|
||||
// 개별 pin 은 BOM 보다 우선하므로 남겨 두면 BOM 만 올렸을 때 core/annotations/databind 가
|
||||
// 옛 버전에 고정돼 버전이 어긋난다(실제로 그런 상태였다).
|
||||
|
||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||
|
||||
@@ -127,7 +137,10 @@ dependencies {
|
||||
implementation 'net.bytebuddy:byte-buddy:1.14.5'
|
||||
|
||||
// Commons FileUpload (WAS 독립적인 multipart 처리)
|
||||
implementation 'commons-fileupload:commons-fileupload:1.5'
|
||||
// CVE-2025-48976 (멀티파트 파트 헤더 크기 제한 부재 → DoS). 1.6.0 에서 partHeaderSizeMax 도입.
|
||||
// 주의: 1.6 부터 파트 헤더 총량 기본 상한이 10240 → 512 바이트로 줄었다(DEFAULT_PART_HEADER_SIZE_MAX).
|
||||
// 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 길어질 수 있어 실측으로 여유를 확인했다.
|
||||
implementation 'commons-fileupload:commons-fileupload:1.6.0'
|
||||
implementation 'commons-io:commons-io:2.15.1'
|
||||
|
||||
|
||||
@@ -145,6 +158,49 @@ ext {
|
||||
springMavenArtifactVersion = '5.3.30'
|
||||
encoding = 'UTF-8'
|
||||
profile = 'local'
|
||||
|
||||
// 내장 Tomcat 버전 상향 (Spring Boot 2.7.18 기본값 9.0.83 → 9.0.120).
|
||||
// Boot BOM 의 tomcat.version 프로퍼티를 덮어써서 tomcat-embed-core/-el/-websocket 이 함께 올라간다.
|
||||
// 9.0.x 계열 유지 = Servlet 4.0 / javax.* 네임스페이스 그대로, JDK8 호환.
|
||||
set('tomcat.version', '9.0.120')
|
||||
|
||||
// CVE-2022-1471 (snakeyaml Constructor 임의 타입 역직렬화 → RCE). Boot 2.7.18 BOM 기본값 1.30 → 2.6.
|
||||
// 앱/프레임워크 실경로는 이미 SafeConstructor 계열(OriginTrackedYamlLoader, spring-beans
|
||||
// FilteringConstructor, swagger-parser DeserializationUtils)이라 익스플로잇 경로는 없었으나
|
||||
// SCA 는 버전으로 판정하므로 2.x 로 올린다. snakeyaml 2.6 = Java 8 바이트코드(major 52).
|
||||
// 2.6 인 이유: swagger-core 2.2.52 / swagger-parser-v3 2.1.45 가 요구하는 버전이라 강등이 없다.
|
||||
// 부수 효과: swagger-parser 가 호출하는 LoaderOptions.setCodePointLimit(1.32+ API) 도 해소.
|
||||
set('snakeyaml.version', '2.6')
|
||||
|
||||
// jackson 전 모듈 버전 통일(Boot 2.7.18 BOM 기본 2.13.5). 2.18.x 는 JDK8 호환 라인이다.
|
||||
// 이유 3가지
|
||||
// 1) snakeyaml 2.x 는 ParserImpl(StreamReader) 를 제거했고 jackson-dataformat-yaml 은
|
||||
// 2.15+ 부터 ParserImpl(StreamReader, LoaderOptions) 를 쓴다 — 위 snakeyaml 상향의 전제.
|
||||
// 2) WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정.
|
||||
// 3) jackson-databind PolymorphicTypeValidator 우회(제네릭 타입 인자 미검증) — 2.18.8 에서 수정.
|
||||
// 이 앱은 다형성 역직렬화(activateDefaultTyping/@JsonTypeInfo)를 쓰지 않아 노출 경로는 없다.
|
||||
// 2.18.x 마지막 패치를 쓴다.
|
||||
set('jackson-bom.version', '2.18.10')
|
||||
|
||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
||||
set('thymeleaf.version', '3.1.4.RELEASE')
|
||||
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
||||
|
||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||
// 5.3.32~5.3.39 구간 CVE 정리용. 5.3.x 는 OSS EOL 이라 이 위로는 상용(Enterprise) 빌드뿐이다.
|
||||
// 남는 5.3.x 지적(CVE-2026-41855 JMS Jackson 역직렬화, CVE-2016-1000027 HttpInvoker)은
|
||||
// 5.3 계열에 수정본이 없고 앱이 JMS/HttpInvoker 를 쓰지 않으므로 억제 항목으로 따로 관리한다.
|
||||
set('spring-framework.version', '5.3.39')
|
||||
|
||||
// Spring Security 5.7.x OSS 마지막 릴리스(Boot 2.7.18 BOM 기본 5.7.11).
|
||||
// CVE-2026-22732(응답 커밋 후 보안 헤더 미기록)는 수정본이 5.7.22/5.8.24 = Enterprise 전용이라
|
||||
// OSS 로는 올릴 수 없다. 우회책(HeaderWriterFilter.shouldWriteHeadersEagerly=true)은 동작 변경이라 분리 검토.
|
||||
set('spring-security.version', '5.7.14')
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,4 +28,94 @@
|
||||
</suppress>
|
||||
-->
|
||||
|
||||
<!-- ================================================================================
|
||||
Spring 계열 Critical 5건 (2026-08-18 리포트 기준).
|
||||
|
||||
공통 배경: Spring Framework 5.3.x / Spring Boot 2.7.x / Spring Security 5.7.x 는 모두 OSS EOL 이라
|
||||
남은 수정본이 상용(Enterprise/Tanzu)뿐이다. JDK 8 유지 제약상 Boot 3 + Spring 6 이관 전까지는
|
||||
버전 상향으로 못 없앤다. 아래는 "코드에 트리거 경로가 없음"을 근거로 한 억제이며 만료일을 둔다.
|
||||
버전 범위를 5.3.x / 2.7.x / 5.7.x 로 고정해 두었으므로, 이관 후에는 억제가 자동으로 풀린다.
|
||||
재검토 시 확인할 것: (1) 각 근거 grep 이 여전히 0건인지 (2) OSS 수정본이 나왔는지.
|
||||
================================================================================ -->
|
||||
|
||||
<!-- 1. Spring Boot: Cassandra SSL 호스트명 미검증 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-40974 는 Spring Boot 의 Cassandra SSL 자동설정이 SSL 번들의 호스트명 검증 설정을
|
||||
드라이버에 전달하지 않는 문제다. 이 앱은 Cassandra 를 쓰지 않는다
|
||||
(runtimeClasspath 에 cassandra/datastax 계열 jar 0건, spring-boot-starter-data-cassandra 미선언).
|
||||
수정본 2.7.33 은 상용(Enterprise) 릴리스라 OSS 로는 올릴 수 없다.
|
||||
devtools/actuator/spring-boot-admin jar 는 스캔 대상(runtimeClasspath)에는 있으나
|
||||
배포 산출물에서는 build.gradle 의 localOnlyLibPrefixes 로 제외되어 WAR 에 실리지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / grep -ri cassandra 0건, WAR 내 devtools/actuator jar 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/.*@2\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-40974</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 2. Thymeleaf SSTI 2건: starter jar 이름 기준 오탐 -->
|
||||
<suppress>
|
||||
<notes><![CDATA[
|
||||
오탐. CVE-2026-40477 / CVE-2026-40478 은 thymeleaf 본체 3.1.3.RELEASE 이하의 표현식 샌드박스
|
||||
우회 문제이고 3.1.4.RELEASE 에서 수정됐다. 이 프로젝트는 build.gradle 의 ext 에서
|
||||
thymeleaf.version=3.1.4.RELEASE / thymeleaf-extras-springsecurity.version=3.1.5.RELEASE 로
|
||||
올려 두었으므로 실제 실리는 jar 는 thymeleaf-3.1.4.RELEASE.jar 다.
|
||||
Dependency-Check 가 spring-boot-starter-thymeleaf-2.7.18.jar(의존만 선언한 빈 starter)에
|
||||
thymeleaf CPE 를 잘못 매칭한 결과다. thymeleaf 본체 jar 에 대한 탐지는 억제하지 않는다.
|
||||
확인: Rinjae / 2026-08-18 / WAR 내 thymeleaf-3.1.4.RELEASE.jar, thymeleaf-spring5-3.1.4.RELEASE.jar.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.boot/spring-boot-starter-thymeleaf@.*$</packageUrl>
|
||||
<cve>CVE-2026-40477</cve>
|
||||
<cve>CVE-2026-40478</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 3. Spring Framework: JMS Jackson 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-41855 는 org.springframework.jms.support.converter.MappingJackson2MessageConverter /
|
||||
JacksonJsonMessageConverter 가 임의 클래스 인스턴스화를 허용하는 문제다(신뢰할 수 없는 JMS 환경 전제).
|
||||
이 앱은 JMS 를 쓰지 않는다: runtimeClasspath 에 spring-jms 0건, 소스에 javax.jms /
|
||||
JmsTemplate / MappingJackson2MessageConverter 참조 0건.
|
||||
5.3.x 는 OSS 수정본이 없다(6.2.19 / 7.0.8 에서만 수정). 이미 5.3.x OSS 마지막인 5.3.39 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn "javax.jms|JmsTemplate|MappingJackson2MessageConverter" src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-.*@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2026-41855</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 4. spring-web: HttpInvoker 역직렬화 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2016-1000027 은 HttpInvokerServiceExporter 를 노출했을 때만 성립한다. 해당 클래스는
|
||||
Spring 6.0 에서 제거됐고 5.3.x 에는 수정본이 없다(= 5.3.x 를 쓰는 한 계속 탐지된다).
|
||||
이 앱은 HttpInvoker 계열을 쓰지 않는다: 소스에 HttpInvoker 참조 0건이고 외부 호출은
|
||||
RestTemplate / HttpClient 기반이다.
|
||||
근본 해결은 Spring 6(Boot 3, JDK 17) 이관. 이관 전까지 억제한다.
|
||||
확인: Rinjae / 2026-08-18 / grep -rn HttpInvoker src 0건.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@5\.3\..*$</packageUrl>
|
||||
<cve>CVE-2016-1000027</cve>
|
||||
</suppress>
|
||||
|
||||
<!-- 5. Spring Security: 응답 커밋 시 보안 헤더 미기록 -->
|
||||
<suppress until="2027-02-28Z">
|
||||
<notes><![CDATA[
|
||||
CVE-2026-22732 는 응답이 커밋된 뒤 Spring Security 가 보안 헤더를 기록하지 못하는 문제다.
|
||||
트리거는 Content-Length 를 setHeader / setIntHeader / addIntHeader 로 지정하는 경로다
|
||||
(OnCommittedResponseWrapper 는 setContentLength / setContentLengthLong / addHeader 만 추적한다).
|
||||
(1) 이 코드베이스에는 트리거가 없다: portal / elink-portal-common / elink-online-core-jpa 전체에
|
||||
setIntHeader, addIntHeader, setHeader("Content-Length") 0건. 파일 다운로드는
|
||||
response.setContentLength(int) 를 쓰며 실측상 보안 헤더가 정상 기록된다.
|
||||
(2) 그럼에도 안전망으로 PortalConfigSecurity 에서
|
||||
HeaderWriterFilter.setShouldWriteHeadersEagerly(true) 를 적용해(spring.io 권고 우회책)
|
||||
요청 시작 시점에 헤더를 기록하도록 했다.
|
||||
수정본 5.7.22 / 5.8.24 는 Enterprise 전용이라 OSS 로는 올릴 수 없다. 이미 5.7.x OSS 마지막인
|
||||
5.7.14 로 올려 둔 상태다.
|
||||
확인: Rinjae / 2026-08-18 / 동일 스택(Boot 2.7.18 + Security 5.7.14 + Tomcat 9.0.120) 프로브 앱 실측 —
|
||||
우회책 미적용 시 setHeader/setIntHeader/addIntHeader 경로에서 헤더 누락 재현, 적용 후 정상 기록.
|
||||
]]></notes>
|
||||
<packageUrl regex="true">^pkg:maven/org\.springframework\.security/spring-security-.*@5\.7\..*$</packageUrl>
|
||||
<cve>CVE-2026-22732</cve>
|
||||
</suppress>
|
||||
|
||||
</suppressions>
|
||||
|
||||
@@ -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. 개정
|
||||
@@ -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> ① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.</p>
|
||||
<p> 1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API</p>
|
||||
<p> 2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것</p>
|
||||
<p> 3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템</p>
|
||||
<p> 4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트</p>
|
||||
<p> 5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)</p>
|
||||
<p> 6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자</p>
|
||||
<p> 7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객</p>
|
||||
<p> 8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자</p>
|
||||
<h3>제3조 (서비스의 내용)</h3>
|
||||
<p> ① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)</p>
|
||||
<p> ② 거래성 API 서비스(환전, 이체 등)</p>
|
||||
<p> ③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”</p>
|
||||
<h3>제4조 (약관의 효력 및 변경)</h3>
|
||||
<p> ① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.</p>
|
||||
<p> ② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||
<h2>제2장 서비스 이용계약</h2>
|
||||
<h3>제5조 (서비스 이용신청)</h3>
|
||||
<p> ① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.</p>
|
||||
<p> ② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.</p>
|
||||
<h3>제6조 (서비스 이용신청 승인 및 거절)</h3>
|
||||
<p> ① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.</p>
|
||||
<p> ② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.</p>
|
||||
<p> 1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우</p>
|
||||
<p> 2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우</p>
|
||||
<p> 3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우</p>
|
||||
<p> 4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우</p>
|
||||
<p> 5. “이용신청기관”의 사업모델에 위법성이 있는 경우</p>
|
||||
<p> 6. “이용신청기관”의 재무안정성이 위험한 경우</p>
|
||||
<p> 7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우</p>
|
||||
<p> 8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우</p>
|
||||
<p> ③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.</p>
|
||||
<h2>제3장 서비스의 이용</h2>
|
||||
<h3>제7조 (서비스 이용개시)</h3>
|
||||
<p>“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.</p>
|
||||
<h3>제8조 (서비스 이용시간)</h3>
|
||||
<p> ① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.</p>
|
||||
<p> 1. 은행의 시스템을 점검하는 경우</p>
|
||||
<p> 2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우</p>
|
||||
<p> 3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우</p>
|
||||
<p> 4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우</p>
|
||||
<p> 5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우</p>
|
||||
<p> ② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.</p>
|
||||
<h3>제9조 (서비스 변경 및 중단)</h3>
|
||||
<p> ① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.</p>
|
||||
<p> ② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.</p>
|
||||
<p> 1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우</p>
|
||||
<p> 2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우</p>
|
||||
<p> 3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우</p>
|
||||
<p> 4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우</p>
|
||||
<p> ③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<p> ④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<h3>제10조 (서비스 이용 제한)</h3>
|
||||
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.</p>
|
||||
<p> 1. 감독당국으로부터 중지 권고가 있는 경우</p>
|
||||
<p> 2. “이용기관”이 휴업 또는 폐업을 하는 경우</p>
|
||||
<p> 3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우</p>
|
||||
<p> 4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우</p>
|
||||
<p> 5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우</p>
|
||||
<p> 6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우</p>
|
||||
<p> ② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.</p>
|
||||
<h3>제11조 (서비스 이용 해지)</h3>
|
||||
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.</p>
|
||||
<p> 1. 감독당국으로부터 해지권고가 있는 경우</p>
|
||||
<p> 2. 최근 12개월간 “시스템” 이용 실적이 없는 경우</p>
|
||||
<p> 3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우</p>
|
||||
<p> 4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우</p>
|
||||
<p> ② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.</p>
|
||||
<p> ③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.</p>
|
||||
<h2>제4장 계약당사자의 의무 및 손해배상 등</h2>
|
||||
<h3>제12조 (은행의 의무)</h3>
|
||||
<p> ① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.</p>
|
||||
<p> ② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.</p>
|
||||
<p> ③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.</p>
|
||||
<p> ④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||
<h3>제13조 (약관 외 준칙)</h3>
|
||||
<p> ① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.</p>
|
||||
<p> ② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.</p>
|
||||
<p> ③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.</p>
|
||||
<p> ④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.</p>
|
||||
<p> ⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.</p>
|
||||
<p> ⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.</p>
|
||||
<p> ⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.</p>
|
||||
<p> ⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.</p>
|
||||
<p> 1. 다른 “이용기관”의 아이디를 부정 사용하는 행위</p>
|
||||
<p> 2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위</p>
|
||||
<p> 3. 선량한 풍속, 기타 사회질서를 해하는 행위</p>
|
||||
<p> 4. 타인의 명예를 훼손하거나 모욕하는 행위</p>
|
||||
<p> 5. 타인의 지식재산권 등의 권리를 침해하는 행위</p>
|
||||
<p> 6. 해킹행위 또는 컴퓨터 바이러스의 유포행위</p>
|
||||
<p> 7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위</p>
|
||||
<p> 8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위</p>
|
||||
<p> ⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||
<h2>제5장 수수료</h2>
|
||||
<h3>제14조 (수수료)</h3>
|
||||
<p> ① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.</p>
|
||||
<p> ② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.</p>
|
||||
<p> ③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.</p>
|
||||
<p> ④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.</p>
|
||||
<p> ⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.</p>
|
||||
<p> ⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||
<h2>제6장 보칙</h2>
|
||||
<h3>제15조 (최종사용자에 대한 고지 의무)</h3>
|
||||
<p> ① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.</p>
|
||||
<p> ② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.</p>
|
||||
<p> ③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.</p>
|
||||
<p> ④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.</p>
|
||||
<h3>제16조 (부정 접속 방지)</h3>
|
||||
<p> ① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.</p>
|
||||
<p> ② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.</p>
|
||||
<h3>제17조 (전산처리 절차)</h3>
|
||||
<p>전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.</p>
|
||||
<h3>제18조 (보안관리)</h3>
|
||||
<p> ① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.</p>
|
||||
<p> 1. 개인정보 및 금융정보 암호화 운영현황</p>
|
||||
<p> 2. 해킹 등에 대비한 정책사항</p>
|
||||
<p> 3. 시스템 운영에 대한 정보보호 체계 등</p>
|
||||
<p> ② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.</p>
|
||||
<p> ③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.</p>
|
||||
<h3>제19조 (기밀유지)</h3>
|
||||
<p> ① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.</p>
|
||||
<p> ② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.</p>
|
||||
<h3>제20조 (손해배상 및 면책)</h3>
|
||||
<p> ① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||
<p> ② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||
<p> 1. 접근매체의 위조나 변조로 발생한 사고</p>
|
||||
<p> 2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고</p>
|
||||
<p> 3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고</p>
|
||||
<p> ③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.</p>
|
||||
<p> ④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||
<p> 1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)</p>
|
||||
<p> 2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우</p>
|
||||
<p> 3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우</p>
|
||||
<p> 4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우</p>
|
||||
<p> 가. 누설·노출 또는 방치한 행위</p>
|
||||
<p> 나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위</p>
|
||||
<p> 5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우</p>
|
||||
<h3>제21조 (이의제기 및 협조)</h3>
|
||||
<p> ① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.</p>
|
||||
<p> ② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.</p>
|
||||
<p> ③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.</p>
|
||||
<p> ④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.</p>
|
||||
<h3>제22조 (거래기록·자료의 제공)</h3>
|
||||
<p> ① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.</p>
|
||||
<p> ② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.</p>
|
||||
<p> ③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.</p>
|
||||
<p> ④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.</p>
|
||||
<p> ⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.</p>
|
||||
<p> 1. 전자적 장치를 통한 제공의 경우에는 즉시</p>
|
||||
<p> 2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부</p>
|
||||
<h2>부칙</h2>
|
||||
<p>본 약관은 2026. 99. 99.부터 시행합니다.</p>
|
||||
@@ -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.부터 시행합니다.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
알림수신동의서
|
||||
|
||||
(주)제주은행 귀중
|
||||
|
||||
귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.
|
||||
|
||||
발송정보
|
||||
ㄴ 수집·이용 항목: 휴대폰 번호
|
||||
ㄴ 발송 목적: Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송
|
||||
ㄴ 보유·이용 기간: 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기
|
||||
|
||||
□ 동의하지 않음 □ 동의함
|
||||
|
||||
※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.
|
||||
|
||||
※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.
|
||||
|
||||
※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.
|
||||
|
||||
위 알림 수신에 동의하십니까?
|
||||
|
||||
제정일자: 2026. 99. 99. 제정
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@@ -155,10 +156,10 @@ public class ApiSpecService {
|
||||
updateReferences(pathItem, apiId);
|
||||
|
||||
String tmpFullPath = StringUtils.isNotEmpty(basePath)
|
||||
? StringUtils.join(basePath, "/", StringUtils.removeStart(path, "/"))
|
||||
? StringUtils.join(basePath, "/", Strings.CS.removeStart(path, "/"))
|
||||
: path;
|
||||
|
||||
final String fullPath = StringUtils.replacePattern(tmpFullPath, "//+", "/");
|
||||
final String fullPath = tmpFullPath.replaceAll("//+", "/");
|
||||
|
||||
// HTTP 메소드별로 중복 체크
|
||||
pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
|
||||
@@ -294,7 +295,7 @@ public class ApiSpecService {
|
||||
return StringUtils.EMPTY;
|
||||
}
|
||||
Server server = api.getServers().get(0);
|
||||
return StringUtils.removeEnd(server.getUrl(), "/");
|
||||
return Strings.CS.removeEnd(server.getUrl(), "/");
|
||||
}
|
||||
|
||||
private void mergeComponents(OpenAPI currentAPI, String apiId, String apiName, Map<String, Object> mergedComponents, ObjectMapper objectMapper) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+18
-3
@@ -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";
|
||||
}
|
||||
|
||||
+32
-1
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -18,7 +20,25 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
+6
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||
*/
|
||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||
*/
|
||||
void deleteMyApplication(String id);
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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 작성이 완료되었습니다.");
|
||||
|
||||
+11
@@ -3,7 +3,9 @@ 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;
|
||||
@@ -17,4 +19,13 @@ public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSp
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
+41
-6
@@ -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")
|
||||
|
||||
+2
-2
@@ -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,16 @@ 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).
|
||||
*/
|
||||
@@ -133,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)에서 조회.
|
||||
*/
|
||||
|
||||
+44
-8
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.common.exception;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -36,23 +37,27 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@RequiredArgsConstructor
|
||||
public class PortalGlobalExceptionHandler {
|
||||
|
||||
private static final String REDIRECT_PREFIX = "redirect:";
|
||||
private static final String REDIRECT_HOME = REDIRECT_PREFIX + "/";
|
||||
private static final String REDIRECT_LOGIN_AUTH = REDIRECT_PREFIX + "/login?reason=auth";
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
private final PortalProperties portalProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@ExceptionHandler(value = NotFoundException.class)
|
||||
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = MethodArgumentTypeMismatchException.class)
|
||||
public ModelAndView handleMethodArgumentTypeMismatchException(HttpServletRequest request, MethodArgumentTypeMismatchException ex) {
|
||||
return new ModelAndView("redirect:/");
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
@@ -61,7 +66,7 @@ public class PortalGlobalExceptionHandler {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
|
||||
savePostLoginRedirect(request);
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
return new ModelAndView(REDIRECT_LOGIN_AUTH);
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
@@ -122,9 +127,40 @@ public class PortalGlobalExceptionHandler {
|
||||
|| path.startsWith("/favicon"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 리다이렉트/뷰 이름으로 허용할 문자. 스킴 구분자(:)·중괄호·달러를 막아
|
||||
* 외부 URL 이나 Thymeleaf 표현식이 뷰 이름으로 흘러드는 것을 차단한다.
|
||||
*/
|
||||
private static final Pattern SAFE_REDIRECT_TARGET = Pattern.compile("[A-Za-z0-9._/?=&%-]+");
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||
return new ModelAndView(ex.getMessage());
|
||||
// PortalRedirectException 은 super(message) 를 호출하지 않으므로 getMessage() 는 항상 null 이다.
|
||||
// 이전 구현은 그 null 을 뷰 이름으로 넘겨(= 뷰 미지정) 요청 URL 기준으로 뷰가 추론되게 만들었다.
|
||||
// 실제 대상은 redirectPage 필드다.
|
||||
String target = ex.getRedirectPage();
|
||||
if (!isSafeRedirectTarget(target)) {
|
||||
log.warn("허용되지 않은 리다이렉트 대상 - uri={}, target={}", request.getRequestURI(), target);
|
||||
return new ModelAndView(REDIRECT_HOME);
|
||||
}
|
||||
return new ModelAndView(target);
|
||||
}
|
||||
|
||||
/** 내부 경로/뷰 이름만 허용(외부 URL·프로토콜 상대 URL·표현식 문자 차단) */
|
||||
private boolean isSafeRedirectTarget(String target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
String path = target;
|
||||
if (path.startsWith(REDIRECT_PREFIX)) {
|
||||
path = path.substring(REDIRECT_PREFIX.length());
|
||||
} else if (path.startsWith("forward:")) {
|
||||
path = path.substring("forward:".length());
|
||||
}
|
||||
if (path.isEmpty() || path.startsWith("//")) { // //evil.example 형태의 외부 리다이렉트 차단
|
||||
return false;
|
||||
}
|
||||
return SAFE_REDIRECT_TARGET.matcher(path).matches();
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = {IllegalArgumentException.class})
|
||||
@@ -140,7 +176,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleHttpRequestMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:/");
|
||||
modelAndView.setViewName(REDIRECT_HOME);
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -184,7 +220,7 @@ public class PortalGlobalExceptionHandler {
|
||||
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
redirectAttributes.addFlashAttribute("error", ex.getMessage());
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -195,7 +231,7 @@ public class PortalGlobalExceptionHandler {
|
||||
String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다.";
|
||||
redirectAttributes.addFlashAttribute("error", errorMessage);
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.setViewName("redirect:" + request.getRequestURI());
|
||||
modelAndView.setViewName(REDIRECT_PREFIX + request.getRequestURI());
|
||||
return modelAndView;
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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=계정 차단 후 로그아웃)"));
|
||||
}
|
||||
}
|
||||
+131
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
@@ -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,6 +1,7 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
@@ -39,14 +40,8 @@ public class CellPhoneValidator implements ConstraintValidator<CellPhone, String
|
||||
}
|
||||
|
||||
// 기존의 prefix 체크 로직 유지
|
||||
return StringUtils.startsWith(cellPhone, "+821") ||
|
||||
StringUtils.startsWith(cellPhone, "821") ||
|
||||
StringUtils.startsWith(cellPhone, "010") ||
|
||||
StringUtils.startsWith(cellPhone, "011") ||
|
||||
StringUtils.startsWith(cellPhone, "016") ||
|
||||
StringUtils.startsWith(cellPhone, "017") ||
|
||||
StringUtils.startsWith(cellPhone, "018") ||
|
||||
StringUtils.startsWith(cellPhone, "019");
|
||||
return Strings.CS.startsWithAny(cellPhone,
|
||||
"+821", "821", "010", "011", "016", "017", "018", "019");
|
||||
}
|
||||
|
||||
private boolean isValidPartLength(String prefix, String middle, String last) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.fileupload.FileItemFactory;
|
||||
import org.apache.commons.fileupload.FileUpload;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
|
||||
@@ -18,6 +20,18 @@ import org.springframework.web.multipart.commons.CommonsMultipartResolver;
|
||||
@RequiredArgsConstructor
|
||||
public class MultipartConfig {
|
||||
|
||||
/**
|
||||
* 멀티파트 파트 1개의 헤더 구간 최대 바이트.
|
||||
* <p>
|
||||
* commons-fileupload 1.6.0(CVE-2025-48976 수정)부터 이 상한이 생겼고 기본값이 512 바이트다.
|
||||
* 한글 파일명은 UTF-8 로 3바이트/자라 Content-Disposition 이 금방 커진다 — 실측상 기본값 512 로는
|
||||
* 한글 약 137자 이상 파일명에서 업로드가 실패하고, 예외도 "Maximum upload size exceeded" 로 감싸져
|
||||
* 원인 파악이 어렵다. OS 파일명 상한(255자)이 전부 한글이어도 통과하도록 2048 로 둔다.
|
||||
* (1.5 이전에는 사실상 10240 이었으므로 이 값도 그보다 5배 엄격하다.)
|
||||
* </p>
|
||||
*/
|
||||
private static final int PART_HEADER_SIZE_MAX = 2048;
|
||||
|
||||
private final PortalProperties portalProperties;
|
||||
|
||||
/**
|
||||
@@ -31,7 +45,14 @@ public class MultipartConfig {
|
||||
*/
|
||||
@Bean(name = "filterMultipartResolver")
|
||||
public CommonsMultipartResolver filterMultipartResolver() {
|
||||
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
|
||||
CommonsMultipartResolver resolver = new CommonsMultipartResolver() {
|
||||
@Override
|
||||
protected FileUpload newFileUpload(FileItemFactory fileItemFactory) {
|
||||
FileUpload fileUpload = super.newFileUpload(fileItemFactory);
|
||||
fileUpload.setPartHeaderSizeMax(PART_HEADER_SIZE_MAX);
|
||||
return fileUpload;
|
||||
}
|
||||
};
|
||||
|
||||
long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes();
|
||||
resolver.setMaxUploadSize(maxSizeBytes);
|
||||
@@ -39,8 +60,8 @@ public class MultipartConfig {
|
||||
resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE));
|
||||
resolver.setDefaultEncoding("UTF-8");
|
||||
|
||||
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})",
|
||||
maxSizeBytes, portalProperties.getFile().getMaxSize());
|
||||
log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({}), partHeaderSizeMax: {} bytes",
|
||||
maxSizeBytes, portalProperties.getFile().getMaxSize(), PART_HEADER_SIZE_MAX);
|
||||
|
||||
return resolver;
|
||||
}
|
||||
|
||||
+8
-17
@@ -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 + "회 이상 로그인 실패로 인한 계정 잠금");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -16,6 +17,7 @@ import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.header.HeaderWriterFilter;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
@@ -92,6 +94,23 @@ public class PortalConfigSecurity {
|
||||
|
||||
http
|
||||
.authenticationManager(portalAuthenticationManager)
|
||||
// CVE-2026-22732 우회책. Spring Security 는 기본적으로 보안 헤더를 "응답 커밋 시점"에
|
||||
// 지연 기록하는데, 응답 래퍼가 Content-Length 를 setHeader/setIntHeader/addIntHeader 로
|
||||
// 지정하는 경로를 추적하지 못해 그 경우 헤더가 통째로 누락된다
|
||||
// (X-Content-Type-Options, X-Frame-Options, Cache-Control, Pragma, Expires, X-XSS-Protection).
|
||||
// 수정본은 5.7.22/5.8.24(Enterprise 전용)뿐이라 OSS 로는 올릴 수 없어 우회책을 적용한다.
|
||||
// 요청 시작 시점에 헤더를 기록하게 만든다. 앱이 나중에 같은 헤더를 지정하면 앱 값이 남는다
|
||||
// (실측: FileDownloadController#viewImage 의 Cache-Control: public, max-age=86400 유지됨).
|
||||
// 현재 앱은 response.setContentLength(int) 만 쓰므로 노출 경로는 없지만,
|
||||
// 새 코드가 위 메서드를 쓰더라도 헤더가 빠지지 않도록 두는 안전망이다.
|
||||
// Spring Security 를 수정본(6.5.9+/7.0.4+ 또는 Enterprise 5.7.22+)으로 올리면 제거 가능.
|
||||
.headers(headers -> headers.addObjectPostProcessor(new ObjectPostProcessor<HeaderWriterFilter>() {
|
||||
@Override
|
||||
public <O extends HeaderWriterFilter> O postProcess(O filter) {
|
||||
filter.setShouldWriteHeadersEagerly(true);
|
||||
return filter;
|
||||
}
|
||||
}))
|
||||
.formLogin(form -> form
|
||||
.loginPage("/login")
|
||||
.usernameParameter("id")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-6
@@ -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);
|
||||
}
|
||||
|
||||
+6
-3
@@ -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()) {
|
||||
|
||||
+14
@@ -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)의 마지막 실행 시각.
|
||||
*
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+7
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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());
|
||||
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
+12
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -21,4 +22,15 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.eactive.apim.portal.djb.notitest.controller;
|
||||
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.notitest.NotiTestHistoryEntry;
|
||||
import com.eactive.apim.portal.djb.notitest.NotiTestProperties;
|
||||
import com.eactive.apim.portal.djb.notitest.NotiTestResult;
|
||||
import com.eactive.apim.portal.djb.notitest.NotiTestService;
|
||||
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 알림 발송 테스트 페이지.
|
||||
*
|
||||
* <p>PTL_PROPERTY({@link NotiTestProperties})로 기능 on/off 와 접근 허용 이메일 목록을 관리한다.
|
||||
* 운영에서도 상시 켜둘 수 있도록 GNB 에는 노출하지 않고 직접 URL 로만 접근한다(page.yml/portal.pages
|
||||
* 미등록). 발송 이력은 별도 테이블 없이 HttpSession 에 보관하고, 상태는 기존 PTL_MESSAGE_REQUEST 를
|
||||
* 3초 폴링으로 재조회해 갱신한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("//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;
|
||||
}
|
||||
|
||||
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
||||
|
||||
+360
-3
@@ -2,14 +2,18 @@ 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;
|
||||
@@ -17,14 +21,17 @@ 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 — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과
|
||||
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404).
|
||||
* 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>
|
||||
@@ -41,7 +48,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("dev")
|
||||
@Profile("playwright")
|
||||
@RestController
|
||||
@RequestMapping("/internal/test-cleanup")
|
||||
@RequiredArgsConstructor
|
||||
@@ -103,6 +110,356 @@ public class TestCleanupInternalController {
|
||||
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);
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+2
-2
@@ -11,11 +11,11 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
|
||||
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService}
|
||||
* 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
|
||||
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("dev")
|
||||
@Profile("playwright")
|
||||
@Service
|
||||
@Transactional
|
||||
public class OrphanCleanupService {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Repository;
|
||||
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
|
||||
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
|
||||
*/
|
||||
@Profile("dev")
|
||||
@Profile("playwright")
|
||||
@Repository
|
||||
class TestCleanupNativeQueries {
|
||||
|
||||
|
||||
+493
-3
@@ -1,41 +1,74 @@
|
||||
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)과
|
||||
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며,
|
||||
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다.
|
||||
* 그에 딸린 데이터를 하드 삭제한다. 전용 {@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("dev")
|
||||
@Profile("playwright")
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
@@ -52,11 +85,33 @@ public class TestCleanupService {
|
||||
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 + 법인 자체를 하드 삭제한다.
|
||||
@@ -65,6 +120,12 @@ public class TestCleanupService {
|
||||
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();
|
||||
}
|
||||
@@ -72,6 +133,8 @@ public class TestCleanupService {
|
||||
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));
|
||||
@@ -117,6 +180,257 @@ public class TestCleanupService {
|
||||
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()));
|
||||
@@ -133,6 +447,182 @@ public class TestCleanupService {
|
||||
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를 수행할 수 없습니다.");
|
||||
|
||||
+56
-22
@@ -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() {
|
||||
@@ -211,6 +218,7 @@ public class WebhookController {
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
@@ -297,33 +305,35 @@ 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);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
Long webhookId = webhook.get().getId();
|
||||
result.put("success", true);
|
||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||
result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
|
||||
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@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);
|
||||
@@ -338,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);
|
||||
@@ -360,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",
|
||||
@@ -376,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;
|
||||
}
|
||||
@@ -16,11 +16,15 @@ public class WebhookDTO implements Serializable {
|
||||
private Long id;
|
||||
private String targetUrl;
|
||||
private String secretMasked;
|
||||
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;
|
||||
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
|
||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||
private String targetUrl;
|
||||
|
||||
/**
|
||||
* 사용자 지정 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). */
|
||||
private List<String> eventTypes = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
|
||||
public interface WebhookMapper {
|
||||
|
||||
@Mapping(target = "secretMasked", ignore = true)
|
||||
@Mapping(target = "userSecretMasked", ignore = true)
|
||||
@Mapping(target = "apiIds", ignore = true)
|
||||
@Mapping(target = "eventTypes", ignore = true)
|
||||
WebhookDTO toDto(WebhookRequest entity);
|
||||
|
||||
+4
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
|
||||
@Column(name = "SECRET", length = 500)
|
||||
private String secret;
|
||||
|
||||
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
|
||||
@Column(name = "USER_SECRET", length = 500)
|
||||
private String userSecret;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -70,6 +74,7 @@ public class WebhookService {
|
||||
request.setOrgId(orgId);
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
request.setSecret(secret);
|
||||
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
|
||||
WebhookRequest saved = requestRepository.save(request);
|
||||
|
||||
persistChildren(saved.getId(), dto);
|
||||
@@ -79,12 +84,17 @@ public class WebhookService {
|
||||
|
||||
/**
|
||||
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
|
||||
*/
|
||||
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
validate(dto);
|
||||
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
String userSecret = normalizeUserSecret(dto.getUserSecret());
|
||||
if (userSecret != null) {
|
||||
request.setUserSecret(userSecret);
|
||||
}
|
||||
requestRepository.save(request);
|
||||
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
@@ -128,6 +138,14 @@ public class WebhookService {
|
||||
return loadOwned(id, orgId).getSecret();
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String getPlainUserSecret(Long id, String orgId) {
|
||||
return loadOwned(id, orgId).getUserSecret();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||
@@ -152,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개 이상 선택해주세요.");
|
||||
@@ -164,6 +181,14 @@ public class WebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeUserSecret(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private List<String> dedup(List<String> values) {
|
||||
if (values == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
@@ -174,9 +199,18 @@ public class WebhookService {
|
||||
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||
WebhookDTO dto = webhookMapper.toDto(request);
|
||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||
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,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:
|
||||
|
||||
@@ -41,6 +41,14 @@ spring:
|
||||
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:
|
||||
@@ -85,6 +93,10 @@ app:
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
|
||||
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
|
||||
web-resources:
|
||||
static-base: 'classpath:/static/'
|
||||
|
||||
security:
|
||||
basic:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,13 @@ body {
|
||||
color: #1A1A2E;
|
||||
background-color: #FFFFFF;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
body > * {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
@@ -824,16 +831,20 @@ hr {
|
||||
.env-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 0 0 4px 0;
|
||||
background: var(--accent-orange);
|
||||
color: var(--white);
|
||||
font-size: 11px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.env-badge {
|
||||
@@ -897,7 +908,7 @@ hr {
|
||||
}
|
||||
.logo img {
|
||||
height: 32px;
|
||||
width: 114px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1266,6 +1277,9 @@ hr {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated .last-login-info {
|
||||
padding: 0;
|
||||
}
|
||||
.mobile-drawer .drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -1705,10 +1719,22 @@ hr {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.last-login-info {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 9pt;
|
||||
color: var(--text-gray);
|
||||
white-space: nowrap;
|
||||
padding: 0 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.header-user-info {
|
||||
display: none;
|
||||
}
|
||||
.auth-group .last-login-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* 원래 있던 Footer 스타일 (주석 처리)
|
||||
.global-footer {
|
||||
@@ -1866,6 +1892,7 @@ hr {
|
||||
background-color: rgb(15, 23, 42);
|
||||
color: rgb(100, 116, 139);
|
||||
padding: 60px 0px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.global-footer .container {
|
||||
max-width: 1200px;
|
||||
@@ -5454,6 +5481,8 @@ select.form-control {
|
||||
}
|
||||
.auth-group.authenticated {
|
||||
gap: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.auth-group .login-btn {
|
||||
font-size: 15px;
|
||||
@@ -5757,6 +5786,7 @@ select.form-control {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
width: max-content;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -10949,11 +10979,54 @@ body.index-page-body {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 하단 중앙 "API 사용 신청" 버튼 */
|
||||
/* API 사용 신청은 클라이언트 신청 흐름에서 진행됨을 안내 */
|
||||
.api-apply-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
margin-top: 48px;
|
||||
padding: 32px 44px;
|
||||
border: 1px solid #d5e6f7;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(100deg, #eaf4fd, #dfedfb);
|
||||
}
|
||||
.api-apply-footer__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.api-apply-footer__title {
|
||||
margin: 0;
|
||||
color: #0d3264;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.api-apply-footer__guide {
|
||||
margin: 6px 0 0;
|
||||
color: #64748B;
|
||||
font-size: 15px;
|
||||
}
|
||||
.api-apply-footer__action {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding: 15px 30px;
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.api-apply-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 24px;
|
||||
}
|
||||
.api-apply-footer__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.api-apply-footer__action {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.api-overview-card {
|
||||
@@ -21839,6 +21912,35 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: #334155;
|
||||
word-break: break-word;
|
||||
}
|
||||
.recent-apps .recent-apps-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.recent-apps .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;
|
||||
}
|
||||
.recent-apps .recent-apps-delete:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
.recent-apps .recent-apps-delete:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 40px;
|
||||
@@ -23345,81 +23447,302 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
|
||||
.service-intro {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.service-intro__title {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.service-intro__lead {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.service-intro__desc {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__spacer {
|
||||
height: 10px;
|
||||
}
|
||||
.service-intro__section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.service-intro__section-body {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__section-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.service-intro__section-body p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.service-intro__list {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
list-style: disc;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.service-intro__list li + li {
|
||||
margin-top: 4px;
|
||||
color: #1e2939;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-intro {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
.intro-callout {
|
||||
background: #0B2A5B;
|
||||
border-radius: 16px;
|
||||
padding: 34px 40px;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
box-shadow: 0 14px 34px rgba(11, 42, 91, 0.18);
|
||||
}
|
||||
.intro-callout__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.intro-callout ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
}
|
||||
.intro-callout li {
|
||||
position: relative;
|
||||
padding-left: 15px;
|
||||
color: #C2D4EA;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.intro-callout li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #00acdd;
|
||||
}
|
||||
.intro-callout li strong {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.intro-section__eyebrow {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2.4px;
|
||||
color: #0049b4;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.intro-section__title {
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -1px;
|
||||
color: #0B2A5B;
|
||||
margin: 0;
|
||||
}
|
||||
.intro-section__lead {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
color: #4a5565;
|
||||
line-height: 1.85;
|
||||
max-width: 830px;
|
||||
}
|
||||
.intro-section + .intro-section {
|
||||
margin-top: 76px;
|
||||
}
|
||||
|
||||
.intro-who {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.intro-who__item {
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
padding: 22px 20px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-who__item p {
|
||||
margin-top: 6px;
|
||||
font-size: 13.6px;
|
||||
color: #4a5565;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.intro-who__title {
|
||||
margin-top: 12px;
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
|
||||
.intro-grid3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 16px;
|
||||
padding: 26px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.intro-card__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 11px;
|
||||
background: #EFF6FD;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.intro-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
letter-spacing: -0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
.intro-card p {
|
||||
margin-top: 9px;
|
||||
font-size: 14.6px;
|
||||
color: #4a5565;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.intro-card code {
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
background: #EFF4FA;
|
||||
color: #0049b4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.intro-card__tag {
|
||||
display: inline-block;
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #0049b4;
|
||||
background: #EEF6FD;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.intro-diagram {
|
||||
margin-top: 28px;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 16px;
|
||||
padding: 30px 24px;
|
||||
background: #FAFCFF;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.intro-diagram svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.intro-steps {
|
||||
margin-top: 34px;
|
||||
position: relative;
|
||||
padding-left: 38px;
|
||||
}
|
||||
.intro-steps::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 2px;
|
||||
background: #D8E5F3;
|
||||
}
|
||||
|
||||
.intro-step {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
}
|
||||
.intro-step::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -38px;
|
||||
top: 34px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #0049b4;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 3px #D9E7F7;
|
||||
}
|
||||
.intro-step__icon {
|
||||
width: 88px;
|
||||
height: 82px;
|
||||
flex: none;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-step__body {
|
||||
flex: 1;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 14px;
|
||||
padding: 19px 26px;
|
||||
background: #fff;
|
||||
}
|
||||
.intro-step__body p {
|
||||
margin-top: 5px;
|
||||
font-size: 14.4px;
|
||||
color: #4a5565;
|
||||
}
|
||||
.intro-step__title {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
.intro-step__title em {
|
||||
font-style: normal;
|
||||
color: #0049b4;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.intro-cta {
|
||||
margin-top: 64px;
|
||||
border-radius: 18px;
|
||||
padding: 38px 44px;
|
||||
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
border: 1px solid #D5E6F7;
|
||||
}
|
||||
.intro-cta__body h3 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #0B2A5B;
|
||||
}
|
||||
.intro-cta__body p {
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
color: #4a5565;
|
||||
}
|
||||
.intro-cta .btn-action-primary {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 15.5px;
|
||||
padding: 15px 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.service-intro__title {
|
||||
font-size: 22px;
|
||||
.intro-cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.service-intro__lead {
|
||||
font-size: 17px;
|
||||
.intro-cta .btn-action-primary {
|
||||
margin-left: 0;
|
||||
}
|
||||
.service-intro__section-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.intro-who,
|
||||
.intro-grid3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.service-intro__desc, .service-intro__section-body, .service-intro__list {
|
||||
font-size: 14px;
|
||||
.intro-callout {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.intro-section__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.intro-step {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.service-main {
|
||||
@@ -24794,6 +25117,9 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
.oauth2-2legged__hero-body {
|
||||
align-items: center;
|
||||
}
|
||||
.oauth2-2legged__hero-illust {
|
||||
order: -1;
|
||||
}
|
||||
.oauth2-2legged__prereq-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -25471,29 +25797,31 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
padding: 24px 16px 60px;
|
||||
font-family: "Spoqa Han Sans Neo", "Noto Sans CJK KR", sans-serif;
|
||||
}
|
||||
.webhook-container .field-help {
|
||||
|
||||
.step1-wrap .field-help {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.webhook-container .field-help-red {
|
||||
.step1-wrap .field-help-red {
|
||||
color: #f4253c;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.webhook-container .field-help-red {
|
||||
.step1-wrap .field-help-red {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.webhook-container .field-error,
|
||||
.webhook-container .webhook-field-error {
|
||||
.step1-wrap .field-error,
|
||||
.step1-wrap .webhook-field-error {
|
||||
color: #f4253c;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.webhook-container .req {
|
||||
.step1-wrap .req {
|
||||
color: #f4253c;
|
||||
}
|
||||
|
||||
@@ -26148,7 +26476,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -26159,24 +26487,24 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||
flex: 1;
|
||||
max-width: 250px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
background-color: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 0 20px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #4e5968;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 1px;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
border: 1px solid #DFDFDF;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
@@ -27464,6 +27792,191 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
|
||||
.nt-page,
|
||||
.nt-page h1,
|
||||
.nt-page h2,
|
||||
.nt-page h3 {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.nt-page {
|
||||
margin: 44px 0 60px;
|
||||
}
|
||||
.nt-page .nt-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.nt-page .nt-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.nt-page .nt-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748B;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.nt-page .nt-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.nt-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.nt-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.nt-catalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nt-category-card {
|
||||
padding: 24px;
|
||||
}
|
||||
.nt-category-card .nt-category-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
|
||||
.nt-target {
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.nt-target:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.nt-target .nt-risk-badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.nt-params {
|
||||
margin: 12px 0 4px 26px;
|
||||
padding: 12px 16px;
|
||||
background: #F8FAFC;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.nt-params .nt-param-field {
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.nt-params .nt-param-field label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.nt-side {
|
||||
position: sticky;
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
.nt-recipient-card {
|
||||
padding: 24px;
|
||||
}
|
||||
.nt-recipient-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.nt-recipient-card .btn-block {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nt-history-card {
|
||||
margin-top: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
.nt-history-card h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
|
||||
.nt-table-wrap {
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nt-table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #E2E8F0;
|
||||
}
|
||||
.nt-table-wrap th, .nt-table-wrap td {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #E2E8F0;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nt-table-wrap thead th {
|
||||
background: #EFF6FF;
|
||||
font-weight: 600;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.nt-table-wrap tbody td {
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.nt-message-cell {
|
||||
min-width: 280px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.nt-message-textarea {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 6px;
|
||||
background: #F8FAFC;
|
||||
color: #1A1A2E;
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
.nt-message-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #0049b4;
|
||||
}
|
||||
|
||||
.nt-row-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nt-row-warning {
|
||||
color: #FF6B6B;
|
||||
}
|
||||
|
||||
.nt-row-error {
|
||||
color: #FF6B6B;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
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 |
@@ -1,491 +0,0 @@
|
||||
class ChartEditor {
|
||||
constructor(container, layoutManager, dashboard) {
|
||||
this.rootContainer = container;
|
||||
this.layoutManager = layoutManager;
|
||||
this.dashboard = dashboard;
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
this.tempChart = null;
|
||||
this.chartPreview = null;
|
||||
this.containerHeight = 0;
|
||||
|
||||
this.chartEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본 정보',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'contentId',
|
||||
'type': 'text',
|
||||
'label': 'Chart ID',
|
||||
'placeholder': 'chartId를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'config.animation',
|
||||
'type': 'boolean',
|
||||
'label': 'Animation 사용'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'title',
|
||||
'groupTitle': '제목',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.title.text',
|
||||
'type': 'text',
|
||||
'label': '차트 제목',
|
||||
'placeholder': '차트 제목을 입력하세요'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'tooltip',
|
||||
'groupTitle': 'Tooltip',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.tooltip.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Tooltip 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.tooltip.trigger',
|
||||
'type': 'select',
|
||||
'label': 'Tooltip Trigger',
|
||||
'placeholder': 'Enter the trigger type',
|
||||
'options': ['item', 'axis', 'none']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'legend',
|
||||
'groupTitle': 'Legend',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.legend.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Legend 표시'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'grid',
|
||||
'groupTitle': 'Grid',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.grid.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Grid 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.left',
|
||||
'type': 'text',
|
||||
'label': 'Grid Left',
|
||||
'placeholder': '좌측 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.right',
|
||||
'type': 'text',
|
||||
'label': 'Grid Right',
|
||||
'placeholder': '우측 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.top',
|
||||
'type': 'text',
|
||||
'label': 'Grid Top',
|
||||
'placeholder': '위쪽 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.bottom',
|
||||
'type': 'text',
|
||||
'label': 'Grid Bottom',
|
||||
'placeholder': '아래쪽 여백'
|
||||
},
|
||||
{
|
||||
'path': 'config.grid.containLabel',
|
||||
'type': 'boolean',
|
||||
'label': 'Contain Label'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'xAxis',
|
||||
'groupTitle': 'X축',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.xAxis.show',
|
||||
'type': 'boolean',
|
||||
'label': 'X축 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.xAxis.type',
|
||||
'type': 'select',
|
||||
'label': 'X축 유형',
|
||||
'placeholder': 'X축 유형을 선택하세요',
|
||||
'options': ['category', 'value', 'time']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'yAxis',
|
||||
'groupTitle': 'Y축',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.yAxis[0].show',
|
||||
'type': 'boolean',
|
||||
'label': 'Y축 표시'
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].name',
|
||||
'type': 'text',
|
||||
'label': 'Y축 이름'
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].type',
|
||||
'type': 'select',
|
||||
'label': 'Y축 유형',
|
||||
'placeholder': 'Y축 유형을 선택하세요',
|
||||
'options': ['category', 'value', 'time']
|
||||
},
|
||||
{
|
||||
'path': 'config.yAxis[0].position',
|
||||
'type': 'select',
|
||||
'label': 'Y축 위치',
|
||||
'placeholder': 'Y축 위치를 선택하세요',
|
||||
'options': ['left', 'right']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'groupName': 'toolbox',
|
||||
'groupTitle': 'Toolbox',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'config.toolbox.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Toolbox 표시',
|
||||
'placeholder': 'Check to show toolbox'
|
||||
},
|
||||
{
|
||||
'path': 'config.toolbox.feature.saveAsImage.show',
|
||||
'type': 'boolean',
|
||||
'label': 'Save as Image 사용'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
this.seriesConfigModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '시리즈 정보',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'seriesConfig[0].queryType',
|
||||
'type': 'select',
|
||||
'label': 'Query Type',
|
||||
'options': ['elasticsearch']
|
||||
},
|
||||
// {
|
||||
// 'path': 'seriesConfig[0].type',
|
||||
// 'type': 'select',
|
||||
// 'label': '차트 유형',
|
||||
// 'placeholder': '차트 유형을 선택하세요',
|
||||
// 'options': ['bar', 'line', 'pie']
|
||||
// },
|
||||
{
|
||||
'path': 'seriesConfig[0].index',
|
||||
'type': 'text',
|
||||
'label': 'Elasticsearch Index',
|
||||
'placeholder': 'Elasticsearch Index를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].query',
|
||||
'type': 'textarea',
|
||||
'label': 'Elasticsearch Query'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].queryResult',
|
||||
'type': 'textarea',
|
||||
'label': 'Elasticsearch Query Result'
|
||||
},
|
||||
{
|
||||
'path': 'seriesConfig[0].dataPath',
|
||||
'type': 'text',
|
||||
'label': '데이터 경로',
|
||||
'placeholder': '데이터 경로를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
initChartEditor() {
|
||||
|
||||
const chartEditorPopup = `
|
||||
<div class="modal fade" id="chart_editor_modal" tabindex="-1" aria-labelledby="chart_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">차트 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="editor_container">
|
||||
<input type="hidden" name="type" value="" />
|
||||
<div class="left-panel default-info">
|
||||
${LayoutUtil.createForm(this.chartEditorModel)}
|
||||
</div>
|
||||
<div class="editor-right-panel">
|
||||
<div class="series-config d-flex flex-column">
|
||||
<h6>Data Source</h6>
|
||||
<div class="d-flex flex-row">
|
||||
<div class="form-floating me-2" style="flex: 1">
|
||||
<select class="form-select" name="seriesConfig[0].queryType">
|
||||
<option value="elasticsearch">Elasticsearch</option>
|
||||
</select>
|
||||
<label for="queryType">Query Type</label>
|
||||
</div>
|
||||
<div class="form-floating me-2" style="flex: 1">
|
||||
<input class="form-control" type="text" name="seriesConfig[0].index" value="" />
|
||||
<label for="index">Elasticsearch Index</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-2" style="flex: 1">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].query" id="seriesConfigQuery" placeholder="Code goes here..." style="height: 400px;">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].queryResult" style="height: 400px;" readonly>
|
||||
</textarea>
|
||||
<label for="query">요청 결과</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-2" style="flex: 1">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].mapperY" style="height: 400px;">
|
||||
</textarea>
|
||||
<label for="mapper">데이터 Mapper</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" name="seriesConfig[0].mapperX" style="height: 400px;">
|
||||
</textarea>
|
||||
<label for="mapper">X축 Mapper</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row" style="height: 400px">
|
||||
<div style="flex: 1">
|
||||
<div class="form-floating me-2" style="height: 100%">
|
||||
<textarea class="form-control" type="text" id="echartOption" style="width: 100%; height: 400px;" readonly>
|
||||
</textarea>
|
||||
<label for="mapper">생성된 Option</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<div class="chart-preview form-control" id="chart-preview" style="height: 400px;">
|
||||
chart_preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm add-chart">추가</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-chart">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$(this.rootContainer).parent().append(chartEditorPopup);
|
||||
|
||||
//add event to modal show
|
||||
$('#chart_editor_modal').on('shown.bs.modal', (event) => {
|
||||
let chartContainer = document.getElementById('chart-preview');
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
this.chartPreview = echarts.init(document.getElementById('chart-preview'), 'dark', {
|
||||
width: width, height: height
|
||||
});
|
||||
this.drawCharts();
|
||||
});
|
||||
}
|
||||
|
||||
showChartEditor(isNew, tempChart, containerIndex, columnIndex) {
|
||||
this.targetContainerIndex = containerIndex;
|
||||
this.targetColumnIndex = columnIndex;
|
||||
this.tempChart = tempChart;
|
||||
if (isNew) {
|
||||
$('.add-chart').show();
|
||||
$('.save-chart').hide();
|
||||
} else {
|
||||
$('.add-chart').hide();
|
||||
$('.save-chart').show();
|
||||
}
|
||||
|
||||
let flatModel = LayoutUtil.flattenObject(tempChart);
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
|
||||
const inputSelector = `input[name="${key}"]`;
|
||||
const selectSelector = `select[name="${key}"]`;
|
||||
const textareaSelector = `textarea[name="${key}"]`;
|
||||
|
||||
const $input = $('#chart_editor_modal').find(inputSelector);
|
||||
const $select = $('#chart_editor_modal').find(selectSelector);
|
||||
const $textarea = $('#chart_editor_modal').find(textareaSelector);
|
||||
|
||||
if ($input.length > 0 && $input.attr('type') === 'checkbox') {
|
||||
// If it's a checkbox, update its 'checked' property
|
||||
$input.prop('checked', value);
|
||||
} else if ($input.length > 0 || $select.length > 0 || $textarea.length > 0) {
|
||||
// If any input, select, or textarea is found, update its value
|
||||
$($input.add($select).add($textarea)).val(value);
|
||||
} else {
|
||||
// If no elements are found, log to the console
|
||||
console.log(`Element not found for name: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').modal('show');
|
||||
console.log('chart_editor_modal show');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
addChart() {
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutManager.layoutData.children[this.targetContainerIndex].children.push(JSON.parse(JSON.stringify(this.tempChart)));
|
||||
this.tempChart = null;
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
|
||||
this.layoutManager.resetLayout();
|
||||
$('#chart_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
cleanUpEvents() {
|
||||
console.log('cleanUpEvents');
|
||||
$('.add-chart').off('click');
|
||||
$('.save-chart').off('click');
|
||||
}
|
||||
|
||||
saveChart() {
|
||||
console.log('saveChart');
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== undefined && fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
const json = JSON.stringify(this.tempChart, LayoutUtil.echartReplacer, 2);
|
||||
console.log(json);
|
||||
this.layoutManager.layoutData.children[this.targetContainerIndex].children[this.targetColumnIndex] = JSON.parse(json);
|
||||
this.targetContainerIndex = -1;
|
||||
this.targetColumnIndex = -1;
|
||||
this.layoutManager.resetLayout();
|
||||
$('#chart_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
refreshChart(chartInfo) {
|
||||
console.log('refreshChart');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let option = JSON.parse(JSON.stringify(chartInfo.config));
|
||||
option.series = [];
|
||||
|
||||
let self = this;
|
||||
Promise.all(this.tempChart.seriesConfig.map(config => this.dashboard.requestQuery(config))).then(async (results) => {
|
||||
await self.dashboard.updateChartSeries(option, results, chartInfo, (index, result) => {
|
||||
$('#chart_editor_modal').find(`textarea[name='seriesConfig[${index}].queryResult']`).val('result = ' + JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
||||
document.getElementById('echartOption').value = JSON.stringify(option, null, 2);
|
||||
self.chartPreview.setOption(option);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching data for preview: ', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
drawCharts() {
|
||||
console.log('drawCharts');
|
||||
|
||||
const promises = [this.refreshChart(this.tempChart)];
|
||||
|
||||
Promise.all(promises).then(() => console.log('Chart Preview is completed')).catch(error => console.error('An error occurred:', error));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
$('.add-chart').off('click').on('click', (event) => {
|
||||
this.addChart();
|
||||
});
|
||||
|
||||
$('.save-chart').off('click').on('click', (event) => {
|
||||
this.saveChart();
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
$(element).off('change').on('change', (event) => {
|
||||
console.log('change event');
|
||||
const fieldName = $(event.currentTarget).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(event.currentTarget);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempChart, fieldName, newValue);
|
||||
}
|
||||
this.refreshChart(this.tempChart);
|
||||
});
|
||||
});
|
||||
|
||||
$('#chart_editor_modal').on('hidden.bs.modal', (event) => {
|
||||
if (this.chartPreview != null && this.chartPreview.dispose) {
|
||||
this.chartPreview.dispose();
|
||||
this.chartPreview = null;
|
||||
}
|
||||
|
||||
this.cleanUpEvents();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
class Dashboard {
|
||||
|
||||
constructor(layoutData) {
|
||||
this.layoutData = layoutData || {};
|
||||
this.fromDateTarget = 'fromDate';
|
||||
this.toDateTarget = 'toDate';
|
||||
this.charts = [];
|
||||
this.theme = 'dark';
|
||||
this.requestUrl = '/mgmt/dashboard/search.do';
|
||||
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const events = [{selector: '.refresh', action: this.refresh.bind(this)}];
|
||||
|
||||
for (let event of events) {
|
||||
$(document).off('click', event.selector).on('click', event.selector, event.action);
|
||||
}
|
||||
}
|
||||
|
||||
getDateTemplate(id, label) {
|
||||
return `
|
||||
<div class="input-group mb-3">
|
||||
<div class="form-floating">
|
||||
<input id="${id}Input" type="text" class="form-control" data-td-target="#${id}"/>
|
||||
<label for="${id}Input">${label}</label>
|
||||
</div>
|
||||
<span class="input-group-text" data-td-target="#${id}" data-td-toggle="datetimepicker">
|
||||
<span class="fa-solid fa-calendar"></span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init(chartAreaId) {
|
||||
console.log('Dashboard init');
|
||||
this.chartAreaId = chartAreaId;
|
||||
this.initDate();
|
||||
this.initCharts();
|
||||
this.drawCharts();
|
||||
|
||||
window.addEventListener('resize', this.resizeAllCharts.bind(this));
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
console.log('initCharts');
|
||||
this.charts = [];
|
||||
if (this.layoutData.children) {
|
||||
this.layoutData.children.forEach((row) => {
|
||||
row.children.forEach((chartInfo) => {
|
||||
this.charts.push(chartInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
formatTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hour}:${minute}:00`;
|
||||
}
|
||||
|
||||
formatDateTime(isoString) {
|
||||
return this.formatDate(isoString) + 'T' + this.formatTime(isoString);
|
||||
}
|
||||
|
||||
startOfDay(isoString) {
|
||||
return this.formatDate(isoString) + 'T00:00:00';
|
||||
}
|
||||
|
||||
initDate() {
|
||||
const ko = tempusDominus.locales.ko.localization;
|
||||
const fromDateElement = document.getElementById(this.fromDateTarget);
|
||||
$(fromDateElement).data('td-target-input', 'nearest');
|
||||
$(fromDateElement).data('td-target-toggle', 'nearest');
|
||||
|
||||
const toDateElement = document.getElementById(this.toDateTarget);
|
||||
$(toDateElement).data('td-target-input', 'nearest');
|
||||
$(toDateElement).data('td-target-toggle', 'nearest');
|
||||
|
||||
$(fromDateElement).append(this.getDateTemplate(this.fromDateTarget, 'From'));
|
||||
$(toDateElement).append(this.getDateTemplate(this.toDateTarget, 'To'));
|
||||
|
||||
this.fromDate = new tempusDominus.TempusDominus(fromDateElement, {
|
||||
localization: ko, defaultDate: this.startOfDay(new Date())
|
||||
});
|
||||
this.toDate = new tempusDominus.TempusDominus(toDateElement, {
|
||||
useCurrent: false, localization: ko, defaultDate: new Date()
|
||||
});
|
||||
|
||||
fromDateElement.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
this.toDate.updateOptions({
|
||||
restrictions: {
|
||||
minDate: e.detail.date
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const subscription = this.toDate.subscribe(tempusDominus.Namespace.events.change, (e) => {
|
||||
this.fromDate.updateOptions({
|
||||
restrictions: {
|
||||
maxDate: e.date
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
requestPost(url, data, hds) {
|
||||
var deferred = $.Deferred();
|
||||
|
||||
var headers = $.extend({
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
}, hds);
|
||||
|
||||
if (headers['Content-Type'].startsWith('application/json')) {
|
||||
data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url, async: true, method: 'POST', data: data, headers: headers
|
||||
}).done((response) => {
|
||||
deferred.resolve(response);
|
||||
}).fail((response, textStatus, errorThrown) => {
|
||||
deferred.reject(response, textStatus, errorThrown);
|
||||
});
|
||||
return deferred.promise();
|
||||
};
|
||||
|
||||
requestQuery(seriesConfig) {
|
||||
let request = null;
|
||||
try {
|
||||
if (seriesConfig.queryType === 'elasticsearch') {
|
||||
if (seriesConfig.query && seriesConfig.index !== '') {
|
||||
let requestQuery = JSON.parse(seriesConfig.query);
|
||||
requestQuery.query = requestQuery.query || {};
|
||||
requestQuery.query.bool = requestQuery.query.bool || {};
|
||||
requestQuery.query.bool.filter = requestQuery.query.bool.filter || [];
|
||||
requestQuery.query.bool.filter.push({
|
||||
'range': {
|
||||
'@timestamp': {
|
||||
'gte': this.formatDateTime(this.fromDate.dates.picked[0]),
|
||||
'lte': this.formatDateTime(this.toDate.dates.picked[0]),
|
||||
'time_zone': 'Asia/Seoul'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
request = {
|
||||
query: JSON.stringify(requestQuery), index: seriesConfig.index
|
||||
};
|
||||
return this.requestPost(this.requestUrl, request, {});
|
||||
} else {
|
||||
console.log('query is empty');
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for ' + seriesConfig.contentId + ': ', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
resizeAllCharts() {
|
||||
this.charts.forEach(chartInfo => {
|
||||
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
if (chartInfo.echart) {
|
||||
chartInfo.echart.resize({width: width, height: height});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeChart(chartInfo) {
|
||||
console.log('initializeChart: ' + chartInfo.contentId);
|
||||
let chartContainer = document.getElementById('chart_' + chartInfo.contentId);
|
||||
let rect = chartContainer.getBoundingClientRect();
|
||||
let width = rect.width; // Precise width
|
||||
let height = rect.height; // Precise height
|
||||
|
||||
chartInfo.echart = echarts.init(document.getElementById('chart_' + chartInfo.contentId), this.theme, {
|
||||
width: width, height: height
|
||||
});
|
||||
}
|
||||
|
||||
executeScript(result, script) {
|
||||
console.log('executeScript');
|
||||
if (script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resultFrame = document.getElementById('mapperScript');
|
||||
window.preRequestComplete = (result, error) => {
|
||||
if (error) {
|
||||
console.log('Error executing script: ', error);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
window.result = result;
|
||||
resultFrame.srcdoc = `<script>
|
||||
function get(obj, path, defaultValue = undefined) {
|
||||
const keys = Array.isArray(path) ? path : path.replace(/\\[(\\d+)\\]/g, '.$1').split('.');
|
||||
let result = obj;
|
||||
for (let key of keys) {
|
||||
result = result?.[key];
|
||||
if (result === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // +1 because getMonth() returns 0-11
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return \`\${year}-\${month}-\${day}\`;
|
||||
}
|
||||
|
||||
function formatTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return \`\${hour}:\${minute}:00\`;
|
||||
}
|
||||
|
||||
function formatDateTime(isoString) {
|
||||
return formatDate(isoString) + 'T' + formatTime(isoString);
|
||||
}
|
||||
|
||||
function startOfDay(isoString) {
|
||||
return formatDate(isoString) + 'T00:00:00';
|
||||
}
|
||||
|
||||
window.result = window.parent.result;
|
||||
try {
|
||||
const executeFunction = ${script};
|
||||
const data = executeFunction(window.result);
|
||||
window.parent.preRequestComplete(data, null);
|
||||
} catch (error) {
|
||||
window.parent.preRequestComplete(null, error);
|
||||
}
|
||||
</script>`;
|
||||
});
|
||||
} else {
|
||||
console.log('No script to execute');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async refresh(event) {
|
||||
console.log('refresh');
|
||||
|
||||
for (let i = 0; i < this.charts.length; i++) {
|
||||
const chartInfo = this.charts[i];
|
||||
try {
|
||||
await this.refreshChart(chartInfo);
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing chart ${chartInfo.contentId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log('All Charts requests are complete');
|
||||
// const promises = this.charts.map(info => this.refreshChart(info));
|
||||
// Promise.all(promises).then(() => console.log('All Charts requests are complete')).catch(error => console.error('An error occurred:', error));
|
||||
}
|
||||
|
||||
drawCharts() {
|
||||
console.log('drawCharts');
|
||||
this.charts.forEach(info => this.initializeChart(info));
|
||||
|
||||
this.refresh();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
async updateChartSeries(option, results, chartInfo, callback) {
|
||||
console.log('updateChartSeries');
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result){
|
||||
let config = chartInfo.seriesConfig[i];
|
||||
let parsedResult = JSON.parse(result);
|
||||
|
||||
if (callback) {
|
||||
callback(i, parsedResult);
|
||||
}
|
||||
try {
|
||||
console.log('start executeScript');
|
||||
const dataY = await this.executeScript(parsedResult, config.mapperY);
|
||||
|
||||
if (Array.isArray(dataY)) {
|
||||
dataY.forEach(item => {
|
||||
option.series.push(item);
|
||||
});
|
||||
} else {
|
||||
option.series.push(dataY);
|
||||
}
|
||||
const dataX = await this.executeScript(parsedResult, config.mapperX);
|
||||
option.xAxis.data = dataX;
|
||||
|
||||
console.log('stop executeScript');
|
||||
} catch (error) {
|
||||
console.error(`Error updating series for chart ${chartInfo.contentId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshChart(chartInfo) {
|
||||
|
||||
try {
|
||||
console.log('refreshChart');
|
||||
|
||||
let option = JSON.parse(JSON.stringify(chartInfo.config));
|
||||
option.series = [];
|
||||
|
||||
const results = await Promise.all(chartInfo.seriesConfig.map(config => this.requestQuery(config)));
|
||||
await this.updateChartSeries(option, results, chartInfo);
|
||||
|
||||
// Clear the chart and set the new option
|
||||
chartInfo.echart.clear();
|
||||
chartInfo.echart.setOption(option);
|
||||
|
||||
console.log('Chart refreshed successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching data for ' + chartInfo.contentId + ': ', error);
|
||||
throw error; // Rethrow the error if you need to catch it higher up in the call stack
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,643 +0,0 @@
|
||||
class LayoutManager {
|
||||
constructor(container, layoutData) {
|
||||
this.rootContainer = container;
|
||||
this.tempContainer = null;
|
||||
this.editContainerIndex = -1;
|
||||
this.layoutData = layoutData || {
|
||||
type: 'root',
|
||||
styles: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'start',
|
||||
'align-items': 'flex-start',
|
||||
'gap': '10px',
|
||||
'width': '100%',
|
||||
'height': '100vh',
|
||||
'position': 'relative',
|
||||
'overflow': 'auto',
|
||||
'background-color': '#100C2A'
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
this.containerEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'styles.display',
|
||||
'type': 'select',
|
||||
'label': 'Display',
|
||||
'options': ['flex']
|
||||
},
|
||||
{
|
||||
'path': 'styles.flex-direction',
|
||||
'type': 'select',
|
||||
'label': 'Flex 방향',
|
||||
'options': ['row', 'column', 'row-reverse', 'column-reverse']
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px, %)',
|
||||
'placeholder': 'px, % 등 단위를 포함한 높이를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
this.dashboardEditorModel = [
|
||||
{
|
||||
'groupName': 'default',
|
||||
'groupTitle': '기본',
|
||||
'fields': [
|
||||
{
|
||||
'path': 'styles.background-color',
|
||||
'type': 'text',
|
||||
'label': '색상',
|
||||
'placeholder': '#로 시작하는 값을 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.width',
|
||||
'type': 'text',
|
||||
'label': '길이(px)',
|
||||
'placeholder': 'px 단위를 포함한 길이를 입력하세요'
|
||||
},
|
||||
{
|
||||
'path': 'styles.height',
|
||||
'type': 'text',
|
||||
'label': '높이(px)',
|
||||
'placeholder': 'px 단위를 포함한 높이를 입력하세요'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// traverse(obj) {
|
||||
// if (obj.type === 'chart') {
|
||||
// $(`#chart_${obj.contentId}`).closest('.dashboard-chart').resizable({
|
||||
// grid: 10,
|
||||
// ghost: true,
|
||||
// stop: (event, ui) => {
|
||||
// const {width, height} = ui.size;
|
||||
// obj.styles.width = `${width}px`;
|
||||
// obj.styles.height = `${height}px`;
|
||||
// this.resetLayout();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// if (obj.children) {
|
||||
// obj.children.forEach(child => {
|
||||
// this.traverse(child);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
init(Dashboard, viewerMode) {
|
||||
this.viewMode = viewerMode || false;
|
||||
this.renderRoot(this.layoutData, this.rootContainer);
|
||||
|
||||
if (Dashboard) {
|
||||
this.dashboard = new Dashboard(this.layoutData);
|
||||
this.dashboard.init('chart_area');
|
||||
}
|
||||
|
||||
if (!this.viewMode) {
|
||||
this.initContainerEditor();
|
||||
this.initDashboardEditor();
|
||||
this.chartEditor = new ChartEditor(this.rootContainer, this, this.dashboard);
|
||||
this.chartEditor.initChartEditor();
|
||||
this.bindEvents();
|
||||
}
|
||||
}
|
||||
|
||||
initContainerEditor() {
|
||||
const containerEditorPopup = `
|
||||
<div class="modal fade" id="container_editor_modal" tabindex="-1" aria-labelledby="container_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">컨테이너 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="containerOption">
|
||||
${LayoutUtil.createForm(this.containerEditorModel)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm add-container">추가</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-container">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$(this.rootContainer).parent().append(containerEditorPopup);
|
||||
}
|
||||
|
||||
initDashboardEditor() {
|
||||
const containerEditorPopup = `
|
||||
<div class="modal fade" id="dashboard_editor_modal" tabindex="-1" aria-labelledby="dashboard_editor_modal" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">대시보드 정보</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="containerOption">
|
||||
${LayoutUtil.createForm(this.dashboardEditorModel)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">닫기</button>
|
||||
<button type="button" class="btn btn-primary btn-sm save-dashboard">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$(this.rootContainer).parent().append(containerEditorPopup);
|
||||
}
|
||||
|
||||
resetLayout() {
|
||||
console.log('resetLayout');
|
||||
$(this.rootContainer).empty();
|
||||
this.renderRoot(this.layoutData, this.rootContainer);
|
||||
this.dashboard.initCharts();
|
||||
this.dashboard.layoutData = this.layoutData;
|
||||
this.dashboard.drawCharts();
|
||||
this.editContainerIndex = -1;
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
addNewChart(containerIndex) {
|
||||
this.editContainerIndex = containerIndex;
|
||||
let tempChart = {
|
||||
'contentId': '',
|
||||
'type': 'chart',
|
||||
'styles': {
|
||||
width: '100%', height: '100%'
|
||||
},
|
||||
'config': {
|
||||
'tooltip': {
|
||||
'trigger': 'axis',
|
||||
'show': true
|
||||
},
|
||||
'legend': {
|
||||
'show': true
|
||||
},
|
||||
'grid': {
|
||||
'show': true,
|
||||
'top': 0,
|
||||
'bottom': 0,
|
||||
'left': 0,
|
||||
'right': 0,
|
||||
'containLabel': true
|
||||
},
|
||||
'title': {},
|
||||
'xAxis': {
|
||||
'show': true,
|
||||
'type': 'category'
|
||||
},
|
||||
'yAxis': [
|
||||
{
|
||||
'show': true,
|
||||
'name': '',
|
||||
'type': 'value',
|
||||
'position': 'left'
|
||||
}],
|
||||
'toolbox': {
|
||||
'show': true
|
||||
}
|
||||
},
|
||||
'seriesConfig': [
|
||||
{
|
||||
'type': 'line',
|
||||
'index': '',
|
||||
'queryType': 'elasticsearch',
|
||||
'query': '',
|
||||
'mapperX': `
|
||||
function(result) {
|
||||
let data = [];
|
||||
return data;
|
||||
}
|
||||
`,
|
||||
'mapperY': `
|
||||
function(result) {
|
||||
let data = [{
|
||||
type : 'line',
|
||||
data: []
|
||||
}];
|
||||
return data;
|
||||
}
|
||||
`,
|
||||
'queryResult': ''
|
||||
}
|
||||
]
|
||||
};
|
||||
this.chartEditor.showChartEditor(true, tempChart, containerIndex);
|
||||
}
|
||||
|
||||
editChart(editContainerIndex, editColumnIndex) {
|
||||
console.log('edit-chart');
|
||||
let tempChart = this.layoutData.children[editContainerIndex].children[editColumnIndex];
|
||||
this.chartEditor.showChartEditor(false, JSON.parse(JSON.stringify(tempChart, LayoutUtil.echartReplacer)), editContainerIndex, editColumnIndex);
|
||||
}
|
||||
|
||||
renderRoot(layoutData, parent) {
|
||||
let classList = 'dashboard-root mt-2' + (!this.viewMode ? ' edit' : '');
|
||||
let children = '';
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
|
||||
}
|
||||
console.log({style});
|
||||
if (layoutData.children) {
|
||||
layoutData.children.forEach((child) => {
|
||||
if (child.type === 'container') {
|
||||
children += this.renderContainer(child);
|
||||
} else if (child.type === 'chart') {
|
||||
children += this.renderChart(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
let layoutItem = `<div class="${classList}" style="${style}"> ${children}</div>`;
|
||||
$(parent).append(layoutItem);
|
||||
// if (!this.viewMode) {
|
||||
// this.traverse(this.layoutData);
|
||||
// }
|
||||
};
|
||||
|
||||
renderChart(layoutData) {
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key => `${key}: ${layoutData.styles[key]};`).join(' ');
|
||||
}
|
||||
|
||||
let editButton = this.viewMode ? '' : `<div class="chart-menu-icon" data-content-id="chart_${layoutData.contentId}">
|
||||
<div class="dropdown">
|
||||
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
|
||||
<ul class="dropdown-menu">
|
||||
<li><span class="dropdown-item edit-chart">차트 편집</span></li>
|
||||
<li><span class="dropdown-item remove-chart">차트 제거</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="dashboard-chart" data-content-id="chart_${layoutData.contentId}" style="${style}">
|
||||
${editButton}
|
||||
<div id="chart_${layoutData.contentId}" style="width: 100%; height: 100%"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderContainer(layoutData) {
|
||||
console.log('renderContainer');
|
||||
|
||||
let children = '';
|
||||
if (layoutData.children) {
|
||||
layoutData.children.forEach(child => {
|
||||
if (child.type === 'container') {
|
||||
children += this.renderContainer(child);
|
||||
} else if (child.type === 'chart') {
|
||||
children += this.renderChart(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
let width = layoutData.styles.width || '100%';
|
||||
let height = layoutData.styles.height || '100%';
|
||||
|
||||
let editButton = this.viewMode ? '' : `
|
||||
<div class="container-menu-icon">
|
||||
<div class="dropdown">
|
||||
<span><i class="fa-solid fa-arrows-up-down-left-right"></i></span>
|
||||
<span class="" data-bs-toggle="dropdown" aria-expanded="false">⋮</span>
|
||||
<ul class="dropdown-menu">
|
||||
<li><span class="dropdown-item edit-container">컨테이너 편집</span></li>
|
||||
<li><span class="dropdown-item remove-container">컨테이너 제거</span></li>
|
||||
<li><span class="dropdown-item add-new-chart">차트 추가</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
let style = '';
|
||||
if (layoutData.styles) {
|
||||
style = Object.keys(layoutData.styles).map(key =>{
|
||||
if (key === 'width' || key === 'height') return `${key}: 100%;`;
|
||||
return `${key}: ${layoutData.styles[key]};`;
|
||||
} ).join(' ');
|
||||
}
|
||||
|
||||
return `<div class="dashboard-container" style="width: ${width}; height: ${height};">
|
||||
<div class="handle">
|
||||
${editButton}
|
||||
</div>
|
||||
<div class="dashboard-container-children" style="${style}">
|
||||
${children}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
addNewContainer(event) {
|
||||
this.tempContainer = {
|
||||
type: 'container',
|
||||
styles: {
|
||||
display: 'flex',
|
||||
'flex-direction': 'row',
|
||||
width: '100%',
|
||||
height: '300px'
|
||||
},
|
||||
children: []
|
||||
};
|
||||
this.showContainerEditor(true);
|
||||
}
|
||||
|
||||
editContainer(editContainerIndex) {
|
||||
console.log('edit-container: ' + editContainerIndex);
|
||||
this.tempContainer = JSON.parse(JSON.stringify(this.layoutData.children[editContainerIndex], LayoutUtil.echartReplacer));
|
||||
this.showContainerEditor(false);
|
||||
this.editContainerIndex = editContainerIndex;
|
||||
}
|
||||
|
||||
removeContainer(containerIndex) {
|
||||
console.log('edit-container: ' + containerIndex);
|
||||
this.layoutData.children.splice(containerIndex, 1);
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
addContainer(event) {
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempContainer, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutData.children.push(JSON.parse(JSON.stringify(this.tempContainer)));
|
||||
this.tempContainer = null;
|
||||
this.resetLayout();
|
||||
$('#container_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
saveContainer(event) {
|
||||
console.log('save-container: ' + this.editContainerIndex);
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.tempContainer, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
this.layoutData.children[this.editContainerIndex] = JSON.parse(JSON.stringify(this.tempContainer));
|
||||
this.tempContainer = null;
|
||||
this.resetLayout();
|
||||
$('#container_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
removeChart(containerIndex, columnIndex) {
|
||||
this.layoutData.children[containerIndex].children.splice(columnIndex, 1);
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
onRowsSorted(ui) {
|
||||
let originalContainerIndex = ui.item.data('original_row_index');
|
||||
this.updateLayoutDataRows(originalContainerIndex, ui);
|
||||
}
|
||||
|
||||
onColumnsSorted(ui) {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
let originalContainerIndex = ui.item.data('original_row_index');
|
||||
if (originalContainerIndex) {
|
||||
if (originalContainerIndex === containerIndex) { // Check if the column moved from another row
|
||||
this.updateLayoutDataWithinRow(ui.item);
|
||||
} else { // The column was sorted within the same row
|
||||
this.updateLayoutDataColumns(ui.item);
|
||||
}
|
||||
this.resetLayout();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
updateLayoutDataRows(originalContainerIndex, ui) {
|
||||
let targetIndex = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
|
||||
if (targetIndex !== originalContainerIndex) {
|
||||
this.layoutData.children.splice(targetIndex, 0, this.layoutData.children.splice(originalContainerIndex, 1)[0]);
|
||||
}
|
||||
console.log(this.layoutData);
|
||||
}
|
||||
|
||||
updateLayoutDataWithinRow(movedItem) {
|
||||
let oldContainerIndex = movedItem.data('original_row_index');
|
||||
let oldIndex = movedItem.data('original_column_index');
|
||||
|
||||
if (oldContainerIndex !== -1 && oldIndex !== -1) {
|
||||
let $row = movedItem.closest('.dashboard-container');
|
||||
let newIndex = $row.children('.dashboard-chart').index(movedItem);
|
||||
this.layoutData.children[oldContainerIndex].children.splice(newIndex, 0, this.layoutData.children[oldContainerIndex].children.splice(oldIndex, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
const jsonString = JSON.stringify(this.layoutData, LayoutUtil.echartReplacer);
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
updateLayoutDataColumns(movedItem) {
|
||||
// 새로운 행과 이전 행의 jQuery 요소를 가져옵니다
|
||||
let $newRow = movedItem.closest('.dashboard-container');
|
||||
|
||||
// 이전 행의 레이아웃 데이터에서 이동된 행과 열의 인덱스를 찾습니다
|
||||
let oldContainerIndex = movedItem.data('original_row_index');
|
||||
let movedColumnIndex = movedItem.data('original_column_index');
|
||||
|
||||
if (oldContainerIndex !== -1 && movedColumnIndex !== undefined && movedColumnIndex !== -1) {
|
||||
movedItem.data('original_row_index', -1);
|
||||
movedItem.data('original_column_index', -1);
|
||||
|
||||
// 새 행의 인덱스를 찾습니다
|
||||
let newContainerIndex = $('.dashboard-root').children('.dashboard-container').index($newRow);
|
||||
|
||||
// 이전 행에서 열 데이터를 추출합니다
|
||||
let movedColumnData = this.layoutData.children[oldContainerIndex].children.splice(movedColumnIndex, 1)[0];
|
||||
// 아이템이 드롭된 새 열 인덱스를 찾습니다
|
||||
let newColumnIndex = movedItem.closest('.dashboard-container-children').children('.dashboard-chart').index(movedItem);
|
||||
|
||||
// 이동된 열 데이터를 새 행의 열에 새 인덱스에 삽입합니다
|
||||
// 아이템이 마지막 위치로 이동된 경우 인덱스는 -1이 될 것이므로 이 경우를 처리합니다
|
||||
if (newColumnIndex === -1) {
|
||||
this.layoutData.children[newContainerIndex].children.push(movedColumnData);
|
||||
} else {
|
||||
this.layoutData.children[newContainerIndex].children.splice(newColumnIndex, 0, movedColumnData);
|
||||
}
|
||||
} else {
|
||||
console.log('called twice on update');
|
||||
}
|
||||
}
|
||||
|
||||
showContainerEditor(isNew) {
|
||||
if (isNew) {
|
||||
$('.add-container').show();
|
||||
$('.save-container').hide();
|
||||
} else {
|
||||
$('.add-container').hide();
|
||||
$('.save-container').show();
|
||||
}
|
||||
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.tempContainer)));
|
||||
|
||||
$('#container_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
if ($('#container_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
|
||||
$('#container_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
|
||||
} else {
|
||||
$('#container_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
|
||||
}
|
||||
});
|
||||
|
||||
$('#container_editor_modal').modal('show');
|
||||
}
|
||||
|
||||
resize() {
|
||||
console.log('resize');
|
||||
this.dashboard.resizeAllCharts();
|
||||
}
|
||||
|
||||
editDashboard(event) {
|
||||
this.showDashboardEditor();
|
||||
}
|
||||
|
||||
showDashboardEditor() {
|
||||
let flatModel = LayoutUtil.flattenObject(JSON.parse(JSON.stringify(this.layoutData, LayoutUtil.echartReplacer)));
|
||||
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
if ($(element).attr('type') === 'checkbox') {
|
||||
$(element).prop('checked', false);
|
||||
}
|
||||
$(element).val('');
|
||||
});
|
||||
|
||||
_.forEach(flatModel, (value, key) => {
|
||||
if ($('#dashboard_editor_modal').find(`input[name="${key}"]`).attr('type') === 'checkbox') {
|
||||
$('#dashboard_editor_modal').find(`input[name="${key}"]`).prop('checked', value);
|
||||
} else {
|
||||
$('#dashboard_editor_modal').find(`input[name="${key}"], select[name="${key}"], textarea[name="${key}"]`).val(value);
|
||||
}
|
||||
});
|
||||
|
||||
$('#dashboard_editor_modal').modal('show');
|
||||
}
|
||||
|
||||
saveDashboard(event) {
|
||||
$('#dashboard_editor_modal').find('input, select, textarea').each((index, element) => {
|
||||
const fieldName = $(element).attr('name');
|
||||
const newValue = LayoutUtil.getFieldValue(element);
|
||||
if (fieldName !== '') {
|
||||
_.set(this.layoutData, fieldName, newValue);
|
||||
}
|
||||
});
|
||||
console.log(this.layoutData);
|
||||
this.resetLayout();
|
||||
$('#dashboard_editor_modal').modal('hide');
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
$('.add-new-container').off('click').on('click', (event) => {
|
||||
this.addNewContainer(event);
|
||||
});
|
||||
|
||||
$('.edit-dashboard').off('click').on('click', (event) => {
|
||||
this.editDashboard(event);
|
||||
});
|
||||
|
||||
$('.save-dashboard').off('click').on('click', (event) => {
|
||||
this.saveDashboard(event);
|
||||
});
|
||||
|
||||
$('.remove-container').off('click').on('click', (event) => {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.removeContainer(containerIndex);
|
||||
});
|
||||
|
||||
$('.add-container').off('click').on('click', (event) => {
|
||||
this.addContainer(event);
|
||||
});
|
||||
|
||||
$('.save-container').off('click').on('click', (event) => {
|
||||
this.saveContainer(event);
|
||||
});
|
||||
|
||||
$('.edit-container').off('click').on('click', (event) => {
|
||||
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.editContainer(editContainerIndex);
|
||||
});
|
||||
|
||||
$('.edit-chart').off('click').on('click', (event) => {
|
||||
let editContainerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
let column = event.target.closest('.dashboard-chart');
|
||||
let editColumnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
|
||||
this.editChart(editContainerIndex, editColumnIndex);
|
||||
});
|
||||
|
||||
$('.add-new-chart').off('click').on('click', (event) => {
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
this.addNewChart(containerIndex);
|
||||
});
|
||||
|
||||
$('.remove-chart').off('click').on('click', (event) => {
|
||||
let column = event.target.closest('.dashboard-chart');
|
||||
let containerIndex = $('.dashboard-root').children('.dashboard-container').index(event.target.closest('.dashboard-container'));
|
||||
let columnIndex = $(event.target.closest('.dashboard-container-children')).children('.dashboard-chart').index(column);
|
||||
this.removeChart(containerIndex, columnIndex);
|
||||
});
|
||||
|
||||
if ($('.dashboard-root').hasClass('ui-sortable')) {
|
||||
$('.dashboard-root').sortable('destroy');
|
||||
}
|
||||
|
||||
$('.dashboard-root').sortable({
|
||||
handle: '.handle',
|
||||
items: '.dashboard-container',
|
||||
placeholder: 'ui-state-highlight',
|
||||
start: (event, ui) => {
|
||||
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
ui.item.data('original_row_index', originalRowIdx);
|
||||
}, update: (event, ui) => {
|
||||
this.onRowsSorted(ui);
|
||||
}
|
||||
});
|
||||
|
||||
if ($('.dashboard-container').hasClass('ui-sortable')) {
|
||||
$('.dashboard-container').sortable('destroy');
|
||||
}
|
||||
$('.dashboard-container-children').sortable({
|
||||
// handle: ".handle",
|
||||
placeholder: 'ui-state-highlight',
|
||||
items: '.dashboard-chart',
|
||||
connectWith: '.dashboard-container-children',
|
||||
update: (event, ui) => {
|
||||
this.onColumnsSorted(ui);
|
||||
},
|
||||
start: (event, ui) => {
|
||||
let originalRowIdx = $('.dashboard-root').children('.dashboard-container').index(ui.item.closest('.dashboard-container'));
|
||||
let originalColumnIdx = ui.item.closest('.dashboard-container-children').children('.dashboard-chart').index(ui.item);
|
||||
ui.item.data('original_row_index', originalRowIdx);
|
||||
ui.item.data('original_column_index', originalColumnIdx);
|
||||
}
|
||||
}).disableSelection();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function aggregateStatus(entry) {
|
||||
var ids = entry.requestIds || [];
|
||||
if (ids.length === 0) {
|
||||
return entry.errorMessage ? 'FAILED' : 'PENDING';
|
||||
}
|
||||
var statuses = ids.map(function (id) { return (entry.statuses || {})[id]; });
|
||||
if (statuses.indexOf('FAILED') >= 0) return 'FAILED';
|
||||
if (statuses.every(function (s) { return s === 'SENT' || s === 'SKIPPED'; })) return 'SENT';
|
||||
if (statuses.indexOf('PROCESSING') >= 0) return 'PROCESSING';
|
||||
return 'PENDING';
|
||||
}
|
||||
|
||||
function renderRow(entry) {
|
||||
var status = aggregateStatus(entry);
|
||||
var label = STATUS_LABEL[status] || status;
|
||||
var cls = STATUS_CLASS[status] || 'status-inactive';
|
||||
var note = '';
|
||||
if (entry.warning) {
|
||||
note += '<div class="nt-row-note nt-row-warning">' + escapeHtml(entry.warning) + '</div>';
|
||||
}
|
||||
if (entry.errorMessage) {
|
||||
note += '<div class="nt-row-note nt-row-error">' + escapeHtml(entry.errorMessage) + '</div>';
|
||||
}
|
||||
var messageText = (entry.messages || []).join('\n');
|
||||
return '' +
|
||||
'<tr data-entry-id="' + escapeHtml(entry.entryId) + '">' +
|
||||
'<td>' + escapeHtml(entry.requestedAt) + '</td>' +
|
||||
'<td>' + escapeHtml(CATEGORY_LABEL[entry.category] || entry.category) + '</td>' +
|
||||
'<td>' + escapeHtml(entry.targetLabel) + note + '</td>' +
|
||||
'<td>' + escapeHtml(entry.recipient) + '</td>' +
|
||||
'<td class="nt-message-cell"><textarea class="nt-message-textarea" readonly rows="3">' +
|
||||
escapeHtml(messageText) + '</textarea></td>' +
|
||||
'<td><span class="status-badge-header ' + cls + '">' + label + '</span></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function renderHistory(list) {
|
||||
if (!list || list.length === 0) {
|
||||
historyBody.innerHTML = '';
|
||||
if (emptyRow) historyBody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
historyBody.innerHTML = list.map(renderRow).join('');
|
||||
}
|
||||
|
||||
function prependEntry(entry) {
|
||||
if (emptyRow && emptyRow.parentNode === historyBody) {
|
||||
historyBody.removeChild(emptyRow);
|
||||
}
|
||||
historyBody.insertAdjacentHTML('afterbegin', renderRow(entry));
|
||||
}
|
||||
|
||||
function selectedRadio() {
|
||||
for (var i = 0; i < radios.length; i++) {
|
||||
if (radios[i].checked) return radios[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paramsPanelFor(radio) {
|
||||
return document.getElementById('params-' + radio.id.replace('target-', ''));
|
||||
}
|
||||
|
||||
function onRadioChange() {
|
||||
document.querySelectorAll('.nt-params').forEach(function (el) { el.style.display = 'none'; });
|
||||
|
||||
var radio = selectedRadio();
|
||||
if (!radio) {
|
||||
sendBtn.disabled = true;
|
||||
hint.textContent = '왼쪽에서 발송할 항목을 선택하세요.';
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = paramsPanelFor(radio);
|
||||
if (panel) panel.style.display = '';
|
||||
sendBtn.disabled = false;
|
||||
|
||||
var strategy = radio.getAttribute('data-strategy');
|
||||
if (strategy === 'BROADCAST') {
|
||||
hint.textContent = '이 항목은 테스트 대상을 임의로 지정할 수 없으며, 실제 담당 내부직원 전원에게 발송됩니다.';
|
||||
} else if (strategy === 'DIRECT') {
|
||||
hint.textContent = '이 항목은 실서비스 템플릿을 사용합니다. 추가 수신자가 등록되어 있으면 함께 발송될 수 있습니다.';
|
||||
} else {
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
radios.forEach(function (r) { r.addEventListener('change', onRadioChange); });
|
||||
|
||||
form.addEventListener('submit', function (evt) {
|
||||
evt.preventDefault();
|
||||
var radio = selectedRadio();
|
||||
if (!radio) return;
|
||||
|
||||
var strategy = radio.getAttribute('data-strategy');
|
||||
if ((strategy === 'DIRECT' || strategy === 'BROADCAST') &&
|
||||
!window.confirm('실제 수신자(추가 수신자 또는 담당 내부직원)에게 함께 발송될 수 있습니다. 계속하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var params = new URLSearchParams();
|
||||
params.set('messageCode', radio.value);
|
||||
['recipientUsername', 'recipientEmail', 'recipientPhone', 'recipientMessengerId'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) params.set(id, el.value);
|
||||
});
|
||||
|
||||
var panel = paramsPanelFor(radio);
|
||||
if (panel) {
|
||||
panel.querySelectorAll('input').forEach(function (input) {
|
||||
params.set(input.name, input.value);
|
||||
});
|
||||
}
|
||||
|
||||
sendBtn.disabled = true;
|
||||
fetchJson(SEND_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
return res.json().catch(function () { return {}; }).then(function (body) {
|
||||
throw new Error(body.error || ('발송 요청 실패: ' + res.status));
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (entry) {
|
||||
prependEntry(entry);
|
||||
})
|
||||
.catch(function (e) {
|
||||
window.alert(e.message || '발송 중 오류가 발생했습니다.');
|
||||
})
|
||||
.then(function () {
|
||||
sendBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
function poll() {
|
||||
fetchJson(STATUS_URL)
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (list) { if (list) renderHistory(list); })
|
||||
.catch(function () { /* 폴링 실패 시 다음 주기에 재시도 */ });
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, POLL_INTERVAL_MS);
|
||||
})();
|
||||
@@ -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() : '';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,173 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports);
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({});
|
||||
}
|
||||
})(this, function(exports) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: English.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
],
|
||||
monthAbbr: [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: 'All',
|
||||
inverse: 'Inv'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: 'Box Select',
|
||||
polygon: 'Lasso Select',
|
||||
lineX: 'Horizontally Select',
|
||||
lineY: 'Vertically Select',
|
||||
keep: 'Keep Selections',
|
||||
clear: 'Clear Selections'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: 'Data View',
|
||||
lang: ['Data View', 'Close', 'Refresh']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Zoom Reset'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: 'Switch to Line Chart',
|
||||
bar: 'Switch to Bar Chart',
|
||||
stack: 'Stack',
|
||||
tiled: 'Tile'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Restore'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save as Image',
|
||||
lang: ['Right Click to Save Image']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: 'Pie chart',
|
||||
bar: 'Bar chart',
|
||||
line: 'Line chart',
|
||||
scatter: 'Scatter plot',
|
||||
effectScatter: 'Ripple scatter plot',
|
||||
radar: 'Radar chart',
|
||||
tree: 'Tree',
|
||||
treemap: 'Treemap',
|
||||
boxplot: 'Boxplot',
|
||||
candlestick: 'Candlestick',
|
||||
k: 'K line chart',
|
||||
heatmap: 'Heat map',
|
||||
map: 'Map',
|
||||
parallel: 'Parallel coordinate map',
|
||||
lines: 'Line graph',
|
||||
graph: 'Relationship graph',
|
||||
sankey: 'Sankey diagram',
|
||||
funnel: 'Funnel chart',
|
||||
gauge: 'Gauge',
|
||||
pictorialBar: 'Pictorial bar',
|
||||
themeRiver: 'Theme River Map',
|
||||
sunburst: 'Sunburst'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: 'This is a chart about "{title}"',
|
||||
withoutTitle: 'This is a chart'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' with type {seriesType} named {seriesName}.',
|
||||
withoutName: ' with type {seriesType}.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. It consists of {seriesCount} series count.',
|
||||
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
|
||||
withoutName: ' The {seriesId} series is a {seriesType}.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: 'The data is as follows: ',
|
||||
partialData: 'The first {displayCnt} items are: ',
|
||||
withName: 'the data for {name} is {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (var key in localeObj) {
|
||||
if (localeObj.hasOwnProperty(key)) {
|
||||
exports[key] = localeObj[key];
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: English.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
],
|
||||
monthAbbr: [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: 'All',
|
||||
inverse: 'Inv'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: 'Box Select',
|
||||
polygon: 'Lasso Select',
|
||||
lineX: 'Horizontally Select',
|
||||
lineY: 'Vertically Select',
|
||||
keep: 'Keep Selections',
|
||||
clear: 'Clear Selections'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: 'Data View',
|
||||
lang: ['Data View', 'Close', 'Refresh']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: 'Zoom',
|
||||
back: 'Zoom Reset'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: 'Switch to Line Chart',
|
||||
bar: 'Switch to Bar Chart',
|
||||
stack: 'Stack',
|
||||
tiled: 'Tile'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: 'Restore'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: 'Save as Image',
|
||||
lang: ['Right Click to Save Image']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: 'Pie chart',
|
||||
bar: 'Bar chart',
|
||||
line: 'Line chart',
|
||||
scatter: 'Scatter plot',
|
||||
effectScatter: 'Ripple scatter plot',
|
||||
radar: 'Radar chart',
|
||||
tree: 'Tree',
|
||||
treemap: 'Treemap',
|
||||
boxplot: 'Boxplot',
|
||||
candlestick: 'Candlestick',
|
||||
k: 'K line chart',
|
||||
heatmap: 'Heat map',
|
||||
map: 'Map',
|
||||
parallel: 'Parallel coordinate map',
|
||||
lines: 'Line graph',
|
||||
graph: 'Relationship graph',
|
||||
sankey: 'Sankey diagram',
|
||||
funnel: 'Funnel chart',
|
||||
gauge: 'Gauge',
|
||||
pictorialBar: 'Pictorial bar',
|
||||
themeRiver: 'Theme River Map',
|
||||
sunburst: 'Sunburst'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: 'This is a chart about "{title}"',
|
||||
withoutTitle: 'This is a chart'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' with type {seriesType} named {seriesName}.',
|
||||
withoutName: ' with type {seriesType}.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. It consists of {seriesCount} series count.',
|
||||
withName: ' The {seriesId} series is a {seriesType} representing {seriesName}.',
|
||||
withoutName: ' The {seriesId} series is a {seriesType}.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: 'The data is as follows: ',
|
||||
partialData: 'The first {displayCnt} items are: ',
|
||||
withName: 'the data for {name} is {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerLocale('EN', localeObj);
|
||||
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports);
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({});
|
||||
}
|
||||
})(this, function(exports) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: Korean.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
monthAbbr: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'일', '월', '화', '수', '목', '금', '토'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: '모두 선택',
|
||||
inverse: '선택 범위 반전'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: '사각형 선택',
|
||||
polygon: '올가미 선택',
|
||||
lineX: '수평 선택',
|
||||
lineY: '수직 선택',
|
||||
keep: '선택 유지',
|
||||
clear: '선택 지우기'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: '날짜 보기',
|
||||
lang: ['날짜 보기', '닫기', '새로 고침']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '확대/축소',
|
||||
back: '확대/축소 초기화'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: '꺽은선 그래프로 변경',
|
||||
bar: '막대 그래프로 변경',
|
||||
stack: '스택',
|
||||
tiled: '타일'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: '복구'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: '이미지로 저장',
|
||||
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: '원 그래프',
|
||||
bar: '막대 그래프',
|
||||
line: '꺽은선 그래프',
|
||||
scatter: '산점도',
|
||||
effectScatter: '물결 효과 산점도',
|
||||
radar: '방사형 그래프',
|
||||
tree: '트리',
|
||||
treemap: '트리맵',
|
||||
boxplot: '상자 수염 그래프',
|
||||
candlestick: '캔들스틱 차트',
|
||||
k: 'K 라인 차트',
|
||||
heatmap: '히트 맵',
|
||||
map: '지도',
|
||||
parallel: '평행 좌표 맵',
|
||||
lines: '선',
|
||||
graph: '관계 그래프',
|
||||
sankey: '산키 다이어그램',
|
||||
funnel: '깔때기형 그래프',
|
||||
gauge: '계기',
|
||||
pictorialBar: '픽토그램 차트',
|
||||
themeRiver: '스트림 그래프',
|
||||
sunburst: '선버스트 차트'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: '"{title}"에 대한 차트입니다.',
|
||||
withoutTitle: '차트입니다.'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
|
||||
withoutName: ' 차트 유형은 {seriesType}입니다.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
|
||||
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
|
||||
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: '데이터: ',
|
||||
partialData: '첫번째 {displayCnt} 아이템: ',
|
||||
withName: '{name}의 데이터는 {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (var key in localeObj) {
|
||||
if (localeObj.hasOwnProperty(key)) {
|
||||
exports[key] = localeObj[key];
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* AUTO-GENERATED FILE. DO NOT MODIFY.
|
||||
*/
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
|
||||
|
||||
/**
|
||||
* Language: Korean.
|
||||
*/
|
||||
|
||||
var localeObj = {
|
||||
time: {
|
||||
month: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
monthAbbr: [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
],
|
||||
dayOfWeek: [
|
||||
'일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'
|
||||
],
|
||||
dayOfWeekAbbr: [
|
||||
'일', '월', '화', '수', '목', '금', '토'
|
||||
]
|
||||
},
|
||||
legend: {
|
||||
selector: {
|
||||
all: '모두 선택',
|
||||
inverse: '선택 범위 반전'
|
||||
}
|
||||
},
|
||||
toolbox: {
|
||||
brush: {
|
||||
title: {
|
||||
rect: '사각형 선택',
|
||||
polygon: '올가미 선택',
|
||||
lineX: '수평 선택',
|
||||
lineY: '수직 선택',
|
||||
keep: '선택 유지',
|
||||
clear: '선택 지우기'
|
||||
}
|
||||
},
|
||||
dataView: {
|
||||
title: '날짜 보기',
|
||||
lang: ['날짜 보기', '닫기', '새로 고침']
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '확대/축소',
|
||||
back: '확대/축소 초기화'
|
||||
}
|
||||
},
|
||||
magicType: {
|
||||
title: {
|
||||
line: '꺽은선 그래프로 변경',
|
||||
bar: '막대 그래프로 변경',
|
||||
stack: '스택',
|
||||
tiled: '타일'
|
||||
}
|
||||
},
|
||||
restore: {
|
||||
title: '복구'
|
||||
},
|
||||
saveAsImage: {
|
||||
title: '이미지로 저장',
|
||||
lang: ['이미지를 저장하려면 마우스 오른쪽 버튼을 클릭하세요.']
|
||||
}
|
||||
},
|
||||
series: {
|
||||
typeNames: {
|
||||
pie: '원 그래프',
|
||||
bar: '막대 그래프',
|
||||
line: '꺽은선 그래프',
|
||||
scatter: '산점도',
|
||||
effectScatter: '물결 효과 산점도',
|
||||
radar: '방사형 그래프',
|
||||
tree: '트리',
|
||||
treemap: '트리맵',
|
||||
boxplot: '상자 수염 그래프',
|
||||
candlestick: '캔들스틱 차트',
|
||||
k: 'K 라인 차트',
|
||||
heatmap: '히트 맵',
|
||||
map: '지도',
|
||||
parallel: '평행 좌표 맵',
|
||||
lines: '선',
|
||||
graph: '관계 그래프',
|
||||
sankey: '산키 다이어그램',
|
||||
funnel: '깔때기형 그래프',
|
||||
gauge: '계기',
|
||||
pictorialBar: '픽토그램 차트',
|
||||
themeRiver: '스트림 그래프',
|
||||
sunburst: '선버스트 차트'
|
||||
}
|
||||
},
|
||||
aria: {
|
||||
general: {
|
||||
withTitle: '"{title}"에 대한 차트입니다.',
|
||||
withoutTitle: '차트입니다.'
|
||||
},
|
||||
series: {
|
||||
single: {
|
||||
prefix: '',
|
||||
withName: ' 차트 유형은 {seriesType}이며 {seriesName}을 표시합니다.',
|
||||
withoutName: ' 차트 유형은 {seriesType}입니다.'
|
||||
},
|
||||
multiple: {
|
||||
prefix: '. {seriesCount} 하나의 차트 시리즈로 구성됩니다.',
|
||||
withName: ' {seriesId}번째 시리즈는 {seriesName}을 나타내는 {seriesType} representing.',
|
||||
withoutName: ' {seriesId}번째 시리즈는 {seriesType}입니다.',
|
||||
separator: {
|
||||
middle: '',
|
||||
end: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
allData: '데이터: ',
|
||||
partialData: '첫번째 {displayCnt} 아이템: ',
|
||||
withName: '{name}의 데이터는 {value}',
|
||||
withoutName: '{value}',
|
||||
separator: {
|
||||
middle: ', ',
|
||||
end: '. '
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerLocale('KO', localeObj);
|
||||
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#f2385a',
|
||||
'#f5a503',
|
||||
'#4ad9d9',
|
||||
'#f7879c',
|
||||
'#c1d7a8',
|
||||
'#4dffd2',
|
||||
'#fccfd7',
|
||||
'#d5f6f6'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#f2385a', '#f5a503']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#f2385a', '#f2385a', '#f2385a', '#f2385a']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#f2385a',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#f2385a' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#f2385a',
|
||||
borderColor: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#f2385a',
|
||||
color0: '#f5a503'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#f2385a',
|
||||
color0: '#f5a503'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#c1d7a8',
|
||||
color0: '#4ad9d9'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#ddd'
|
||||
},
|
||||
label: {
|
||||
color: '#c12e34'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#f2385a'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#f2385a'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f5a503'],
|
||||
[0.8, '#f2385a'],
|
||||
[1, '#c1d7a8']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('azul', theme);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['exports', 'echarts'], factory);
|
||||
} else if (
|
||||
typeof exports === 'object' &&
|
||||
typeof exports.nodeName !== 'string'
|
||||
) {
|
||||
// CommonJS
|
||||
factory(exports, require('echarts/lib/echarts'));
|
||||
} else {
|
||||
// Browser globals
|
||||
factory({}, root.echarts);
|
||||
}
|
||||
})(this, function(exports, echarts) {
|
||||
var log = function(msg) {
|
||||
if (typeof console !== 'undefined') {
|
||||
console && console.error && console.error(msg);
|
||||
}
|
||||
};
|
||||
if (!echarts) {
|
||||
log('ECharts is not Loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
var colorPalette = [
|
||||
'#001727',
|
||||
'#805500',
|
||||
'#ffff00',
|
||||
'#ffd11a',
|
||||
'#f2d71f',
|
||||
'#f2be19',
|
||||
'#f3a81a',
|
||||
'#fff5cc'
|
||||
];
|
||||
|
||||
var theme = {
|
||||
color: colorPalette,
|
||||
|
||||
title: {
|
||||
textStyle: {
|
||||
fontWeight: 'normal',
|
||||
color: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
visualMap: {
|
||||
color: ['#001727', '#805500']
|
||||
},
|
||||
|
||||
toolbox: {
|
||||
color: ['#001727', '#001727', '#001727', '#001727']
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
axisPointer: {
|
||||
// Axis indicator, coordinate trigger effective
|
||||
type: 'line', // The default is a straight line: 'line' | 'shadow'
|
||||
lineStyle: {
|
||||
// Straight line indicator style settings
|
||||
color: '#001727',
|
||||
type: 'dashed'
|
||||
},
|
||||
crossStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
shadowStyle: {
|
||||
// Shadow indicator style settings
|
||||
color: 'rgba(200,200,200,0.3)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Area scaling controller
|
||||
dataZoom: {
|
||||
dataBackgroundColor: '#eee', // Data background color
|
||||
fillerColor: 'rgba(200,200,200,0.2)', // Fill the color
|
||||
handleColor: '#001727' // Handle color
|
||||
},
|
||||
|
||||
timeline: {
|
||||
lineStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
controlStyle: {
|
||||
color: '#001727',
|
||||
borderColor: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
candlestick: {
|
||||
itemStyle: {
|
||||
color: '#f3a81a',
|
||||
color0: '#ffff00'
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#ffff00',
|
||||
color0: '#f3a81a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#805500',
|
||||
color0: '#ffff00'
|
||||
}
|
||||
},
|
||||
|
||||
chord: {
|
||||
padding: 4,
|
||||
itemStyle: {
|
||||
color: '#f3a81a',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'rgba(128, 128, 128, 0.5)'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#805500'
|
||||
}
|
||||
},
|
||||
|
||||
map: {
|
||||
itemStyle: {
|
||||
color: '#ffd11a'
|
||||
},
|
||||
areaStyle: {
|
||||
color: '#f2be19'
|
||||
},
|
||||
label: {
|
||||
color: '#ffd11a'
|
||||
}
|
||||
},
|
||||
|
||||
graph: {
|
||||
itemStyle: {
|
||||
color: '#001727'
|
||||
},
|
||||
linkStyle: {
|
||||
color: '#001727'
|
||||
}
|
||||
},
|
||||
|
||||
gauge: {
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: [
|
||||
[0.2, '#f2d71f'],
|
||||
[0.8, '#001727'],
|
||||
[1, '#ffff00']
|
||||
],
|
||||
width: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
echarts.registerTheme('bee-inspired', theme);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user