Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca44e8a967 |
+9
-6
@@ -37,13 +37,13 @@ java {
|
||||
}
|
||||
}
|
||||
|
||||
// generatedJavaDir / generatedTestJavaDir 는 아래 annotationProcessorGeneratedSourcesDirectory 로
|
||||
// APT 가 이미 컴파일한다. srcDir 로도 등록하면 낡은 생성물이 입력소스가 돼
|
||||
// APT 재생성 시 duplicate class 가 발생하므로 등록 금지.
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDirs = ['src/main/java']
|
||||
srcDirs = [
|
||||
'src/main/java',
|
||||
generatedJavaDir
|
||||
]
|
||||
}
|
||||
resources {
|
||||
srcDirs = ['src/main/resources']
|
||||
@@ -51,7 +51,10 @@ sourceSets {
|
||||
}
|
||||
test {
|
||||
java {
|
||||
srcDirs = ['src/test/java']
|
||||
srcDirs = [
|
||||
'src/test/java',
|
||||
generatedTestJavaDir
|
||||
]
|
||||
}
|
||||
resources {
|
||||
srcDirs = ['src/test/resources']
|
||||
@@ -111,7 +114,7 @@ dependencies {
|
||||
api group: 'commons-io', name: 'commons-io', version: '2.11.0'
|
||||
|
||||
|
||||
implementation files('libs/damo-manager.jar')
|
||||
compileOnly files('libs/damo-manager.jar')
|
||||
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apispec.repository;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -24,23 +23,4 @@ public interface ApiSpecInfoRepository extends JpaRepository<ApiSpecInfo, String
|
||||
|
||||
|
||||
Optional<ApiSpecInfo> findApiSpecInfoByApiUrlAndApiMethodAndDisplayYn(String apiUrl, String apiMethod, String displayYn);
|
||||
|
||||
/**
|
||||
* 스펙이 등록된 API ID 만 추린다 - 존재 여부 확인용 ID 프로젝션.
|
||||
*
|
||||
* <p>PTL_API_SPEC_INFO 는 EMSAPP 스키마다. API 그룹(AGWAPP.API_GROUP)과 한 쿼리로 조인하면
|
||||
* AGWAPP 커넥션에서 EMSAPP 를 참조하게 되어 ORA-00942 가 난다. 스키마별로 나눠 조회한 뒤
|
||||
* 호출부에서 교집합을 잡는다.</p>
|
||||
*/
|
||||
@Query("SELECT a.apiId FROM ApiSpecInfo a WHERE a.apiId IN :apiIds")
|
||||
List<String> findApiIdsIn(@Param("apiIds") Collection<String> apiIds);
|
||||
|
||||
/**
|
||||
* 스펙이 등록된 전체 API ID.
|
||||
*
|
||||
* <p>{@link #findApiIdsIn} 과 같은 이유로 존재한다 - 다른 스키마(TSEAIHE01·API_GROUP)의 목록을
|
||||
* 스펙 유무로 거를 때, 조인 대신 ID 집합을 받아 호출부에서 걸러야 ORA-00942 를 피한다.</p>
|
||||
*/
|
||||
@Query("SELECT a.apiId FROM ApiSpecInfo a")
|
||||
List<String> findAllApiIds();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@EMSDataSource
|
||||
public interface CredentialRepository extends BaseRepository<Credential, String> {
|
||||
@@ -20,9 +19,6 @@ public interface CredentialRepository extends BaseRepository<Credential, String>
|
||||
|
||||
List<Credential> findAllByOrgid(String orgid);
|
||||
|
||||
@Transactional
|
||||
long deleteByOrgid(String orgid);
|
||||
|
||||
Optional<Credential> findByClientidAndOrgid(String clientid, String orgid);
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,11 +68,6 @@ public class AppRequest implements com.eactive.eai.data.Data {
|
||||
@Column(name = "reason")
|
||||
private String reason;
|
||||
|
||||
//DELETE 승인 시 admin 이 선택한 GW 처리방식. null=차단(BLOCK)과 동일
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "gw_action", length = 10)
|
||||
private GwAction gwAction;
|
||||
|
||||
@Column(name = "app_description", length = 500)
|
||||
private String appDescription;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.eactive.apim.portal.apprequest.entity;
|
||||
|
||||
public enum AppRequestType {
|
||||
NEW("클라이언트 인증정보 - 신규"),
|
||||
MODIFY("클라이언트 인증정보 - 수정"),
|
||||
DELETE("클라이언트 인증정보 - 삭제");
|
||||
NEW("API키 신규"),
|
||||
MODIFY("API 변경"),
|
||||
DELETE("API키 삭제");
|
||||
|
||||
private final String description;
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.eactive.apim.portal.apprequest.entity;
|
||||
|
||||
/**
|
||||
* API 이용해지(DELETE) 승인 시 게이트웨이 클라이언트(TSEAIAU01) 처리 방식.
|
||||
* 승인 시점에 관리자가 선택하며 PTL_APP_REQUEST.GW_ACTION 에 영속화된다.
|
||||
* null(미지정)은 BLOCK 과 동일하게 처리한다.
|
||||
*/
|
||||
public enum GwAction {
|
||||
BLOCK("차단"),
|
||||
DELETE("완전삭제");
|
||||
|
||||
private final String description;
|
||||
|
||||
GwAction(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,12 @@ public interface AppRequestRepository extends BaseRepository<AppRequest, String>
|
||||
|
||||
List<AppRequest> findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(PortalOrg org, List<AppRequestType> types, List<ApprovalState> approvalStates);
|
||||
|
||||
List<AppRequest> findAllByOrgAndTypeIsInAndApprovalIsNull(PortalOrg org, List<AppRequestType> types);
|
||||
|
||||
int countAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(PortalOrg org, List<AppRequestType> types, List<ApprovalState> approvalStates);
|
||||
|
||||
Optional<AppRequest> findByIdAndOrg(String id, PortalOrg org);
|
||||
|
||||
Optional<AppRequest> findByIdAndOrgAndTypeIsIn(String id, PortalOrg org, List<AppRequestType> types);
|
||||
|
||||
/** 테스트 재실행 정리처럼 법인 내 동일 클라이언트명의 신청을 좁게 조회한다. */
|
||||
List<AppRequest> findAllByOrgAndClientName(PortalOrg org, String clientName);
|
||||
|
||||
List<AppRequest> findAllByClientIdsContainsAndTypeIsIn(String clientId, List<AppRequestType> types);
|
||||
|
||||
List<AppRequest> findAllByOrgAndStatus(PortalOrg org, ApprovalStatus status);
|
||||
|
||||
@@ -23,9 +23,6 @@ public class ProcessingState implements ApprovalState {
|
||||
case DENY:
|
||||
handleDeny(approval, options);
|
||||
break;
|
||||
case CANCEL:
|
||||
handleCancel(approval, options);
|
||||
break;
|
||||
default:
|
||||
logger.warn("Invalid event {} for ProcessingState in approval {}", event, approval.getId());
|
||||
throw new UnsupportedOperationException("Invalid event for Processing state");
|
||||
@@ -94,38 +91,6 @@ public class ProcessingState implements ApprovalState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청자 취소. 심사중(PROCESSING)이라도 <b>아직 아무도 승인하지 않은</b> 경우에 한해 허용한다.
|
||||
*
|
||||
* <p>관리자가 결재 상세를 열어보기만 해도 REQUESTED → PROCESSING 으로 바뀌므로(admin
|
||||
* {@code PortalApprovalManService.selectDetail}), 이 상태의 상당수는 실제로 결재가 진행된 것이
|
||||
* 아니라 열람만 된 건이다. 그런 건이 취소 불가로 남으면 해당 인증키의 후속 신청(변경/해지)이
|
||||
* 무기한 막힌다. 승인 이력이 하나라도 있으면 기존 정책대로 거절한다.</p>
|
||||
*/
|
||||
private void handleCancel(Approval approval, Map<String, Object> options) {
|
||||
boolean anyApproved = approval.getApprovers().stream()
|
||||
.anyMatch(approver -> ApproverStatus.APPROVED.equals(approver.getApproverStatus()));
|
||||
if (anyApproved) {
|
||||
logger.warn("Cancel rejected for approval {} - already approved by at least one approver", approval.getId());
|
||||
throw new UnsupportedOperationException("Cannot cancel approval already approved by an approver");
|
||||
}
|
||||
|
||||
String approverId = (String) options.getOrDefault(ApprovalConstants.APPROVER, "");
|
||||
if (!isAuthorizedApprover(approval, approverId, options)) {
|
||||
logger.warn("Unauthorized cancel attempt for approval {} by {}", approval.getId(), approverId);
|
||||
throw new UnauthorizedApprovalException("Unauthorized approver");
|
||||
}
|
||||
|
||||
approval.getApprovers().forEach(approver -> {
|
||||
approver.setApproverStatus(ApproverStatus.CANCELED);
|
||||
approver.setApprovedDate(LocalDateTime.now());
|
||||
});
|
||||
approval.setApprovalDate(LocalDateTime.now());
|
||||
getApprovalListener(options).rollback(approval);
|
||||
approval.setApprovalStatus(new CanceledState());
|
||||
logger.info("Approval {} cancelled in Processing state by {}", approval.getId(), approverId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return ApprovalStatus.PROCESSING.toString();
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.eactive.apim.portal.common.internal;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 내부 API(admin → portal) 공유 토큰 관리.
|
||||
*
|
||||
* <p>포탈의 {@code /internal/**} 는 CSRF 예외 경로다. CSRF 토큰이 없는 대신 <b>커스텀 헤더</b>를 요구해
|
||||
* 브라우저발 cross-site 위조(form POST 는 커스텀 헤더를 붙일 수 없다)를 원천 차단한다.
|
||||
* 헤더명과 토큰값은 PTL_PROPERTY {@code Portal} 그룹으로 관리한다 — portal·admin 이 동일 DB 를
|
||||
* 공유하므로 별도 배포/설정 동기화 없이 같은 값을 읽는다.</p>
|
||||
*
|
||||
* <table border="1">
|
||||
* <caption>PTL_PROPERTY (group = Portal)</caption>
|
||||
* <tr><th>키</th><th>기본값</th><th>비고</th></tr>
|
||||
* <tr><td>{@code internal.api.header-name}</td><td>{@code X-Internal-Token}</td><td>요청 헤더명</td></tr>
|
||||
* <tr><td>{@code internal.api.token}</td><td>기동 시 난수 생성</td><td>256bit SecureRandom, base64url</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <h3>생성 주체</h3>
|
||||
* 토큰을 <b>생성</b>하는 쪽은 포탈 한 곳이다({@code ensureXxx}, 기동 시 1회). admin 은 읽기 전용
|
||||
* ({@code findXxx})으로만 접근해, 포탈보다 먼저 기동하더라도 엉뚱한 값을 선점 저장하지 않는다.
|
||||
*
|
||||
* <h3>토큰 교체</h3>
|
||||
* PTL_PROPERTY 값을 수정하면 즉시 반영된다(양쪽 모두 호출 시점에 조회). {@code ptl_property} 는 2차 캐시
|
||||
* 대상이므로 값 변경 후 첫 호출이 실패하면 각 인스턴스 재기동으로 캐시를 비운다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InternalApiTokenService {
|
||||
|
||||
public static final String PROP_GROUP = "Portal";
|
||||
public static final String PROP_HEADER_NAME = "internal.api.header-name";
|
||||
public static final String PROP_TOKEN = "internal.api.token";
|
||||
|
||||
/** 헤더명 기본값 — 프로퍼티가 없을 때 portal·admin 이 동일하게 사용한다. */
|
||||
public static final String DEFAULT_HEADER_NAME = "X-Internal-Token";
|
||||
|
||||
static final String HEADER_NAME_DESCRIPTION =
|
||||
"내부 API(admin → portal /internal/**) 인증 헤더명. CSRF 예외 경로의 위조 방지용";
|
||||
static final String TOKEN_DESCRIPTION =
|
||||
"내부 API(admin → portal /internal/**) 공유 토큰. 최초 기동 시 자동 생성되며, 교체 시 값만 바꾸면 됨";
|
||||
|
||||
/** 토큰 엔트로피(바이트). base64url 인코딩 시 43자. */
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 이 JVM 이 생성한 토큰. DB 저장이 실패해도(그룹 미존재 등) 호출마다 값이 달라지지 않도록 붙잡아 둔다.
|
||||
* — 값이 흔들리면 "가끔 되고 가끔 안 되는" 형태로 증상이 숨는다.
|
||||
*/
|
||||
private final AtomicReference<String> generatedToken = new AtomicReference<>();
|
||||
|
||||
/** 헤더명 조회 — 없으면 기본값으로 생성한다. (포탈 전용) */
|
||||
public String ensureHeaderName() {
|
||||
String headerName = getOrCreate(PROP_HEADER_NAME, DEFAULT_HEADER_NAME, HEADER_NAME_DESCRIPTION);
|
||||
return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
|
||||
}
|
||||
|
||||
/** 토큰 조회 — 없으면 난수를 생성해 저장한다. (포탈 전용) */
|
||||
public String ensureToken() {
|
||||
String token = getOrCreate(PROP_TOKEN, localToken(), TOKEN_DESCRIPTION);
|
||||
return isBlank(token) ? null : token.trim();
|
||||
}
|
||||
|
||||
/** 헤더명 조회(읽기 전용) — 없으면 기본값. 생성하지 않는다. (admin 용) */
|
||||
public String findHeaderName() {
|
||||
String headerName = read(PROP_HEADER_NAME);
|
||||
return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
|
||||
}
|
||||
|
||||
/** 토큰 조회(읽기 전용) — 없으면 {@code null}. 생성하지 않는다. (admin 용) */
|
||||
public String findToken() {
|
||||
String token = read(PROP_TOKEN);
|
||||
return isBlank(token) ? null : token.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청이 제시한 토큰이 유효한지 검사한다. 값 비교는 타이밍 공격을 피해 상수 시간으로 수행한다.
|
||||
* 기대 토큰을 확보하지 못하면 <b>거부</b>한다(fail-closed).
|
||||
*/
|
||||
public boolean matches(String presented) {
|
||||
String expected = ensureToken();
|
||||
return tokenMatches(presented, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* admin 등 토큰을 생성하면 안 되는 애플리케이션용 검증.
|
||||
* 프로퍼티가 없으면 생성하지 않고 거부한다.
|
||||
*/
|
||||
public boolean matchesReadOnly(String presented) {
|
||||
return tokenMatches(presented, findToken());
|
||||
}
|
||||
|
||||
/** 기동 시 1회 호출 — 헤더명/토큰 프로퍼티를 미리 만들어 둔다. (포탈 전용) */
|
||||
public void initialize() {
|
||||
String headerName = ensureHeaderName();
|
||||
String token = ensureToken();
|
||||
if (token == null) {
|
||||
log.error("내부 API 토큰 초기화 실패 - PTL_PROPERTY 그룹 '{}' 존재 여부를 확인하세요.", PROP_GROUP);
|
||||
return;
|
||||
}
|
||||
// 토큰 값은 로그에 남기지 않는다.
|
||||
log.info("내부 API 토큰 초기화 완료 - header: {}, tokenLength: {}", headerName, token.length());
|
||||
}
|
||||
|
||||
private String getOrCreate(String propertyName, String defaultValue, String description) {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, propertyName, defaultValue, description);
|
||||
} catch (Exception e) {
|
||||
log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private String read(String propertyName) {
|
||||
try {
|
||||
Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap(PROP_GROUP);
|
||||
return properties.get(propertyName);
|
||||
} catch (Exception e) {
|
||||
log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tokenMatches(String presented, String expected) {
|
||||
if (expected == null) {
|
||||
log.error("내부 API 토큰({}/{})을 확보하지 못해 요청을 거부합니다.", PROP_GROUP, PROP_TOKEN);
|
||||
return false;
|
||||
}
|
||||
if (isBlank(presented)) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
presented.trim().getBytes(StandardCharsets.UTF_8),
|
||||
expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** JVM 당 1회만 생성되는 난수 토큰 (DB 에 값이 없을 때의 기본값). */
|
||||
private String localToken() {
|
||||
String token = generatedToken.get();
|
||||
if (token != null) {
|
||||
return token;
|
||||
}
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
String candidate = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
return generatedToken.compareAndSet(null, candidate) ? candidate : generatedToken.get();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
/**
|
||||
* 허용 IP 목록(allowlist) 매칭 유틸.
|
||||
*
|
||||
* <p>내부 API 가드에서 {@code PTL_PROPERTY} 로 관리하는 허용 IP 문자열을 해석한다.
|
||||
* 목록은 <b>콤마(,) / 세미콜론(;) / 줄바꿈</b> 으로 구분하며 각 항목은 다음 형식을 지원한다.</p>
|
||||
*
|
||||
* <table border="1">
|
||||
* <caption>지원 형식</caption>
|
||||
* <tr><th>형식</th><th>예</th><th>설명</th></tr>
|
||||
* <tr><td>정확 일치</td><td>{@code 127.0.0.1}, {@code ::1}</td><td>문자열 완전 일치(IPv6 포함)</td></tr>
|
||||
* <tr><td>IPv4 CIDR</td><td>{@code 172.30.1.0/24}</td><td>prefix 0~32</td></tr>
|
||||
* <tr><td>IPv4 와일드카드</td><td>{@code 172.30.*.*}</td><td>옥텟 단위 {@code *}</td></tr>
|
||||
* <tr><td>전체 허용</td><td>{@code *}</td><td>모든 IP 허용 — 운영 사용 금지 권고</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>{@code #} 로 시작하는 항목은 주석으로 무시한다.
|
||||
* IPv4-mapped IPv6({@code ::ffff:172.30.1.5}) 와 IPv6 loopback 표기({@code 0:0:0:0:0:0:0:1})
|
||||
* 는 비교 전 정규화하므로 듀얼스택 환경에서도 IPv4 규칙이 그대로 적용된다.</p>
|
||||
*
|
||||
* <p>CIDR·와일드카드 항목의 형식이 잘못되면(잘못된 옥텟, 범위 밖 prefix 등) <b>매칭 실패로 처리</b>한다 —
|
||||
* 허용 목록이므로 해석 불가 항목을 통과시키지 않는다. 정확 일치 항목은 IPv6 등 임의 표기를 허용해야 하므로
|
||||
* 유효성 검증 없이 문자열을 그대로 비교한다(형식이 깨진 항목은 실제 소켓 IP 와 일치할 수 없어 무효 규칙이 된다).</p>
|
||||
*/
|
||||
public final class IpAddressMatcher {
|
||||
|
||||
/** 모든 IP 를 허용하는 와일드카드 항목 */
|
||||
public static final String MATCH_ALL = "*";
|
||||
|
||||
private static final String LIST_DELIMITERS = "[,;\\r\\n]";
|
||||
private static final String IPV6_LOOPBACK_LONG = "0:0:0:0:0:0:0:1";
|
||||
private static final String IPV4_MAPPED_PREFIX = "::ffff:";
|
||||
|
||||
private IpAddressMatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 목록에 {@code remoteIp} 가 포함되는지 검사한다.
|
||||
*
|
||||
* @param allowList 콤마/세미콜론/줄바꿈으로 구분된 허용 IP 목록 (null·공백이면 false)
|
||||
* @param remoteIp 검사 대상 IP (null·공백이면 false)
|
||||
* @return 하나라도 매칭되면 true
|
||||
*/
|
||||
public static boolean matches(String allowList, String remoteIp) {
|
||||
if (allowList == null || allowList.trim().isEmpty()
|
||||
|| remoteIp == null || remoteIp.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String remote = canonicalize(remoteIp.trim());
|
||||
for (String token : allowList.split(LIST_DELIMITERS)) {
|
||||
String pattern = token.trim();
|
||||
if (pattern.isEmpty() || pattern.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (matchesOne(pattern, remote)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesOne(String pattern, String remote) {
|
||||
if (MATCH_ALL.equals(pattern)) {
|
||||
return true;
|
||||
}
|
||||
if (pattern.indexOf('/') >= 0) {
|
||||
return matchesCidr(pattern, remote);
|
||||
}
|
||||
if (pattern.indexOf('*') >= 0) {
|
||||
return matchesWildcard(pattern, remote);
|
||||
}
|
||||
return canonicalize(pattern).equals(remote);
|
||||
}
|
||||
|
||||
/** IPv4 CIDR({@code 172.30.1.0/24}) 매칭. IPv6 CIDR 은 지원하지 않는다. */
|
||||
private static boolean matchesCidr(String pattern, String remote) {
|
||||
int slash = pattern.indexOf('/');
|
||||
String base = pattern.substring(0, slash).trim();
|
||||
String prefixText = pattern.substring(slash + 1).trim();
|
||||
|
||||
long baseAddr = toIpv4Long(base);
|
||||
long remoteAddr = toIpv4Long(remote);
|
||||
if (baseAddr < 0 || remoteAddr < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int prefix;
|
||||
try {
|
||||
prefix = Integer.parseInt(prefixText);
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
if (prefix < 0 || prefix > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long mask = (prefix == 0) ? 0L : ((0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL);
|
||||
return (baseAddr & mask) == (remoteAddr & mask);
|
||||
}
|
||||
|
||||
/** IPv4 옥텟 와일드카드({@code 172.30.*.*}) 매칭. 옥텟 내 부분 와일드카드는 지원하지 않는다. */
|
||||
private static boolean matchesWildcard(String pattern, String remote) {
|
||||
String[] patternParts = pattern.split("\\.", -1);
|
||||
String[] remoteParts = remote.split("\\.", -1);
|
||||
if (patternParts.length != 4 || remoteParts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int remoteOctet = parseOctet(remoteParts[i]);
|
||||
if (remoteOctet < 0) {
|
||||
return false;
|
||||
}
|
||||
String p = patternParts[i].trim();
|
||||
if (MATCH_ALL.equals(p)) {
|
||||
continue;
|
||||
}
|
||||
int patternOctet = parseOctet(p);
|
||||
if (patternOctet < 0 || patternOctet != remoteOctet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비교 전 표기 정규화 — IPv6 loopback 축약, IPv4-mapped IPv6 의 IPv4 부분 추출.
|
||||
*/
|
||||
public static String canonicalize(String ip) {
|
||||
if (ip == null) {
|
||||
return null;
|
||||
}
|
||||
String value = ip.trim();
|
||||
if (IPV6_LOOPBACK_LONG.equals(value)) {
|
||||
return "::1";
|
||||
}
|
||||
if (value.length() > IPV4_MAPPED_PREFIX.length()
|
||||
&& value.regionMatches(true, 0, IPV4_MAPPED_PREFIX, 0, IPV4_MAPPED_PREFIX.length())) {
|
||||
String candidate = value.substring(IPV4_MAPPED_PREFIX.length());
|
||||
if (toIpv4Long(candidate) >= 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @return IPv4 주소의 32bit 값, 형식 오류면 -1 */
|
||||
private static long toIpv4Long(String ip) {
|
||||
String[] parts = ip.split("\\.", -1);
|
||||
if (parts.length != 4) {
|
||||
return -1L;
|
||||
}
|
||||
long value = 0L;
|
||||
for (String part : parts) {
|
||||
int octet = parseOctet(part);
|
||||
if (octet < 0) {
|
||||
return -1L;
|
||||
}
|
||||
value = (value << 8) | octet;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @return 0~255 옥텟 값, 형식 오류면 -1 */
|
||||
private static int parseOctet(String text) {
|
||||
String value = text.trim();
|
||||
if (value.isEmpty() || value.length() > 3) {
|
||||
return -1;
|
||||
}
|
||||
int result = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c < '0' || c > '9') {
|
||||
return -1;
|
||||
}
|
||||
result = result * 10 + (c - '0');
|
||||
}
|
||||
return (result > 255) ? -1 : result;
|
||||
}
|
||||
}
|
||||
+1
-34
@@ -1,39 +1,6 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.incident.entity;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 이슈 종류.
|
||||
*
|
||||
* <p>{@link #INCIDENT} 와 {@link #DELAY} 는 둘 다 서비스 저하이며 {@code STATE} 를 가진다.
|
||||
* {@link #MAINTENANCE} 만 계획된 작업이라 {@code STATE} 가 없다 (ADR-F15).</p>
|
||||
*
|
||||
* <p>{@link #DELAY} 는 자동 탐지(응답시간 임계 초과) 전용이며 공지(PTL_NOTICE)를 만들지 않는다.
|
||||
* 관리자 검수 화면이 없고 복구 이벤트({@code DELAY_END}/{@code ERROR_END})로 자동 종결된다.</p>
|
||||
*/
|
||||
public enum IncidentKind {
|
||||
|
||||
/** 장애 */
|
||||
INCIDENT,
|
||||
|
||||
/** 지연 - 자동 탐지 전용, 연결 공지 없음 */
|
||||
DELAY,
|
||||
|
||||
/** 점검 */
|
||||
MAINTENANCE;
|
||||
|
||||
/**
|
||||
* 서비스 저하로 취급하는 종류 (장애 + 지연).
|
||||
*
|
||||
* <p>가동률 차감, 복구 처리, 진행 중 이슈 조회의 대상이다. 점검은 계획된 작업이라 제외된다.</p>
|
||||
*/
|
||||
public static final Collection<IncidentKind> DEGRADING =
|
||||
Collections.unmodifiableList(Arrays.asList(INCIDENT, DELAY));
|
||||
|
||||
/** {@link #DEGRADING} 여부 */
|
||||
public boolean isDegrading() {
|
||||
return this != MAINTENANCE;
|
||||
}
|
||||
MAINTENANCE
|
||||
}
|
||||
|
||||
-7
@@ -6,7 +6,6 @@ import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@@ -16,11 +15,5 @@ public interface DjbApistatusIncidentApiRepository
|
||||
|
||||
List<DjbApistatusIncidentApi> findByIncidentIdOrderByApiId(Long incidentId);
|
||||
|
||||
List<DjbApistatusIncidentApi> findByIncidentIdInOrderByIncidentIdAscApiIdAsc(Collection<Long> incidentIds);
|
||||
|
||||
List<DjbApistatusIncidentApi> findByApiIdInAndRecoveredAtIsNull(Collection<String> apiIds);
|
||||
|
||||
long countByIncidentIdAndRecoveredAtIsNull(Long incidentId);
|
||||
|
||||
void deleteByIncidentId(Long incidentId);
|
||||
}
|
||||
|
||||
-38
@@ -1,17 +1,11 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.incident.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@@ -20,36 +14,4 @@ public interface DjbApistatusIncidentRepository
|
||||
extends JpaRepository<DjbApistatusIncident, Long>, JpaSpecificationExecutor<DjbApistatusIncident> {
|
||||
|
||||
Optional<DjbApistatusIncident> findByNoticeId(String noticeId);
|
||||
|
||||
/** 공지 목록에 상태를 함께 표기할 때 1+N 을 피하기 위한 일괄 조회. */
|
||||
List<DjbApistatusIncident> findByNoticeIdIn(Collection<String> noticeIds);
|
||||
|
||||
Optional<DjbApistatusIncident> findByInterfaceId(String interfaceId);
|
||||
|
||||
/**
|
||||
* 미종결 이슈. 장애와 지연을 함께 봐야 하므로 종류는 복수로 받는다
|
||||
* ({@link IncidentKind#DEGRADING}).
|
||||
*/
|
||||
List<DjbApistatusIncident> findByKindInAndStateNotInOrderByStartedAtDesc(
|
||||
Collection<IncidentKind> kinds, Collection<IncidentState> excludedStates);
|
||||
|
||||
/**
|
||||
* 특정 API 들에 영향을 주는 미종결 이슈. 자동 탐지 시 기존 이슈 병합 대상 판별용.
|
||||
*
|
||||
* <p>{@code detectedBy} 로 등록 주체를 한정한다. 자동 탐지는 자동 등록 건에만 영향 API 를 덧붙여야
|
||||
* 관리자가 직접 작성한 장애 공지의 내용이 바뀌지 않는다.</p>
|
||||
*
|
||||
* <p>지연(DELAY)이 장애(INCIDENT)로 번지는 경우가 있어 종류는 복수로 받는다.</p>
|
||||
*/
|
||||
@Query("SELECT DISTINCT i FROM DjbApistatusIncident i, DjbApistatusIncidentApi a"
|
||||
+ " WHERE a.incidentId = i.incidentId"
|
||||
+ " AND i.kind IN :kinds"
|
||||
+ " AND i.detectedBy = :detectedBy"
|
||||
+ " AND i.state NOT IN :excludedStates"
|
||||
+ " AND a.apiId IN :apiIds"
|
||||
+ " ORDER BY i.startedAt DESC")
|
||||
List<DjbApistatusIncident> findOpenByApiIds(@Param("kinds") Collection<IncidentKind> kinds,
|
||||
@Param("detectedBy") String detectedBy,
|
||||
@Param("excludedStates") Collection<IncidentState> excludedStates,
|
||||
@Param("apiIds") Collection<String> apiIds);
|
||||
}
|
||||
|
||||
-6
@@ -5,7 +5,6 @@ import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@@ -15,10 +14,5 @@ public interface DjbApistatusIncidentTimelineRepository
|
||||
|
||||
List<DjbApistatusIncidentTimeline> findByIncidentIdOrderByEventAtAsc(Long incidentId);
|
||||
|
||||
List<DjbApistatusIncidentTimeline> findByIncidentIdOrderByEventAtDesc(Long incidentId);
|
||||
|
||||
List<DjbApistatusIncidentTimeline> findByIncidentIdInAndVisibleYnOrderByEventAtDesc(
|
||||
Collection<Long> incidentIds, String visibleYn);
|
||||
|
||||
void deleteByIncidentId(Long incidentId);
|
||||
}
|
||||
|
||||
-4
@@ -17,10 +17,6 @@ public interface UserInvitationRepository extends JpaRepository<UserInvitation,
|
||||
|
||||
List<UserInvitation> findByStatus(InvitationStatus status);
|
||||
|
||||
long deleteByOrgId(String orgId);
|
||||
|
||||
long deleteByInvitationMobile(String invitationMobile);
|
||||
|
||||
Optional<UserInvitation> findFirstByInvitationEmailAndStatus(String email, InvitationStatus status);
|
||||
|
||||
Optional<UserInvitation> findFirstByInvitationEmailAndOrgIdAndStatus(String email, String orgId, InvitationStatus status);
|
||||
|
||||
@@ -97,11 +97,6 @@ public class PersonalDataEncryptConverter implements AttributeConverter<String,
|
||||
return DamoManager.getInstance().isBypassMode();
|
||||
}
|
||||
|
||||
/** 현재 damo-manager 가 fake 모드(scpdb 부재, Base64 대체)인지 여부. 운영 실행 전 REAL 확인용. */
|
||||
public boolean isFakeMode() {
|
||||
return DamoManager.getInstance().isFakeMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 암호화 여부 확인(base64 인코딩 여부로 판단, 또는 0-9, - 로 구성된 경우 암호화 되지 않은 걸로 판단(연락처))
|
||||
* @param data
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.eactive.apim.portal.jpa;
|
||||
|
||||
import org.hibernate.HibernateException;
|
||||
import org.hibernate.engine.spi.SharedSessionContractImplementor;
|
||||
import org.hibernate.id.IdentifierGenerator;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
/**
|
||||
* UUID version 7 (time-ordered, RFC 9562) 생성기.
|
||||
*
|
||||
* <p>상위 48비트에 Unix epoch 밀리초 타임스탬프를 두어 생성 시각 순으로 정렬되므로,
|
||||
* 무작위 v4 대비 인덱스(B-Tree) 삽입 지역성이 좋아 PK 인덱스 단편화를 줄인다.
|
||||
* canonical 36자 문자열로 반환하여 기존 {@code VARCHAR2(36)} 컬럼과 호환된다.</p>
|
||||
*
|
||||
* <p>레이아웃(128비트): unix_ts_ms(48) | ver=0b0111(4) | rand_a(12) | var=0b10(2) | rand_b(62)</p>
|
||||
*
|
||||
* <p>Java 8 호환(java.util.UUID + SecureRandom). Hibernate {@link IdentifierGenerator} 구현이며
|
||||
* 엔티티에서 {@code @GenericGenerator(strategy = "...UuidV7Generator")} 로 지정한다.</p>
|
||||
*/
|
||||
public class UuidV7Generator implements IdentifierGenerator {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
@Override
|
||||
public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
|
||||
return generateString();
|
||||
}
|
||||
|
||||
/** UUID v7 을 canonical 36자 문자열로 생성한다. */
|
||||
public static String generateString() {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
byte[] value = new byte[16];
|
||||
|
||||
// 상위 48비트: unix epoch millis (big-endian)
|
||||
value[0] = (byte) ((timestamp >>> 40) & 0xFF);
|
||||
value[1] = (byte) ((timestamp >>> 32) & 0xFF);
|
||||
value[2] = (byte) ((timestamp >>> 24) & 0xFF);
|
||||
value[3] = (byte) ((timestamp >>> 16) & 0xFF);
|
||||
value[4] = (byte) ((timestamp >>> 8) & 0xFF);
|
||||
value[5] = (byte) (timestamp & 0xFF);
|
||||
|
||||
// 나머지 10바이트는 난수로 채운다.
|
||||
byte[] rand = new byte[10];
|
||||
RANDOM.nextBytes(rand);
|
||||
System.arraycopy(rand, 0, value, 6, 10);
|
||||
|
||||
// version 7: value[6] 상위 니블을 0111 로 설정
|
||||
value[6] = (byte) ((value[6] & 0x0F) | 0x70);
|
||||
// variant 10xx: value[8] 상위 2비트를 10 으로 설정
|
||||
value[8] = (byte) ((value[8] & 0x3F) | 0x80);
|
||||
|
||||
long msb = 0L;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
msb = (msb << 8) | (value[i] & 0xFFL);
|
||||
}
|
||||
long lsb = 0L;
|
||||
for (int i = 8; i < 16; i++) {
|
||||
lsb = (lsb << 8) | (value[i] & 0xFFL);
|
||||
}
|
||||
|
||||
return new java.util.UUID(msb, lsb).toString();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.entity.Auditable;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.annotations.Comment;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 포탈 메뉴 항목 마스터.
|
||||
*
|
||||
* <p>PK 는 menu.yml 의 kebab-case 자연키(예: {@code service-intro}).
|
||||
* PORTAL(기본) 항목은 eapim-portal 부팅 시 menu.yml 로 upsert 되고,
|
||||
* ADMIN(커스텀) 항목은 eapim-admin 에서 등록/삭제한다.
|
||||
* DFLT_* 컬럼은 최종 yml 시딩값 스냅샷으로, 관리자 수정과의 충돌 판정과
|
||||
* "메뉴 초기화"의 원본으로 사용한다.
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Entity
|
||||
@Table(name = "PTL_MENU_ITEM")
|
||||
public class PortalMenuItem extends Auditable implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String SOURCE_PORTAL = "PORTAL";
|
||||
public static final String SOURCE_ADMIN = "ADMIN";
|
||||
public static final String SECTION_GNB = "GNB";
|
||||
public static final String SECTION_MYPAGE = "MYPAGE";
|
||||
/** EXPOSE/ACCESS_ROLES 특수값: 로그인 사용자 전체 */
|
||||
public static final String ROLE_AUTHENTICATED = "AUTHENTICATED";
|
||||
|
||||
@Id
|
||||
@Column(name = "MENU_ID", length = 100, nullable = false)
|
||||
@Comment("kebab-case 자연키 (menu.yml id)")
|
||||
private String menuId;
|
||||
|
||||
@Column(name = "MENU_NAME", length = 200, nullable = false)
|
||||
@Comment("노출명 (관리자 수정 가능)")
|
||||
private String menuName;
|
||||
|
||||
@Column(name = "MENU_PATH", length = 500)
|
||||
@Comment("이동 경로 (NULL=클릭 없는 그룹)")
|
||||
private String menuPath;
|
||||
|
||||
@Column(name = "GROUP_YN", length = 1, nullable = false)
|
||||
@Comment("그룹(상위 메뉴) 여부 - 구조 필드, menu.yml 소유")
|
||||
private String groupYn = "N";
|
||||
|
||||
@Column(name = "MENU_SECTION", length = 20, nullable = false)
|
||||
@Comment("섹션 (GNB=상단 글로벌 네비 / MYPAGE=마이페이지 드롭다운)")
|
||||
private String menuSection = SECTION_GNB;
|
||||
|
||||
@Column(name = "NEW_WINDOW_YN", length = 1, nullable = false)
|
||||
@Comment("새 창 열기 여부 (관리자 추가 외부링크용)")
|
||||
private String newWindowYn = "N";
|
||||
|
||||
@Column(name = "ICON_CLASS", length = 100)
|
||||
@Comment("아이콘 클래스 (FontAwesome, 마이페이지 드롭다운 표기)")
|
||||
private String iconClass;
|
||||
|
||||
@Column(name = "EXPOSE_ROLES", length = 500)
|
||||
@Comment("노출 권한 CSV (NULL=전체, AUTHENTICATED=로그인자, 그 외 역할코드 any-of)")
|
||||
private String exposeRoles;
|
||||
|
||||
@Column(name = "ACCESS_ROLES", length = 500)
|
||||
@Comment("접근 권한 CSV (NULL=제한 없음, 서버 인터셉터 집행)")
|
||||
private String accessRoles;
|
||||
|
||||
@Column(name = "SOURCE_TYPE", length = 10, nullable = false)
|
||||
@Comment("항목 출처 (PORTAL=menu.yml 기본 / ADMIN=관리자 커스텀)")
|
||||
private String sourceType = SOURCE_PORTAL;
|
||||
|
||||
@Column(name = "DFLT_MENU_NAME", length = 200)
|
||||
@Comment("기본 노출명 (최종 yml 시딩값)")
|
||||
private String dfltMenuName;
|
||||
|
||||
@Column(name = "DFLT_MENU_PATH", length = 500)
|
||||
@Comment("기본 경로 (최종 yml 시딩값)")
|
||||
private String dfltMenuPath;
|
||||
|
||||
@Column(name = "DFLT_EXPOSE_ROLES", length = 500)
|
||||
@Comment("기본 노출 권한 CSV (최종 yml 시딩값)")
|
||||
private String dfltExposeRoles;
|
||||
|
||||
@Column(name = "DFLT_ACCESS_ROLES", length = 500)
|
||||
@Comment("기본 접근 권한 CSV (최종 yml 시딩값)")
|
||||
private String dfltAccessRoles;
|
||||
|
||||
@Column(name = "DFLT_PARENT_ID", length = 100)
|
||||
@Comment("기본 배치 부모 (MENU_ID 논리 참조, NULL=최상위)")
|
||||
private String dfltParentId;
|
||||
|
||||
@Column(name = "DFLT_SORT_ORDER")
|
||||
@Comment("기본 배치 정렬 순서")
|
||||
private Integer dfltSortOrder;
|
||||
|
||||
@Column(name = "DFLT_VISIBLE_YN", length = 1, nullable = false)
|
||||
@Comment("기본 노출 여부")
|
||||
private String dfltVisibleYn = "Y";
|
||||
|
||||
public boolean isGroup() {
|
||||
return "Y".equals(groupYn);
|
||||
}
|
||||
|
||||
public boolean isPortalSource() {
|
||||
return SOURCE_PORTAL.equals(sourceType);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.entity.Auditable;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.annotations.Comment;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 포탈 메뉴 현재 배치.
|
||||
*
|
||||
* <p>row 없음 = 미배치. eapim-portal 부팅 시 테이블이 비어있을 때만
|
||||
* {@link PortalMenuItem} 의 DFLT_* 값으로 최초 시딩하며, 이후 배치 관리는
|
||||
* eapim-admin 이 수행한다(일괄 replace-all 저장).
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Entity
|
||||
@Table(name = "PTL_MENU_PLACEMENT")
|
||||
public class PortalMenuPlacement extends Auditable implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "MENU_ID", length = 100, nullable = false)
|
||||
@Comment("PTL_MENU_ITEM.MENU_ID 논리 참조 (항목당 배치 최대 1건)")
|
||||
private String menuId;
|
||||
|
||||
@Column(name = "PARENT_ID", length = 100)
|
||||
@Comment("부모 메뉴 ID (NULL=최상위/레인)")
|
||||
private String parentId;
|
||||
|
||||
@Column(name = "SORT_ORDER", nullable = false)
|
||||
@Comment("동일 부모 내 정렬 순서")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "VISIBLE_YN", length = 1, nullable = false)
|
||||
@Comment("노출 여부 (N=배치 유지한 채 숨김)")
|
||||
private String visibleYn = "Y";
|
||||
|
||||
public boolean isVisible() {
|
||||
return "Y".equals(visibleYn);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.entity.Auditable;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.annotations.Comment;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 포탈 역할 사전 (roles.yml 미러).
|
||||
*
|
||||
* <p>eapim-portal 부팅 시 roles.yml 로 upsert 된다. 로그인 시 권한 확장은
|
||||
* yml 바인딩을 직접 사용하며, 본 테이블은 eapim-admin 의 메뉴 권한 선택
|
||||
* UI(체크박스) 소스로만 소비된다.
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Entity
|
||||
@Table(name = "PTL_ROLE")
|
||||
public class PortalRole extends Auditable implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String TYPE_BASE = "BASE";
|
||||
public static final String TYPE_AUTHORITY = "AUTHORITY";
|
||||
|
||||
@Id
|
||||
@Column(name = "ROLE_CODE", length = 50, nullable = false)
|
||||
@Comment("역할 코드 (예: ROLE_CORP_MANAGER)")
|
||||
private String roleCode;
|
||||
|
||||
@Column(name = "ROLE_NAME", length = 200, nullable = false)
|
||||
@Comment("역할 한글명")
|
||||
private String roleName;
|
||||
|
||||
@Column(name = "ROLE_TYPE", length = 10, nullable = false)
|
||||
@Comment("역할 유형 (BASE=기본 역할 / AUTHORITY=파생 권한)")
|
||||
private String roleType;
|
||||
|
||||
@Column(name = "SORT_ORDER", nullable = false)
|
||||
@Comment("표시 정렬 순서")
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.entity.Auditable;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.persistence.EmbeddedId;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 기본 역할 → 파생 권한 매핑 (roles.yml 미러, eapim-admin 조회 전용).
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Entity
|
||||
@Table(name = "PTL_ROLE_AUTHORITY")
|
||||
public class PortalRoleAuthority extends Auditable implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@EmbeddedId
|
||||
private PortalRoleAuthorityId id;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.Comment;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Embeddable;
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Embeddable
|
||||
public class PortalRoleAuthorityId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Column(name = "ROLE_CODE", length = 50, nullable = false)
|
||||
@Comment("기본 역할 코드 (PTL_ROLE.ROLE_CODE 논리 참조)")
|
||||
private String roleCode;
|
||||
|
||||
@Column(name = "AUTHORITY_CODE", length = 50, nullable = false)
|
||||
@Comment("부여되는 파생 권한 코드")
|
||||
private String authorityCode;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.repository;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalMenuItemRepository extends BaseRepository<PortalMenuItem, String> {
|
||||
|
||||
List<PortalMenuItem> findAllBySourceType(String sourceType);
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.repository;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalMenuPlacementRepository extends BaseRepository<PortalMenuPlacement, String> {
|
||||
|
||||
List<PortalMenuPlacement> findAllByOrderByParentIdAscSortOrderAsc();
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.repository;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalRoleAuthority;
|
||||
import com.eactive.apim.portal.menu.entity.PortalRoleAuthorityId;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalRoleAuthorityRepository extends BaseRepository<PortalRoleAuthority, PortalRoleAuthorityId> {
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.repository;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalRole;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalRoleRepository extends BaseRepository<PortalRole, String> {
|
||||
|
||||
List<PortalRole> findAllByOrderBySortOrderAsc();
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.eactive.apim.portal.menu.service;
|
||||
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuItem;
|
||||
import com.eactive.apim.portal.menu.entity.PortalMenuPlacement;
|
||||
import com.eactive.apim.portal.menu.repository.PortalMenuItemRepository;
|
||||
import com.eactive.apim.portal.menu.repository.PortalMenuPlacementRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 메뉴 항목/배치 공유 데이터 서비스 (eapim-portal · eapim-admin 공용).
|
||||
*
|
||||
* <p>트랜잭션 경계: {@code PortalPropertyService} 와 동일하게 기본 @Transactional 을 사용한다.
|
||||
* eapim-admin 에서는 호출측 ManService 가 {@code @Transactional("transactionManagerForEMS")}
|
||||
* 로 감싸 EMS 트랜잭션에 참여시킨다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class PortalMenuDataService {
|
||||
|
||||
private final PortalMenuItemRepository menuItemRepository;
|
||||
private final PortalMenuPlacementRepository menuPlacementRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PortalMenuItem> loadAllItems() {
|
||||
return menuItemRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PortalMenuPlacement> loadAllPlacements() {
|
||||
return menuPlacementRepository.findAllByOrderByParentIdAscSortOrderAsc();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 배치를 통째로 교체한다(일괄 저장). 스윔레인 "저장" 버튼과
|
||||
* 메뉴 초기화가 공용으로 사용한다.
|
||||
*/
|
||||
public void replacePlacements(List<PortalMenuPlacement> placements) {
|
||||
menuPlacementRepository.deleteAllInBatch();
|
||||
menuPlacementRepository.flush();
|
||||
menuPlacementRepository.saveAll(placements);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 삭제 가드.
|
||||
* <ul>
|
||||
* <li>PORTAL(기본) 항목: 실제 삭제 금지 — 배치(placement)만 제거해 미배치로 전환</li>
|
||||
* <li>ADMIN(커스텀) 항목: 배치 제거 + 항목 하드 삭제</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return true 이면 항목까지 삭제됨(커스텀), false 이면 미배치 전환만 수행(기본)
|
||||
*/
|
||||
public boolean deleteMenu(String menuId) {
|
||||
PortalMenuItem item = menuItemRepository.findById(menuId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴: " + menuId));
|
||||
|
||||
menuPlacementRepository.findById(menuId).ifPresent(menuPlacementRepository::delete);
|
||||
|
||||
if (item.isPortalSource()) {
|
||||
log.info("기본 메뉴 항목 미배치 전환: {}", menuId);
|
||||
return false;
|
||||
}
|
||||
menuItemRepository.delete(item);
|
||||
log.info("커스텀 메뉴 항목 삭제: {}", menuId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 초기화 — eapim-portal 이 시딩한 기본값으로 되돌린다.
|
||||
* <ul>
|
||||
* <li>PORTAL 항목: 현재 필드를 DFLT_* 값으로 복원</li>
|
||||
* <li>ADMIN 항목: 전부 삭제</li>
|
||||
* <li>배치: PORTAL 항목의 DFLT_PARENT_ID/DFLT_SORT_ORDER/DFLT_VISIBLE_YN 로 재구축</li>
|
||||
* </ul>
|
||||
*/
|
||||
public void resetToDefaults() {
|
||||
List<PortalMenuItem> allItems = menuItemRepository.findAll();
|
||||
|
||||
List<PortalMenuItem> adminItems = allItems.stream()
|
||||
.filter(item -> !item.isPortalSource())
|
||||
.collect(Collectors.toList());
|
||||
menuItemRepository.deleteAll(adminItems);
|
||||
|
||||
List<PortalMenuItem> portalItems = allItems.stream()
|
||||
.filter(PortalMenuItem::isPortalSource)
|
||||
.collect(Collectors.toList());
|
||||
for (PortalMenuItem item : portalItems) {
|
||||
item.setMenuName(item.getDfltMenuName());
|
||||
item.setMenuPath(item.getDfltMenuPath());
|
||||
item.setExposeRoles(item.getDfltExposeRoles());
|
||||
item.setAccessRoles(item.getDfltAccessRoles());
|
||||
}
|
||||
menuItemRepository.saveAll(portalItems);
|
||||
|
||||
replacePlacements(buildDefaultPlacements(portalItems));
|
||||
log.info("메뉴 초기화 완료 - 기본 항목 {}건 복원, 커스텀 항목 {}건 삭제",
|
||||
portalItems.size(), adminItems.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* PORTAL 항목의 기본 위치 정보(DFLT_*)로 배치 목록을 생성한다.
|
||||
* eapim-portal 최초 시딩과 메뉴 초기화가 공용으로 사용한다.
|
||||
*/
|
||||
public List<PortalMenuPlacement> buildDefaultPlacements(List<PortalMenuItem> portalItems) {
|
||||
return portalItems.stream()
|
||||
.filter(item -> item.getDfltSortOrder() != null)
|
||||
.map(item -> {
|
||||
PortalMenuPlacement placement = new PortalMenuPlacement();
|
||||
placement.setMenuId(item.getMenuId());
|
||||
placement.setParentId(item.getDfltParentId());
|
||||
placement.setSortOrder(item.getDfltSortOrder());
|
||||
placement.setVisibleYn(item.getDfltVisibleYn());
|
||||
return placement;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.eactive.apim.portal.partnership.event;
|
||||
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageEventHandler;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import com.eactive.apim.portal.template.service.MessageSendEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class PartnershipCreatedEvent implements MessageEventHandler {
|
||||
|
||||
public static final MessageCode KEY = MessageCode.PARTNERSHIP_CREATED;
|
||||
|
||||
@Override
|
||||
public boolean allowAdditionalRecipients() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
|
||||
Map<String, String> requestParams = new HashMap<>();
|
||||
String subject = toStr(params.get("bizSubject"));
|
||||
if (subject.isEmpty()) {
|
||||
subject = toStr(params.get("subject"));
|
||||
}
|
||||
requestParams.put("partnershipId", toStr(params.get("partnershipId")));
|
||||
requestParams.put("bizSubject", subject);
|
||||
requestParams.put("subject", subject);
|
||||
requestParams.put("writerName", toStr(params.get("writerName")));
|
||||
return new MessageSendEvent(source, KEY, recipient, requestParams);
|
||||
}
|
||||
|
||||
private String toStr(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageCode getKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "개선요청/제휴 게시물 등록";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "<div>사용 가능 변수: </div><br/>"
|
||||
+ "<div> - userName: 수신자 이름 </div><br/>"
|
||||
+ "<div> - userId: 수신자 ID (이메일) </div><br/>"
|
||||
+ "<div> - partnershipId: 신청 ID </div><br/>"
|
||||
+ "<div> - bizSubject: 신청 제목 </div><br/>"
|
||||
+ "<div> - subject: 신청 제목 (별칭) </div><br/>"
|
||||
+ "<div> - writerName: 작성자 이름 </div><br/>";
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,5 @@ public class PortalOrg extends Auditable implements Serializable, com.eactive.ea
|
||||
this.orgSectors = null;
|
||||
this.orgIndustryType = null;
|
||||
this.serviceName = null;
|
||||
this.orgDesc = null;
|
||||
this.ipWhitelist = null;
|
||||
this.reverseProxyPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
-15
@@ -4,23 +4,8 @@ import com.eactive.apim.portal.portalproperty.entity.PortalProperty;
|
||||
import com.eactive.apim.portal.portalproperty.entity.PortalPropertyId;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalPropertyRepository extends BaseRepository<PortalProperty, PortalPropertyId> {
|
||||
|
||||
/**
|
||||
* (PROPERTY_GROUP_NAME, PROPERTY_NAME) 조합이 2건 이상인 중복 키를 조회한다.
|
||||
* DB에 유니크 제약을 걸지 않으므로 기동 시 무결성 점검용으로 사용한다.
|
||||
*
|
||||
* @return {@code Object[]{propertyGroupName, propertyName, count}} 목록 (중복 없으면 빈 리스트)
|
||||
*/
|
||||
@Query("SELECT p.id.propertyGroupName, p.id.propertyName, COUNT(p) "
|
||||
+ "FROM PortalProperty p "
|
||||
+ "GROUP BY p.id.propertyGroupName, p.id.propertyName "
|
||||
+ "HAVING COUNT(p) > 1")
|
||||
List<Object[]> findDuplicatePropertyKeys();
|
||||
|
||||
}
|
||||
|
||||
-1
@@ -30,7 +30,6 @@ public class PortalPropertyService extends AbstractDataService<PortalPropertyGro
|
||||
.orElse(new PortalPropertyGroup());
|
||||
|
||||
return portalPropertyGroup.getPortalProperties().stream()
|
||||
.filter(portalProperty -> portalProperty.getPropertyValue() != null)
|
||||
.collect(Collectors.toMap(
|
||||
portalProperty -> portalProperty.getId().getPropertyName(),
|
||||
PortalProperty::getPropertyValue,
|
||||
|
||||
@@ -88,11 +88,7 @@ public class PortalUser extends Auditable implements Serializable, com.eactive.e
|
||||
@Comment("로그인실패횟수")
|
||||
private Integer loginFailureCount;
|
||||
|
||||
// PASSWORD_CHANGE_DATE 컬럼은 VARCHAR2(14) yyyyMMddHHmmss 문자열이다.
|
||||
// LocalDateTime 을 그대로 바인딩하면 27자로 변환되어 ORA-12899(값 초과)가 발생하므로
|
||||
// 14자 문자열로 변환하는 컨버터를 적용한다.
|
||||
@Column(name = "password_change_date", length = 14)
|
||||
@Convert(converter = com.eactive.eai.data.converter.LocalDateTimeToStringConverter14.class)
|
||||
@Column(name = "password_change_date")
|
||||
@Comment("비밀번호변경일시")
|
||||
private LocalDateTime passwordChangeDate;
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ public class TwoFactorAuth {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", length = 36, nullable = false)
|
||||
// UUID v7(타임스탬프 기반, 시간순 정렬) — PK 인덱스 삽입 지역성 개선. 기존 v4 행과 컬럼 호환.
|
||||
@GenericGenerator(name = "uuid-gen", strategy = "com.eactive.apim.portal.jpa.UuidV7Generator")
|
||||
@GenericGenerator(name = "uuid-gen", strategy = "uuid2")
|
||||
@GeneratedValue(generator = "uuid-gen", strategy = GenerationType.IDENTITY)
|
||||
private String id;
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.eactive.apim.portal.portaluser.entity;
|
||||
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
|
||||
import lombok.Data;
|
||||
import org.hibernate.annotations.Comment;
|
||||
import org.hibernate.annotations.Parameter;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 역할(권한) 변경 감사 이력.
|
||||
*
|
||||
* <p>법인 관리자 위임/회수, 소속 제외 등으로 {@code PortalUser.roleCode} 가 변경될 때
|
||||
* 이전 역할 → 새 역할, 변경 유형, 변경 수행자(관리자)를 이력으로 남긴다.
|
||||
* {@link UserPasswordHistory} 와 동일한 저장 패턴(시퀀스 PK, 14자리 문자열 일시)을 따른다.</p>
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "ptl_user_role_history")
|
||||
public class UserRoleHistory implements Serializable, com.eactive.eai.data.Data {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GenericGenerator(
|
||||
name = "ptl_user_role_history_seq_gen",
|
||||
strategy = "com.eactive.eai.rms.data.jpa.SchemaPrefixedSequenceGenerator",
|
||||
parameters = {
|
||||
@Parameter(name = "sequence_name", value = "PTL_USER_ROLE_HISTORY_SEQ"),
|
||||
@Parameter(name = "initial_value", value = "1"),
|
||||
@Parameter(name = "increment_size", value = "1")
|
||||
}
|
||||
)
|
||||
@GeneratedValue(
|
||||
strategy = GenerationType.SEQUENCE,
|
||||
generator = "ptl_user_role_history_seq_gen"
|
||||
)
|
||||
@Column(name = "id", columnDefinition = "number(19)")
|
||||
private Long id;
|
||||
|
||||
@Convert(converter = PersonalDataEncryptConverter.class)
|
||||
@Column(name = "user_id", length = 255, nullable = false)
|
||||
@Comment("대상 사용자 ID(loginId)")
|
||||
private String userId;
|
||||
|
||||
@Column(name = "before_role", length = 30)
|
||||
@Comment("변경 전 역할")
|
||||
private String beforeRole;
|
||||
|
||||
@Column(name = "after_role", length = 30, nullable = false)
|
||||
@Comment("변경 후 역할")
|
||||
private String afterRole;
|
||||
|
||||
@Column(name = "change_type", length = 30, nullable = false)
|
||||
@Comment("변경 유형(MANAGER_ASSIGN/MANAGER_REVOKE/ORG_REMOVE 등)")
|
||||
private String changeType;
|
||||
|
||||
@Convert(converter = PersonalDataEncryptConverter.class)
|
||||
@Column(name = "changed_by", length = 255, nullable = false)
|
||||
@Comment("변경 수행자(loginId)")
|
||||
private String changedBy;
|
||||
|
||||
@Column(name = "change_date", length = 14, nullable = false)
|
||||
@Comment("변경 일시")
|
||||
@Convert(converter = LocalDateTimeToStringConverter14.class)
|
||||
private LocalDateTime changeDate;
|
||||
|
||||
@Convert(converter = PersonalDataEncryptConverter.class)
|
||||
@Column(name = "created_by", length = 255, nullable = false)
|
||||
@Comment("생성자")
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "created_date", length = 14, nullable = false)
|
||||
@Comment("생성일시")
|
||||
@Convert(converter = LocalDateTimeToStringConverter14.class)
|
||||
private LocalDateTime createdDate;
|
||||
}
|
||||
-5
@@ -3,7 +3,6 @@ package com.eactive.apim.portal.portaluser.repository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserPrivacyAgreement;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -16,8 +15,4 @@ public interface PortalUserPrivacyAgreementRepository extends BaseRepository<Por
|
||||
List<PortalUserPrivacyAgreement> findAllByCreatedBy(String createdBy);
|
||||
|
||||
// boolean existsByCreatedByAndTermsTypeAndTermsVersionGreaterThanEqual(String userId, String termsType, int latestVersion);
|
||||
|
||||
/** createdBy = PortalUser.id. */
|
||||
@Transactional
|
||||
long deleteByCreatedBy(String createdBy);
|
||||
}
|
||||
@@ -40,8 +40,6 @@ public interface PortalUserRepository extends BaseRepository<PortalUser, String>
|
||||
|
||||
long countByPortalOrg(PortalOrg portalOrg);
|
||||
|
||||
List<PortalUser> findAllByPortalOrg_Id(String orgId);
|
||||
|
||||
Optional<PortalUser> findByUserName(String userName);
|
||||
|
||||
// 암복
|
||||
|
||||
-10
@@ -2,9 +2,7 @@ package com.eactive.apim.portal.portaluser.repository;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface TwoFactorAuthRepository extends JpaRepository<TwoFactorAuth, String> {
|
||||
@@ -14,14 +12,6 @@ public interface TwoFactorAuthRepository extends JpaRepository<TwoFactorAuth, St
|
||||
|
||||
void deleteAllByRecipientKey(String recipientKey);
|
||||
|
||||
/**
|
||||
* 만료된 인증번호 레코드를 일괄 삭제한다. (정리 스케줄러용)
|
||||
* expires_on 은 LocalDateTimeToStringConverter14(yyyyMMddHHmmss) 로 저장되며,
|
||||
* 해당 포맷은 사전식 정렬이 시간순과 일치하므로 문자열 < 비교가 곧 시간 비교다.
|
||||
*/
|
||||
@Modifying
|
||||
int deleteAllByExpiresAtBefore(LocalDateTime threshold);
|
||||
|
||||
// 아래 두 메서드는 recipient(암호화 컬럼) 기반이라 신뢰할 수 없음 — 신규 코드에서는 RecipientKey 버전을 사용할 것.
|
||||
@Deprecated
|
||||
Optional<TwoFactorAuth> findByRecipient(String recipientKey);
|
||||
|
||||
-5
@@ -5,7 +5,6 @@ import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -16,8 +15,4 @@ public interface UserPasswordHistoryRepository extends JpaRepository<UserPasswor
|
||||
List<UserPasswordHistory> findRecentPasswordsByUserId(@Param("loginId") String loginId);
|
||||
|
||||
Optional<UserPasswordHistory> findTopByUserIdOrderByChangeDateDesc(String loginId);
|
||||
|
||||
/** userId = PortalUser.id (loginId 아님 — UserRoleHistory.userId 와 반대 시맨틱). */
|
||||
@Transactional
|
||||
long deleteByUserId(String userId);
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.eactive.apim.portal.portaluser.repository;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.entity.UserRoleHistory;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@EMSDataSource
|
||||
public interface UserRoleHistoryRepository extends JpaRepository<UserRoleHistory, Long> {
|
||||
|
||||
List<UserRoleHistory> findByUserIdOrderByChangeDateDesc(String userId);
|
||||
|
||||
/** userId = PortalUser.loginId (id 아님 — UserPasswordHistory.userId 와 반대 시맨틱). */
|
||||
@Transactional
|
||||
long deleteByUserId(String userId);
|
||||
}
|
||||
@@ -1,37 +1,7 @@
|
||||
package com.eactive.apim.portal.portaluser.service;
|
||||
|
||||
/**
|
||||
* 인증번호(2FA/이메일/SMS) 검증·발송 실패 예외.
|
||||
*
|
||||
* <p>기존에는 메시지 문자열만 있었으나, 감사 로그에서 실패 사유(만료/불일치/미존재)를
|
||||
* 코드로 구분해야 하므로 {@link Reason} 을 추가했다. 기존 메시지 전용 생성자는 유지되며
|
||||
* 이 경우 {@code reason == null} 이다(하위호환).</p>
|
||||
*/
|
||||
public class AuthNumberException extends RuntimeException{
|
||||
|
||||
/** 실패 사유 분류 */
|
||||
public enum Reason {
|
||||
/** 저장된 인증번호가 없음(미발송/이미 소비/스케줄러 정리) */
|
||||
NOT_FOUND,
|
||||
/** 유효시간 초과 */
|
||||
EXPIRED,
|
||||
/** 인증번호 불일치 */
|
||||
MISMATCH
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public AuthNumberException(String message) {
|
||||
super(message);
|
||||
this.reason = null;
|
||||
}
|
||||
|
||||
public AuthNumberException(String message, Reason reason) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Reason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,23 +86,4 @@ public class Inquiry extends Auditable {
|
||||
|
||||
@Column(name = "ATTACH_FILE")
|
||||
private String attachFile;
|
||||
|
||||
/**
|
||||
* 공개범위 (전체공개/법인공개/비공개). 기본값 ORG.
|
||||
* PTL_PROPERTY 기관 기본값이 상한(ceiling)이며 읽기/쓰기 시 clamp 된다.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "VISIBILITY", length = 16)
|
||||
private VisibilityScope visibility = VisibilityScope.ORG;
|
||||
|
||||
/**
|
||||
* 조회수. sessionStorage 기반 dedup 후 POST 요청으로만 증가한다.
|
||||
*/
|
||||
@Column(name = "VIEW_COUNT")
|
||||
private long viewCount = 0L;
|
||||
|
||||
/** 조회수 1 증가. */
|
||||
public void increaseViewCount() {
|
||||
this.viewCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,6 @@ public class InquiryComment extends Auditable implements Serializable {
|
||||
@Column(name = "ADMIN_YN", length = 1)
|
||||
private String adminYn = "N";
|
||||
|
||||
/**
|
||||
* 댓글 공개범위. 개념상 공개(ALL)/비공개(PRIVATE) 2단계.
|
||||
* 비공개 댓글은 작성자·같은 법인 corp-manager·관리자만 열람 가능.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "VISIBILITY", length = 16)
|
||||
private VisibilityScope visibility = VisibilityScope.ALL;
|
||||
|
||||
public boolean isPrivate() {
|
||||
return this.visibility == VisibilityScope.PRIVATE;
|
||||
}
|
||||
|
||||
public void markDeleted() {
|
||||
this.delYn = "Y";
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.eactive.apim.portal.qna.entity;
|
||||
|
||||
/**
|
||||
* 문의(Q&A) 게시물/댓글의 공개범위.
|
||||
*
|
||||
* <p>{@code width}는 공개 범위의 넓이 랭크로, 값이 클수록 더 넓게 공개된다.
|
||||
* {@code PTL_PROPERTY}에 설정된 기관 기본값이 상한(ceiling)이 되며, 게시물이 상한보다
|
||||
* 넓게 설정될 수 없다({@link #clampTo}). property가 항상 우선한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code ALL} : 전체 로그인 사용자 공개</li>
|
||||
* <li>{@code ORG} : 같은 법인 소속 사용자 공개 (게시물 기본값)</li>
|
||||
* <li>{@code PRIVATE} : 작성자 본인만 (댓글의 "비공개")</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum VisibilityScope {
|
||||
|
||||
ALL(3, "전체공개"),
|
||||
ORG(2, "법인공개"),
|
||||
PRIVATE(1, "비공개");
|
||||
|
||||
private final int width;
|
||||
private final String label;
|
||||
|
||||
VisibilityScope(int width, String label) {
|
||||
this.width = width;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** 공개 범위 넓이 랭크. 클수록 넓게 공개. */
|
||||
public int width() {
|
||||
return width;
|
||||
}
|
||||
|
||||
/** 화면 표기용 한글 라벨. */
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 enum으로 안전 변환. null/빈값/미해당은 defaultScope 반환.
|
||||
*/
|
||||
public static VisibilityScope fromString(String value, VisibilityScope defaultScope) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return defaultScope;
|
||||
}
|
||||
try {
|
||||
return VisibilityScope.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return defaultScope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 공개범위를 상한으로 clamp 한다. 요청이 상한보다 넓으면 상한으로 낮춘다.
|
||||
* (property가 항상 우선하므로, 저장값이 옛 상한이라 더 넓더라도 읽기 시 재-clamp 한다.)
|
||||
*/
|
||||
public static VisibilityScope clampTo(VisibilityScope requested, VisibilityScope ceiling) {
|
||||
if (requested == null) {
|
||||
return ceiling;
|
||||
}
|
||||
if (ceiling == null) {
|
||||
return requested;
|
||||
}
|
||||
return requested.width() > ceiling.width() ? ceiling : requested;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.eactive.apim.portal.qna.event;
|
||||
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageEventHandler;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import com.eactive.apim.portal.template.service.MessageSendEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class InquiryCommentCreatedEvent implements MessageEventHandler {
|
||||
|
||||
public static final MessageCode KEY = MessageCode.INQUIRY_COMMENT_CREATED;
|
||||
|
||||
@Override
|
||||
public MessageCode getKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allowAdditionalRecipients() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "Q&A 댓글 등록 알림";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "<div>사용 가능 변수: </div><br/>"
|
||||
+ "<div> - userName: 수신자 이름 </div><br/>"
|
||||
+ "<div> - userId: 수신자 ID </div><br/>"
|
||||
+ "<div> - inquiryId: 문의 ID </div><br/>"
|
||||
+ "<div> - inquirySubject: 문의 제목 </div><br/>"
|
||||
+ "<div> - commentContent: 댓글 본문 </div><br/>"
|
||||
+ "<div> - writerName: 작성자 이름 </div><br/>";
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
|
||||
Map<String, String> requestParams = new HashMap<>();
|
||||
requestParams.put("inquiryId", toStr(params.get("inquiryId")));
|
||||
requestParams.put("inquirySubject", toStr(params.get("inquirySubject")));
|
||||
requestParams.put("commentContent", toStr(params.get("commentContent")));
|
||||
requestParams.put("writerName", toStr(params.get("writerName")));
|
||||
return new MessageSendEvent(source, KEY, recipient, requestParams);
|
||||
}
|
||||
|
||||
private String toStr(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.eactive.apim.portal.qna.event;
|
||||
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageEventHandler;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import com.eactive.apim.portal.template.service.MessageSendEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class InquiryCreatedEvent implements MessageEventHandler {
|
||||
// public static final String KEY = "inquiry_created";
|
||||
public static final MessageCode KEY = MessageCode.INQUIRY_CREATED;
|
||||
|
||||
|
||||
@Override
|
||||
public boolean allowAdditionalRecipients() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
|
||||
Map<String, String> requestParams = new HashMap<>();
|
||||
String subject = toStr(params.get("inquirySubject"));
|
||||
if (subject.isEmpty()) {
|
||||
subject = toStr(params.get("subject"));
|
||||
}
|
||||
requestParams.put("inquiryId", toStr(params.get("inquiryId")));
|
||||
requestParams.put("inquirySubject", subject);
|
||||
requestParams.put("subject", subject);
|
||||
requestParams.put("writerName", toStr(params.get("writerName")));
|
||||
return new MessageSendEvent(source, KEY, recipient, requestParams);
|
||||
}
|
||||
|
||||
private String toStr(Object value) {
|
||||
return value == null ? "" : value.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageCode getKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "QnA 질문 등록";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "<div>사용 가능 변수: </div><br/>"
|
||||
+ "<div> - userName: 수신자 이름 </div><br/>"
|
||||
+ "<div> - userId: 수신자 ID (이메일) </div><br/>"
|
||||
+ "<div> - inquiryId: 질문 ID </div><br/>"
|
||||
+ "<div> - inquirySubject: 질문 제목 </div><br/>"
|
||||
+ "<div> - subject: 질문 제목 (별칭) </div><br/>"
|
||||
+ "<div> - writerName: 작성자 이름 </div><br/>";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,22 +11,23 @@ public enum MessageCode {
|
||||
|
||||
// 사용자 이벤트 영역
|
||||
USER_VERIFICATION_EMAIL("이메일 주소 인증", null, Channels.EMAIL),
|
||||
USER_VERIFICATION_MOBILEPHONE("휴대폰 번호 인증", "JJB_85959019001", Channels.SMS),
|
||||
USER_VERIFICATION_MOBILEPHONE("휴대폰 번호 인증", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
USER_PASSWORD_CHANGED("사용자 비밀번호 변경", "JJB_85959019003", Channels.SMS),
|
||||
USER_PASSWORD_RESET("비밀번호 재설정", null, Channels.KAKAO_ALIMTALK),
|
||||
USER_PASSWORD_CHANGED("사용자 비밀번호 변경", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
USER_ACCOUNT_LOCKED("계정 잠금 알림", "JJB_85959019002", Channels.SMS),
|
||||
USER_ACCOUNT_LOCKED("계정 잠금 알림", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
// 법인관리자 이벤트 영역
|
||||
USER_INVITATION("법인 이용자 이메일 초대", "JJB_85959019006", Channels.SMS),
|
||||
USER_INVITATION_CANCELED("법인 이용자 이메일 초대 취소", "JJB_85959019007", Channels.SMS),
|
||||
USER_INVITATION("법인 이용자 이메일 초대", null, Channels.KAKAO_ALIMTALK),
|
||||
USER_INVITATION_CANCELED("법인 이용자 이메일 초대 취소", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
MANAGER_WITH_ORG_REGISTER_APPROVED("법인관리자 및 법인 등록 승인", "JJB_85959019004", Channels.SMS),
|
||||
MANAGER_WITH_ORG_REGISTER_REJECTED("법인관리자 및 법인 등록 거절", "JJB_85959019005", Channels.SMS),
|
||||
MANAGER_WITH_ORG_REGISTER_APPROVED("법인관리자 및 법인 등록 승인", null, Channels.KAKAO_ALIMTALK),
|
||||
MANAGER_WITH_ORG_REGISTER_REJECTED("법인관리자 및 법인 등록 거절", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
// 관리자 영역
|
||||
APP_APPROVE("앱 사용 승인", "JJB_85959019008", Channels.SMS),
|
||||
APP_REJECTED("앱 사용 거절", "JJB_85959019009", Channels.SMS),
|
||||
APP_APPROVE("앱 사용 승인", null, Channels.KAKAO_ALIMTALK),
|
||||
APP_REJECTED("앱 사용 거절", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
// Q&A 등록/댓글 등록 알림 (DJBank 커스텀 — portal-admin 수신)
|
||||
INQUIRY_CREATED("Q&A 등록 알림", null, Channels.SWING),
|
||||
@@ -36,10 +37,9 @@ public enum MessageCode {
|
||||
// 제주은행
|
||||
API_STATUS_CHANGED("API 상태 변화", null, Channels.SWING),
|
||||
INFLOW_TOKEN_FAILED("유량제어 토큰 획득 실패", null, Channels.SWING),
|
||||
ADMIN_VERIFICATION_MOBILEPHONE("관리자포탈 휴대폰 번호 인증", "JJB_85999019001", Channels.SMS),
|
||||
ADMIN_VERIFICATION_MOBILEPHONE("관리자포탈 휴대폰 번호 인증", null, Channels.KAKAO_ALIMTALK),
|
||||
|
||||
// 레거시/미사용 — Event 핸들러 클래스 호환을 위해 유지 (실제 발송 없음)
|
||||
USER_PASSWORD_RESET("비밀번호 재설정", null, Channels.SMS),
|
||||
API_APPROVED("API 승인 알림", null, Channels.NONE),
|
||||
APP_REGISTER_REQUEST("앱 등록 요청", null, Channels.NONE),
|
||||
APP_REGISTER_REJECTED("앱 등록 거절", null, Channels.NONE),
|
||||
@@ -89,7 +89,7 @@ public enum MessageCode {
|
||||
public static enum Channels {
|
||||
NONE (0b0000),
|
||||
EMAIL (0b0001),
|
||||
SMS (0b0010),
|
||||
KAKAO_ALIMTALK (0b0010),
|
||||
SWING (0b0100),
|
||||
;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.template.entity;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
|
||||
@@ -79,23 +78,9 @@ public class MessageRequest implements Serializable {
|
||||
@Column(name = "request_status")
|
||||
private String requestStatus = "PENDING"; // PENDING, SENT, FAILED
|
||||
|
||||
@Lob
|
||||
@Column(name = "response_data")
|
||||
private String responseData;
|
||||
|
||||
@Column(name = "eai_interface_id")
|
||||
private String eaiInterfaceId;
|
||||
|
||||
@Column(name = "eai_tx_id")
|
||||
private String eaiTxId;
|
||||
|
||||
@Column(name = "service_id")
|
||||
private String serviceId;
|
||||
|
||||
/** 저장 시 전화번호를 하이픈 구분 정규형으로 통일 */
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
private void normalizePhones() {
|
||||
this.phone = PhoneNumberUtil.normalize(this.phone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,24 +37,22 @@ public class MessageSendService {
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
@Transactional
|
||||
public List<MessageRequest> sendMessage(MessageCode messageCode, MessageRecipient recipient, Map<String, String> messageParams) {
|
||||
return processMessage(messageCode, recipient, messageParams);
|
||||
public void sendMessage(MessageCode messageCode, MessageRecipient recipient, Map<String, String> messageParams) {
|
||||
processMessage(messageCode.name(), recipient, messageParams);
|
||||
}
|
||||
|
||||
private List<MessageRequest> processMessage(MessageCode messageCode, MessageRecipient recipient, Map<String, String> messageParams) {
|
||||
private void processMessage(String messageCode, MessageRecipient recipient, Map<String, String> messageParams) {
|
||||
|
||||
Optional<MessageTemplate> template = messageTemplateRepository.findById(messageCode.name().toUpperCase());
|
||||
Optional<MessageTemplate> template = messageTemplateRepository.findById(messageCode.toUpperCase());
|
||||
|
||||
List<MessageRequest> created = new ArrayList<>();
|
||||
if (template.isPresent()) {
|
||||
List<MessageRecipient> recipients = prepareRecipients(template.get(), recipient);
|
||||
for (MessageRecipient user : recipients) {
|
||||
created.addAll(processSingleRecipient(user, template.get(), messageParams, messageCode));
|
||||
processSingleRecipient(user, template.get(), messageParams);
|
||||
}
|
||||
} else {
|
||||
log.warn("메세지 템플릿이 존재 하지 않음 - {}", messageCode.name());
|
||||
log.warn("메세지 템플릿이 존재 하지 않음 - {}", messageCode);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private List<MessageRecipient> prepareRecipients(MessageTemplate template, MessageRecipient recipient) {
|
||||
@@ -72,18 +70,15 @@ public class MessageSendService {
|
||||
}
|
||||
|
||||
|
||||
public List<MessageRequest> processSingleRecipient(MessageRecipient user, MessageTemplate template, Map<String, String> messageParams, MessageCode messageCode) {
|
||||
public void processSingleRecipient(MessageRecipient user, MessageTemplate template, Map<String, String> messageParams) {
|
||||
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap("Portal");
|
||||
|
||||
updateMessageParams(user, messageParams);
|
||||
|
||||
String subject = buildMessage(template.getSubjectTemplate(), messageParams);
|
||||
|
||||
List<MessageRequest> created = new ArrayList<>();
|
||||
|
||||
if (template.getEnableSms().equalsIgnoreCase("Y")) {
|
||||
String smsEaiInterfaceId = portalProperties.get("djb.ums.sms.if_id");
|
||||
String smsEaiTxId = portalProperties.get("djb.ums.sms.tx_id");
|
||||
String smsEaiInterfaceId = portalProperties.get("ums.sms.if_id");
|
||||
MessageRequest smsRequest = new MessageRequest();
|
||||
smsRequest.setMessageCode(MessageCode.valueOf(template.getMessageCode().toUpperCase()));
|
||||
smsRequest.setSubject(subject);
|
||||
@@ -93,37 +88,37 @@ public class MessageSendService {
|
||||
smsRequest.setUsername(user.getUsername());
|
||||
smsRequest.setPhone(user.getPhone());
|
||||
smsRequest.setEaiInterfaceId(smsEaiInterfaceId);
|
||||
smsRequest.setEaiTxId(smsEaiTxId);
|
||||
smsRequest.setServiceId(messageCode.getServiceId());
|
||||
smsRequest.setServiceId(portalProperties.get("ums.sms.tx_id"));
|
||||
smsRequest.setUserId(user.getUserId());
|
||||
if ("ADMIN_VERIFICATION_MOBILEPHONE".equals(template.getMessageCode().toUpperCase())) {
|
||||
smsRequest.setMessageType("KAKAO");
|
||||
} else {
|
||||
smsRequest.setMessageType("SMS");
|
||||
created.add(messageRequestRepository.save(smsRequest));
|
||||
}
|
||||
messageRequestRepository.save(smsRequest);
|
||||
logger.debug(smsRequest.toString());
|
||||
}
|
||||
|
||||
if (template.getEnableEmail().equalsIgnoreCase("Y")) {
|
||||
String mailEaiInterfaceId = portalProperties.get("djb.ums.email.if_id");
|
||||
String mailEaiTxId = portalProperties.get("djb.ums.email.tx_id");
|
||||
String mailEaiInterfaceId = portalProperties.get("ums.email.if_id");
|
||||
MessageRequest emailRequest = new MessageRequest();
|
||||
emailRequest.setMessageCode(MessageCode.valueOf(template.getMessageCode().toUpperCase()));
|
||||
emailRequest.setSubject(subject);
|
||||
String emailMessage = buildMessage(template.getEmailTemplate(), messageParams);
|
||||
emailRequest.setMessage(emailMessage);
|
||||
emailRequest.setRequestDate(LocalDateTime.now());
|
||||
emailRequest.setUsername(resolveEmailUsername(user));
|
||||
emailRequest.setUsername(user.getUsername());
|
||||
emailRequest.setEmail(user.getUserId());
|
||||
emailRequest.setEaiInterfaceId(mailEaiInterfaceId);
|
||||
emailRequest.setEaiTxId(mailEaiTxId);
|
||||
emailRequest.setServiceId(messageCode.getServiceId());
|
||||
emailRequest.setServiceId(portalProperties.get("ums.email.tx_id"));
|
||||
emailRequest.setMessageType("EMAIL");
|
||||
emailRequest.setUserId(user.getUserId());
|
||||
created.add(messageRequestRepository.save(emailRequest));
|
||||
messageRequestRepository.save(emailRequest);
|
||||
logger.debug(emailRequest.toString());
|
||||
}
|
||||
|
||||
if (template.getEnableMessenger().equalsIgnoreCase("Y")) {
|
||||
String swingEaiInterfaceId = portalProperties.get("djb.ums.messenger.if_id");
|
||||
String swingEaiTxId = portalProperties.get("djb.ums.messenger.tx_id");
|
||||
String swingEaiInterfaceId = portalProperties.get("ums.messenger.if_id");
|
||||
MessageRequest messengerRequest = new MessageRequest();
|
||||
messengerRequest.setMessageCode(MessageCode.valueOf(template.getMessageCode()));
|
||||
messengerRequest.setSubject(subject);
|
||||
@@ -131,33 +126,14 @@ public class MessageSendService {
|
||||
messengerRequest.setMessage(messengerTemplate);
|
||||
messengerRequest.setRequestDate(LocalDateTime.now());
|
||||
messengerRequest.setUsername(user.getUsername());
|
||||
messengerRequest.setMessengerId(user.getMessengerId());
|
||||
messengerRequest.setMessengerId(user.getUserId());
|
||||
messengerRequest.setEaiInterfaceId(swingEaiInterfaceId);
|
||||
messengerRequest.setEaiTxId(swingEaiTxId);
|
||||
messengerRequest.setServiceId(messageCode.getServiceId());
|
||||
messengerRequest.setServiceId(portalProperties.get("ums.messenger.tx_id"));
|
||||
messengerRequest.setMessageType("MESSENGER");
|
||||
messengerRequest.setUserId(user.getUserId());
|
||||
created.add(messageRequestRepository.save(messengerRequest));
|
||||
messageRequestRepository.save(messengerRequest);
|
||||
logger.debug(messengerRequest.toString());
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* PTL_MESSAGE_REQUEST.USERNAME 에 넣을 이메일 수신자명을 결정한다.
|
||||
* 이메일 발송은 수신 주소(userId)의 '@' 앞 아이디 부분을 사용하고,
|
||||
* 주소 형식이 아니면 기존 수신자 이름을 그대로 쓴다.
|
||||
*/
|
||||
private String resolveEmailUsername(MessageRecipient user) {
|
||||
String address = user.getUserId();
|
||||
if (StringUtils.hasText(address)) {
|
||||
int atIndex = address.indexOf('@');
|
||||
if (atIndex > 0) {
|
||||
return address.substring(0, atIndex);
|
||||
}
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private void updateMessageParams(MessageRecipient user, Map<String, String> messageParams) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import lombok.NonNull;
|
||||
import org.hibernate.annotations.Cache;
|
||||
import org.hibernate.annotations.CacheConcurrencyStrategy;
|
||||
import org.hibernate.annotations.Comment;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
|
||||
import javax.persistence.*;
|
||||
@@ -74,12 +73,6 @@ public class UserInfo extends AbstractEntity<String> implements Serializable {
|
||||
@LastModifiedDate
|
||||
private LocalDateTime lastamndyms;
|
||||
|
||||
@Column(length = 14)
|
||||
@Comment("등록 일시")
|
||||
@Convert(converter = LocalDateTimeToStringConverter14.class)
|
||||
@CreatedDate
|
||||
private LocalDateTime regdyms;
|
||||
|
||||
@Column(length = 80)
|
||||
@Comment("부서명")
|
||||
private String dvsnname;
|
||||
@@ -143,10 +136,6 @@ public class UserInfo extends AbstractEntity<String> implements Serializable {
|
||||
@Comment("사용자 계정 상태")
|
||||
private String status;
|
||||
|
||||
@Column
|
||||
@Comment("로그인 실패 횟수")
|
||||
private Integer loginfailcount;
|
||||
|
||||
@Override
|
||||
public @NonNull String getId() {
|
||||
return userid;
|
||||
|
||||
@@ -45,27 +45,4 @@ public class UserLog {
|
||||
@Column(name = "success", nullable = false)
|
||||
private boolean success;
|
||||
|
||||
/** 실패 사유 코드 (LoginFailureReason). 성공 행은 null */
|
||||
@Column(name = "failure_reason", length = 64)
|
||||
private String failureReason;
|
||||
|
||||
/** 로그인 유형 코드 (LoginType: NORMAL/TWO_FACTOR/SIGNUP_AUTO). 실패 행·과거 행은 null */
|
||||
@Column(name = "login_type", length = 32)
|
||||
private String loginType;
|
||||
|
||||
/**
|
||||
* 요청 User-Agent 헤더 원문. 컬럼 길이(500)를 넘기면 ORA-12899 로 로그인 자체가 실패하므로
|
||||
* 저장 측({@code PortalUserLogService})에서 잘라 넣는다. 헤더 미전송 요청은 null.
|
||||
*/
|
||||
@Column(name = "user_agent", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 중복 로그인 여부 — 로그인 시점에 다른 기기/브라우저의 활성 세션이 남아 있어
|
||||
* 기존 세션이 강제 해제된 건이면 true. 실패 행·과거 행은 null.
|
||||
* <p>primitive 가 아닌 래퍼 타입인 이유: 기존 행은 NULL 이라 primitive 로 읽으면 매핑에서 깨진다.
|
||||
*/
|
||||
@Column(name = "duplicate")
|
||||
private Boolean duplicate;
|
||||
|
||||
}
|
||||
|
||||
@@ -4,13 +4,9 @@ import com.eactive.apim.portal.user.entity.UserLog;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public interface UserLogRepository extends JpaRepository<UserLog, Long> {
|
||||
|
||||
Optional<UserLog> findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(@Param("loginId") String loginId,@Param("success") boolean success);
|
||||
|
||||
@Transactional
|
||||
long deleteByLoginId(String loginId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class IpAddressMatcherTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("정확 일치 - IPv4/IPv6")
|
||||
void exactMatch() {
|
||||
assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "127.0.0.1"));
|
||||
assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "::1"));
|
||||
assertTrue(IpAddressMatcher.matches("127.0.0.1,::1", "0:0:0:0:0:0:0:1"));
|
||||
assertFalse(IpAddressMatcher.matches("127.0.0.1", "127.0.0.2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정확 일치 항목은 IP 유효성 검증 없이 문자열 비교 (IPv6 임의 표기 허용)")
|
||||
void exactMatchIsLiteral() {
|
||||
assertTrue(IpAddressMatcher.matches("fe80::abcd", "fe80::abcd"));
|
||||
// 형식이 깨진 항목은 실제 소켓 IP 와 같아질 수 없어 사실상 무효 규칙이 된다
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.256", "172.30.1.5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IPv4 CIDR - 경계 포함/제외")
|
||||
void cidrMatch() {
|
||||
assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.0"));
|
||||
assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.255"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "172.30.2.1"));
|
||||
|
||||
assertTrue(IpAddressMatcher.matches("172.30.0.0/16", "172.30.99.5"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.0.0/16", "172.31.0.1"));
|
||||
|
||||
// /32 는 단일 호스트, /0 은 전체
|
||||
assertTrue(IpAddressMatcher.matches("172.30.1.5/32", "172.30.1.5"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.5/32", "172.30.1.6"));
|
||||
assertTrue(IpAddressMatcher.matches("0.0.0.0/0", "8.8.8.8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IPv4 와일드카드 - 옥텟 단위만, 접두 오인 매칭 없음")
|
||||
void wildcardMatch() {
|
||||
assertTrue(IpAddressMatcher.matches("172.30.*.*", "172.30.1.5"));
|
||||
assertTrue(IpAddressMatcher.matches("172.30.1.*", "172.30.1.200"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.*", "172.30.2.1"));
|
||||
|
||||
// 레거시 정규식 구현이 오인 매칭하던 케이스 (192.168.1.* 가 192.168.199.5 를 허용)
|
||||
assertFalse(IpAddressMatcher.matches("192.168.1.*", "192.168.199.5"));
|
||||
// 점(.) 이 any-char 로 해석되던 케이스
|
||||
assertFalse(IpAddressMatcher.matches("192.168.1.*", "192x168y1z5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 허용 와일드카드")
|
||||
void matchAll() {
|
||||
assertTrue(IpAddressMatcher.matches("*", "10.1.2.3"));
|
||||
assertTrue(IpAddressMatcher.matches("*", "::1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("구분자 - 콤마/세미콜론/줄바꿈, 주석·공백 무시")
|
||||
void delimitersAndComments() {
|
||||
String list = "127.0.0.1;\n # 관리자 서버\n172.30.1.0/24 ,\n\n::1";
|
||||
assertTrue(IpAddressMatcher.matches(list, "172.30.1.9"));
|
||||
assertTrue(IpAddressMatcher.matches(list, "127.0.0.1"));
|
||||
assertFalse(IpAddressMatcher.matches(list, "10.0.0.1"));
|
||||
// 주석 항목 자체는 규칙으로 쓰이지 않는다
|
||||
assertFalse(IpAddressMatcher.matches("# 172.30.1.0/24", "172.30.1.9"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IPv4-mapped IPv6 는 IPv4 규칙으로 매칭")
|
||||
void ipv4MappedIpv6() {
|
||||
assertTrue(IpAddressMatcher.matches("172.30.1.0/24", "::ffff:172.30.1.5"));
|
||||
assertTrue(IpAddressMatcher.matches("127.0.0.1", "::FFFF:127.0.0.1"));
|
||||
assertEquals("172.30.1.5", IpAddressMatcher.canonicalize("::ffff:172.30.1.5"));
|
||||
assertEquals("::1", IpAddressMatcher.canonicalize("0:0:0:0:0:0:0:1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("잘못된 형식은 허용하지 않는다")
|
||||
void invalidPatternsDenied() {
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/33", "172.30.1.5"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/-1", "172.30.1.5"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/abc", "172.30.1.5"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "not-an-ip"));
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.*", "172.30.1.256"));
|
||||
// 범위 밖 옥텟이라도 CIDR 규칙에는 매칭되지 않는다
|
||||
assertFalse(IpAddressMatcher.matches("172.30.1.0/24", "172.30.1.256"));
|
||||
// IPv6 CIDR 미지원 — 조용히 통과시키지 않는다
|
||||
assertFalse(IpAddressMatcher.matches("fe80::/10", "fe80::1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null·공백 입력은 거부")
|
||||
void nullAndBlank() {
|
||||
assertFalse(IpAddressMatcher.matches(null, "127.0.0.1"));
|
||||
assertFalse(IpAddressMatcher.matches(" ", "127.0.0.1"));
|
||||
assertFalse(IpAddressMatcher.matches("127.0.0.1", null));
|
||||
assertFalse(IpAddressMatcher.matches("127.0.0.1", " "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user