1 Commits

Author SHA1 Message Date
Rinjae 7f73e6fcd0 djb_apistatus_incident 엔티티/리포지토리 신설
- DjbApistatusIncident / Api / Timeline 3개 엔티티
- IncidentKind / IncidentState enum
- EMSADM 스키마 1:1 매핑, Spring Data JPA 리포지토리

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:14 +09:00
66 changed files with 395 additions and 1978 deletions
+8 -7
View File
@@ -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,8 +114,6 @@ dependencies {
api group: 'commons-io', name: 'commons-io', version: '2.11.0'
implementation files('libs/damo-manager.jar')
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
testImplementation 'ch.qos.logback:logback-classic:1.2.10'
Binary file not shown.
@@ -4,10 +4,8 @@ package com.eactive.apim.portal.agreements.entity;
public enum AgreementType {
TERMS_OF_USE("TERMS_OF_USE", "이용약관"),
PRIVACY_POLICY("PRIVACY_POLICY", "개인정보처리방침"),
PRIVACY_COLLECT("PRIVACY_COLLECT", "개인정보수집동의서"),
PRIVACY_COLLECT_IND("PRIVACY_COLLECT_IND", "개인용 개인정보수집동의서"),
PRIVACY_COLLECT_ORG("PRIVACY_COLLECT_ORG", "법인용 개인정보수집동의서"),
NOTIFICATION_CONSENT("NOTIFICATION_CONSENT", "알림 수신 동의서");
PRIVACY_COLLECT_ORG("PRIVACY_COLLECT_ORG", "법인용 개인정보수집동의서");
private final String code;
private final String description;
@@ -6,7 +6,6 @@ import com.eactive.apim.portal.agreements.entity.AgreementsId;
import com.eactive.apim.portal.agreements.entity.AgreementsStatus;
import com.eactive.apim.portal.agreements.repository.AgreementsRepository;
import com.eactive.apim.portal.common.exception.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,7 +16,6 @@ import java.util.Optional;
@Service
@Transactional
@Slf4j
public class AgreementsService {
private static final String NOT_FOUND_MESSAGE = "해당 버전의 약관이 존재하지 않습니다.";
@@ -113,7 +111,6 @@ public class AgreementsService {
public List<Agreements> findAllAgreementsSortPublishedOn(AgreementType agreementType) {
List<Agreements> agreements = agreementsRepository.findAllByAgreementsTypeOrderByPublishedOnDesc(agreementType);
if(agreements.isEmpty()) {
log.error("해당 약관 종류의 약관이 존재하지 않습니다. agreementType={}", agreementType);
throw new IllegalStateException(NOT_FOUND_BY_DATE_MESSAGE);
}
return agreements;
@@ -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();
}
@@ -16,7 +16,6 @@ import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.LazyCollection;
@@ -24,7 +23,6 @@ import org.hibernate.annotations.LazyCollectionOption;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
@EqualsAndHashCode(callSuper = false)
@Entity
@Data
@Table(name = "ptl_credential")
@@ -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);
@@ -77,21 +77,5 @@ public class Approval implements Serializable, com.eactive.eai.data.Data {
private LocalDateTime approvalDate;
@Column(name = "expect_end_date")
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
@Transient
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
private String expectEndDate; //예상완료일
}
@@ -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,7 +1,7 @@
package com.eactive.apim.portal.common.entity;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.data.annotation.CreatedBy;
@@ -34,7 +34,7 @@ public abstract class Auditable {
* 최초등록자ID
*/
@Column(name = "created_by")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
@CreatedBy
private String createdBy;
@@ -50,7 +50,7 @@ public abstract class Auditable {
* 최종수정자ID
*/
@Column(name = "LAST_MODIFIED_BY")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
@LastModifiedBy
private String lastModifiedBy;
@@ -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,80 +0,0 @@
package com.eactive.apim.portal.common.util;
/**
* 한국 전화번호/연락처 정규화 유틸 (portal·admin 공유).
*
* 전화번호 저장은 본 유틸로 하이픈 구분 정규형으로 통일한다. 저장 형식이 경로별로
* 달라(가입=원본, 초대=하이픈, SSO=숫자만) 휴대폰 기반 조회/초대 매칭이 어긋나는 것을 막는다.
* 암호화 컨버터가 붙은 필드는 {@code @PrePersist}/{@code @PreUpdate} 에서 본 메서드로 평문을
* 정규화한 뒤 암호화되므로 암복호화에 영향이 없다.
*/
public final class PhoneNumberUtil {
private PhoneNumberUtil() {
}
/**
* 한국 전화번호를 하이픈 구분 정규형으로 변환한다.
* 숫자만 추출 후 prefix/길이로 포맷하며, 해당 규칙에 맞지 않는 값은 원본을 그대로 반환한다.
* 멱등(이미 정규형이면 동일 결과)이며 null/blank 는 원본을 반환한다.
*
* <pre>
* 휴대폰 01X 11자리 → XXX-XXXX-XXXX (010-1234-5678)
* 휴대폰 01X 10자리 → XXX-XXX-XXXX (011-123-4567)
* 서울 02 9자리 → 02-XXX-XXXX (02-123-4567)
* 서울 02 10자리 → 02-XXXX-XXXX (02-1234-5678)
* 지역/070 0XX 10자리 → 0XX-XXX-XXXX (031-234-5678)
* 지역/070 0XX 11자리 → 0XX-XXXX-XXXX (070-1234-5678)
* 안심 050X 12자리 → 0XXX-XXXX-XXXX (0507-1234-5678)
* 대표 15/16/18XX 8자리 → XXXX-XXXX (1588-1234)
* 그 외/null → 원본 그대로
* </pre>
*/
public static String normalize(String raw) {
if (raw == null) {
return null;
}
String digits = raw.replaceAll("[^0-9]", "");
int len = digits.length();
if (len == 0) {
return raw;
}
// 대표번호 15XX/16XX/18XX (8자리, 지역번호 없음)
if (len == 8 && (digits.startsWith("15") || digits.startsWith("16") || digits.startsWith("18"))) {
return digits.substring(0, 4) + "-" + digits.substring(4);
}
// 안심번호 050X (12자리)
if (len == 12 && digits.startsWith("050")) {
return digits.substring(0, 4) + "-" + digits.substring(4, 8) + "-" + digits.substring(8);
}
// 서울 02
if (digits.startsWith("02")) {
if (len == 9) {
return "02-" + digits.substring(2, 5) + "-" + digits.substring(5);
}
if (len == 10) {
return "02-" + digits.substring(2, 6) + "-" + digits.substring(6);
}
return raw;
}
// 휴대폰/지역/070 등 0XX
if (digits.startsWith("0")) {
if (len == 10) {
return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
}
if (len == 11) {
return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
}
return raw;
}
return raw;
}
/**
* 숫자만 추출(발송 게이트웨이 전달·등가 비교용). null 은 그대로 반환.
*/
public static String digitsOnly(String raw) {
return raw == null ? null : raw.replaceAll("[^0-9]", "");
}
}
@@ -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
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -1,8 +1,6 @@
package com.eactive.apim.portal.invitation.entity;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import lombok.Data;
import org.hibernate.annotations.Comment;
@@ -31,15 +29,10 @@ public class UserInvitation {
@Comment("기관ID")
private String orgId;
@Column(name = "INVITATION_EMAIL", length = 256)
@Column(name = "INVITATION_EMAIL", length = 30, nullable = false)
@Comment("초대이메일")
private String invitationEmail;
@Column(name = "INVITATION_MOBILE", length = 256)
@Convert(converter = PersonalDataEncryptConverter.class)
@Comment("초대휴대폰")
private String invitationMobile;
@Column(name = "INVITATION_DATE", length = 14, nullable = false)
@Convert(converter = LocalDateTimeToStringConverter14.class)
@Comment("초대일시")
@@ -63,11 +56,4 @@ public class UserInvitation {
@Comment("만료일시")
private LocalDateTime expiresOn;
/** 저장 시 초대 휴대폰을 하이픈 구분 정규형으로 통일 */
@PrePersist
@PreUpdate
private void normalizePhones() {
this.invitationMobile = PhoneNumberUtil.normalize(this.invitationMobile);
}
}
@@ -25,8 +25,6 @@ public class UserInvitationCancelEvent implements MessageEventHandler {
Map<String, String> requestParams = new HashMap<>();
requestParams.put("corpName", params.get("corpName").toString());
requestParams.put("managerName", params.get("managerName").toString());
// DB 템플릿 USER_INVITATION_CANCELED SMS 변수(%ORG_NAME%)에 매핑
requestParams.put("ORG_NAME", params.get("corpName").toString());
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
@@ -26,8 +26,6 @@ public class UserInvitationEvent implements MessageEventHandler {
requestParams.put("url", params.get("url").toString());
requestParams.put("loginId",params.get("corpName").toString() + " 이용자");
requestParams.put("authNumber", params.get("authNumber").toString());
// DB 템플릿 USER_INVITATION SMS 변수(%ORG_NAME%)에 매핑 (USER_NAME 은 공통 자동주입)
requestParams.put("ORG_NAME", params.get("corpName").toString());
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
@@ -17,20 +17,9 @@ 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);
Optional<UserInvitation> findFirstByInvitationEmailAndOrgId(String email, String orgId);
// 휴대폰 번호 기반 초대 조회 (INVITATION_MOBILE 은 결정적 암호화로 저장되어 equality 조회 가능)
Optional<UserInvitation> findFirstByInvitationMobileAndStatus(String mobile, InvitationStatus status);
Optional<UserInvitation> findFirstByInvitationMobileAndOrgIdAndStatus(String mobile, String orgId, InvitationStatus status);
Optional<UserInvitation> findFirstByInvitationMobileAndOrgId(String mobile, String orgId);
}
@@ -1,135 +0,0 @@
package com.eactive.apim.portal.jpa;
import com.eactive.ext.djb.DamoManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Slf4j
@Converter
public class PersonalDataEncryptConverter implements AttributeConverter<String, String> {
/**
* 암호화
* @param attribute the entity attribute value to be converted
* @return
*/
@Override
public String convertToDatabaseColumn(String attribute) {
String originalAttribute = attribute;
if (StringUtils.isNotEmpty(attribute)) {
attribute = DamoManager.getInstance().encrypt(attribute);
if (log.isDebugEnabled()) {
// originalAttribute 홀수 글자 마스킹
StringBuilder sb = new StringBuilder();
for (int i = 0; i < originalAttribute.length(); i++) {
if (i % 2 == 0) {
sb.append(originalAttribute.charAt(i));
} else {
sb.append("*");
}
}
originalAttribute = sb.toString();
log.debug("DB 암호화 : {} -> {}", originalAttribute, attribute);
}
}
return attribute;
}
/**
* 복호화
* @param dbData the data from the database column to be
* converted
* @return
*/
@Override
public String convertToEntityAttribute(String dbData) {
String dbDataOriginal = dbData;
if (StringUtils.isNotEmpty(dbData)) {
if (this.isEncrypted(dbData)) {
try {
dbData = DamoManager.getInstance().decrypt(dbData);
} catch (Exception e) {
// 복호화 실패시 원본 반환
String logData = dbData.length() <= 3 ? dbData : dbData.substring(0, 3) + "...";
log.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length());
return dbData;
}
} else {
log.debug("DB 복호화 경고: Base64 형식이 아님. 복호화하지 않고 원본 반환. dbData={}", dbData);
}
}
if (log.isDebugEnabled()) {
// 복호화 결과(평문) 홀수 글자 마스킹
String maskedDbData = dbData;
if (StringUtils.isNotEmpty(dbData)) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < dbData.length(); i++) {
if (i % 2 == 0) {
sb.append(dbData.charAt(i));
} else {
sb.append("*");
}
}
maskedDbData = sb.toString();
}
log.debug("DB 복호화 : {} -> {}", dbDataOriginal, maskedDbData);
}
return dbData;
}
/**
* 현재 damo-manager 가 bypass 모드(무변환)인지 여부.
* bypass 면 {@code encrypt}/{@code decrypt} 가 원문을 그대로 반환하므로 암호화/정규화가 no-op 이 된다.
* 레거시 평문 일괄 암호화 같은 마이그레이션 작업은 이 경우 의미가 없으므로 호출 측에서 차단해야 한다.
*/
public boolean isBypassMode() {
return DamoManager.getInstance().isBypassMode();
}
/** 현재 damo-manager 가 fake 모드(scpdb 부재, Base64 대체)인지 여부. 운영 실행 전 REAL 확인용. */
public boolean isFakeMode() {
return DamoManager.getInstance().isFakeMode();
}
/**
* 암호화 여부 확인(base64 인코딩 여부로 판단, 또는 0-9, - 로 구성된 경우 암호화 되지 않은 걸로 판단(연락처))
* @param data
* @return
*/
private boolean isEncrypted(String data) {
if (StringUtils.isEmpty(data)) {
return false;
}
// 숫자, - 로만 구성된 경우 암호화 되지 않은 걸로 판단 (ex: 연락처)
if (data.matches("^[0-9-]+$")) {
return false;
}
data = data.trim();
// Base64 형식 체크
if (!data.matches("^[A-Za-z0-9+/]+={0,2}$")) {
return false;
}
// Base64 길이 체크 (4의 배수)
if (data.length() % 4 != 0) {
return false;
}
return true;
}
}
@@ -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);
}
@@ -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();
}
@@ -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());
}
}
@@ -1,7 +1,6 @@
package com.eactive.apim.portal.portalorg.entity;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import lombok.Data;
@@ -92,37 +91,4 @@ public class PortalOrg extends Auditable implements Serializable, com.eactive.ea
@Column(name = "sc_phone_number", length = 20)
@Comment("고객센터연락처")
private String scPhoneNumber;
@Column(name = "reverse_proxy_path", length = 255)
@Comment("리버스 프록시 경로")
private String reverseProxyPath;
/** 저장 시 전화번호를 하이픈 구분 정규형으로 통일 */
@PrePersist
@PreUpdate
private void normalizePhones() {
this.orgPhoneNumber = PhoneNumberUtil.normalize(this.orgPhoneNumber);
this.scPhoneNumber = PhoneNumberUtil.normalize(this.scPhoneNumber);
}
/**
* 법인 반려/삭제(탈퇴) 사업자등록번호·법인등록번호 민감/개인정보 필드를 제거한다.
* orgName·orgStatus·approvalStatus 상태값은 호출 측에서 별도 설정한다.
*/
public void clearSensitiveDataOnRemoval() {
this.corpRegNo = null;
this.compRegNo = null;
this.orgCode = null;
this.compRegFile = null;
this.ceoName = null;
this.orgAddr = null;
this.orgPhoneNumber = null;
this.scPhoneNumber = null;
this.orgSectors = null;
this.orgIndustryType = null;
this.serviceName = null;
this.orgDesc = null;
this.ipWhitelist = null;
this.reverseProxyPath = null;
}
}
@@ -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();
}
@@ -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,
@@ -52,24 +51,13 @@ public class PortalPropertyService extends AbstractDataService<PortalPropertyGro
Optional<PortalProperty> existingProperty = portalPropertyRepository.findById(propertyId);
if (existingProperty.isPresent()) {
PortalProperty property = existingProperty.get();
// 설명(property_desc) 비어 있으면 코드가 넘긴 description 으로 채운다.
// (값이 있으면 관리자 편집분 보존 덮어쓰지 않음)
if (isBlank(property.getPropertyDesc()) && !isBlank(description)) {
property.setPropertyDesc(description);
portalPropertyRepository.save(property);
}
return property.getPropertyValue();
return existingProperty.get().getPropertyValue();
}
// 프로퍼티가 없으면 기본값으로 생성
return createDefaultProperty(groupName, propertyName, defaultValue, description);
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
/**
* 기본 프로퍼티를 DB에 생성
*/
@@ -1,13 +1,13 @@
package com.eactive.apim.portal.portaluser.entity;
import com.eactive.apim.portal.common.entity.Auditable;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.ApprovalStatus;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import java.io.Serializable;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Comment;
@@ -22,7 +22,7 @@ import java.time.LocalDateTime;
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true)
@Entity
@Table(name = "PTL_USER")
@Table(name = "ptl_user")
public class PortalUser extends Auditable implements Serializable, com.eactive.eai.data.Data {
private static final long serialVersionUID = 1L;
@@ -34,7 +34,7 @@ public class PortalUser extends Auditable implements Serializable, com.eactive.e
@Column(name = "login_id", length = 200, nullable = false)
@Comment("로그인ID")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String loginId;
@Column(name = "password_hash", length = 60)
@@ -47,17 +47,17 @@ public class PortalUser extends Auditable implements Serializable, com.eactive.e
@Column(name = "phone_number", length = 50)
@Comment("전화번호")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String phoneNumber;
@Column(name = "mobile_number", length = 50)
@Comment("휴대폰 번호")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String mobileNumber;
@Column(name = "email_addr", length = 200)
@Comment("이메일주소")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String emailAddr;
@ManyToOne(fetch = FetchType.EAGER)
@@ -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;
@@ -103,12 +99,4 @@ public class PortalUser extends Auditable implements Serializable, com.eactive.e
@Column(name = "withdrawal_reason", length=200)
@Comment("회원탈퇴사유")
private String withdrawalReason;
/** 저장 시 전화번호를 하이픈 구분 정규형으로 통일 */
@PrePersist
@PreUpdate
private void normalizePhones() {
this.phoneNumber = PhoneNumberUtil.normalize(this.phoneNumber);
this.mobileNumber = PhoneNumberUtil.normalize(this.mobileNumber);
}
}
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.portaluser.entity;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import org.hibernate.annotations.GenericGenerator;
import javax.persistence.*;
@@ -20,20 +20,14 @@ 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;
@Column(name = "recipient", length = 255)
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String recipient;
// recipient 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하므로,
// recipient 평문의 결정적 해시(HMAC-SHA256, hex) 별도 조회 키로 둔다. (비암호화)
@Column(name = "recipient_key", length = 64)
private String recipientKey;
@Column(name = "auth_number", length = 255)
private String authNumber;
@@ -55,14 +49,6 @@ public class TwoFactorAuth {
this.recipient = recipient;
}
public String getRecipientKey() {
return recipientKey;
}
public void setRecipientKey(String recipientKey) {
this.recipientKey = recipientKey;
}
public String getAuthNumber() {
return authNumber;
}
@@ -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;
}
@@ -24,10 +24,7 @@ public class UserPasswordResetEvent implements MessageEventHandler {
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
String tempPassword = (String) params.get("tempPassword");
requestParams.put("tempPassword", tempPassword);
// DB 템플릿 USER_PASSWORD_RESET SMS 변수(%TEMP_PASSWORD%) 매핑
requestParams.put("TEMP_PASSWORD", tempPassword);
requestParams.put("tempPassword", (String) params.get("tempPassword"));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
@@ -23,10 +23,7 @@ public class UserVerificationEmailEvent implements MessageEventHandler {
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
String authNumber = (String) params.get("authNumber");
requestParams.put("authNumber", authNumber);
// DB 템플릿 USER_VERIFICATION_EMAIL 변수(%AUTH_NO%) 매핑
requestParams.put("AUTH_NO", authNumber);
requestParams.put("authNumber", (String) params.get("authNumber"));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
@@ -24,10 +24,7 @@ public class UserVerificationMobilePhoneEvent implements MessageEventHandler {
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
String authNumber = (String) params.get("authNumber");
requestParams.put("authNumber", authNumber);
// DB 템플릿 USER_VERIFICATION_MOBILEPHONE SMS 변수(%AUTH_CODE%) 매핑
requestParams.put("AUTH_CODE", authNumber);
requestParams.put("authNumber", (String) params.get("authNumber"));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
@@ -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);
}
@@ -28,11 +28,6 @@ public interface PortalUserRepository extends BaseRepository<PortalUser, String>
boolean existsByLoginId(String loginId);
boolean existsByMobileNumber(String mobileNumber);
// 휴대폰 번호 기반 기존 회원 조회 (mobileNumber 유니크 제약이 없어 다건 가능)
List<PortalUser> findAllByMobileNumber(String mobileNumber);
@Query("SELECT u FROM PortalUser u WHERE u.portalOrg = :org AND u.userStatus != :status")
Page<PortalUser> findActiveUsers(@Param("org") PortalOrg org, @Param("status") PortalUserEnums.UserStatus status, Pageable pageable);
@@ -40,15 +35,6 @@ public interface PortalUserRepository extends BaseRepository<PortalUser, String>
long countByPortalOrg(PortalOrg portalOrg);
List<PortalUser> findAllByPortalOrg_Id(String orgId);
Optional<PortalUser> findByUserName(String userName);
// 암복
@Query(
value = "select * from ${ems.datasource.schema}.ptl_user where email_addr = :emailAddr",
nativeQuery = true
)
Optional<PortalUser> findByEmailAddrRaw(@Param("emailAddr") String emailAddr);
}
@@ -2,30 +2,11 @@ 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> {
// recipientKey(결정적 해시) 기반 조회/정리. recipient 암호화 컬럼이라 동등 조회가 깨지는 문제를 우회한다.
Optional<TwoFactorAuth> findByRecipientKey(String recipientKey);
void deleteAllByRecipientKey(String recipientKey);
/**
* 만료된 인증번호 레코드를 일괄 삭제한다. (정리 스케줄러용)
* expires_on LocalDateTimeToStringConverter14(yyyyMMddHHmmss) 저장되며,
* 해당 포맷은 사전식 정렬이 시간순과 일치하므로 문자열 &lt; 비교가 시간 비교다.
*/
@Modifying
int deleteAllByExpiresAtBefore(LocalDateTime threshold);
// 아래 메서드는 recipient(암호화 컬럼) 기반이라 신뢰할 없음 신규 코드에서는 RecipientKey 버전을 사용할 .
@Deprecated
Optional<TwoFactorAuth> findByRecipient(String recipientKey);
@Deprecated
void deleteAllByRecipient(String recipient);
}
@@ -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);
}
@@ -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 class AuthNumberException extends RuntimeException{
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;
}
}
@@ -49,7 +49,6 @@ public class Inquiry extends Auditable {
/**
* 문의 상태 (: 대기중, 답변 완료 )
* @DjbInquiryStatus 참조
*/
@Column(name = "INQUIRY_STATUS", length = 32)
private String inquiryStatus;
@@ -86,23 +85,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&amp;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/>";
}
}
@@ -10,47 +10,85 @@ public enum MessageCode {
// 사용자 이벤트 영역
USER_VERIFICATION_EMAIL("이메일 주소 인증", null, Channels.EMAIL),
USER_VERIFICATION_MOBILEPHONE("휴대폰 번호 인증", "JJB_85959019001", Channels.SMS),
USER_VERIFICATION_EMAIL("이메일 주소 인증", "FM_APIM001", Channels.EMAIL),
USER_VERIFICATION_MOBILEPHONE("휴대폰 번호 인증", "S55_ICT_0006", Channels.KAKAO_ALIMTALK),
USER_PASSWORD_CHANGED("사용자 비밀번호 변경", "JJB_85959019003", Channels.SMS),
USER_PASSWORD_RESET("비밀번호 재설정", "FM_APIM002", Channels.EMAIL),
USER_PASSWORD_CHANGED("사용자 비밀번호 변경", "FM_APIM003", Channels.EMAIL),
USER_ACCOUNT_LOCKED("계정 잠금 알림", "JJB_85959019002", Channels.SMS),
USER_ACCOUNT_LOCKED("계정 잠금 알림", "FM_APIM004", Channels.EMAIL),
// 법인관리자 이벤트 영역
USER_INVITATION("법인 이용자 이메일 초대", "JJB_85959019006", Channels.SMS),
USER_INVITATION_CANCELED("법인 이용자 이메일 초대 취소", "JJB_85959019007", Channels.SMS),
USER_INVITATION("법인 이용자 이메일 초대", "FM_APIM005", Channels.EMAIL),
USER_INVITATION_CANCELED("법인 이용자 이메일 초대 취소", "FM_APIM006", Channels.EMAIL),
MANAGER_WITH_ORG_REGISTER_APPROVED("법인관리자 및 법인 등록 승인", "FM_APIM007", Channels.EMAIL),
MANAGER_WITH_ORG_REGISTER_REJECTED("법인관리자 및 법인 등록 거절", "FM_APIM008", Channels.EMAIL),
MANAGER_WITH_ORG_REGISTER_APPROVED("법인관리자 및 법인 등록 승인", "JJB_85959019004", Channels.SMS),
MANAGER_WITH_ORG_REGISTER_REJECTED("법인관리자 및 법인 등록 거절", "JJB_85959019005", Channels.SMS),
// 관리자 영역
APP_APPROVE("사용 승인", "JJB_85959019008", Channels.SMS),
APP_REJECTED("사용 거절", "JJB_85959019009", Channels.SMS),
APP_REGISTER_APPROVED("등록 승인", "FM_APIM009", Channels.EMAIL),
APP_REGISTER_REJECTED("등록 거절", "FM_APIM010", Channels.EMAIL),
// Q&A 등록/댓글 등록 알림 (DJBank 커스텀 portal-admin 수신)
INQUIRY_CREATED("Q&A 등록 알림", null, Channels.SWING),
INQUIRY_COMMENT_CREATED("Q&A 댓글 등록 알림", null, Channels.SWING),
PARTNERSHIP_CREATED("개선요청/제휴 게시물 등록 알림", null, Channels.SWING),
INQUIRY_CREATED("Q&A 등록 알림", null, Channels.NONE),
INQUIRY_COMMENT_CREATED("Q&A 댓글 등록 알림", null, Channels.NONE),
// 제주은행
API_STATUS_CHANGED("API 상태 변화", null, Channels.SWING),
INFLOW_TOKEN_FAILED("유량제어 토큰 획득 실패", null, Channels.SWING),
ADMIN_VERIFICATION_MOBILEPHONE("관리자포탈 휴대폰 번호 인증", "JJB_85999019001", Channels.SMS),
// 레거시/미사용 Event 핸들러 클래스 호환을 위해 유지 (실제 발송 없음)
USER_PASSWORD_RESET("비밀번호 재설정", null, Channels.SMS),
// API상태 모니터링
API_STATUS_CHANGED("API 상태 변화", "FM_APIM011", Channels.MESSENGER),
// 유량제어 토큰 획득 실패
INFLOW_TOKEN_FAILED("유량제어 토큰 획득 실패", "FM_APIM012", Channels.MESSENGER),
// 사용, 분류
@Deprecated
API_APPROVED("API 승인 알림", null, Channels.NONE),
APP_REGISTER_REQUEST("앱 등록 요청", null, Channels.NONE),
APP_REGISTER_REJECTED("앱 등록 거절", null, Channels.NONE),
UNKNOWN_ERROR("알수 없는 에러 발생", null, Channels.NONE),
USER_REGISTRATION_REQUEST_ADMIN("사용자 등록 승인 요청", null, Channels.NONE),
USER_REGISTRATION_DENIED("사용자 등록 거절", null, Channels.NONE),
USER_REGISTRATION_REQUEST_USER("사용자 등록 요청 완료", null, Channels.NONE),
REQUEST_PASSWORD_RESET("비밀번호 초기화 요청", null, Channels.NONE),
@Deprecated
API_REJECTED("API 거절 알림", null, Channels.NONE),
@Deprecated
APP_REGISTER("앱 등록 완료", null, Channels.NONE),
@Deprecated
USER_MANAGER_ASSIGNED("관리자 권한 부여", null, Channels.NONE),
USER_REGISTRATION_APPROVED("사용자 등록 승인 완료", null, Channels.NONE),
@Deprecated
REQUEST_AUTH_NUMBER("인증번호 요청", null, Channels.NONE),
@Deprecated
USER_WITHDRAWAL_APPROVED("계정 비활성화", null, Channels.NONE),
@Deprecated
USER_ACTIVATION("사용자 활성화 완료", null, Channels.NONE), // 이게 ...이메일 인증인건데........
@Deprecated
APP_REGISTER_REQUEST("앱 등록 요청", null, Channels.NONE),
@Deprecated
UNKNOWN_ERROR("알수 없는 에러 발생", null, Channels.NONE),
@Deprecated
PASSWORD_RESET_COMPLETE("비밀번호 변경 완료", null, Channels.NONE),
@Deprecated
REQUEST_PASSWORD_RESET("비밀번호 초기화 요청", null, Channels.NONE),
@Deprecated // 정확한 용도 모름
USER_REGISTRATION_APPROVED("사용자 등록 승인 완료", null, Channels.NONE),
@Deprecated // 정확한 용도 모름
USER_REGISTRATION_DENIED("사용자 등록 거절", null, Channels.NONE),
@Deprecated // 정확한 용도 모름
USER_REGISTRATION_REQUEST_ADMIN("사용자 등록 승인 요청", null, Channels.NONE),
@Deprecated // 정확한 용도 모름
USER_REGISTRATION_REQUEST_USER("사용자 등록 요청 완료", null, Channels.NONE),
@Deprecated
INQUIRY_RESPONSE("QnA 답변 작성", null, Channels.NONE)
;
@@ -60,8 +98,7 @@ public enum MessageCode {
@Getter
private final String serviceId;
@Getter
@Deprecated // 삭제 예정
private int channels = 0;
private final int channels;
public static Optional<MessageCode> findServiceId(String code) {
@@ -73,11 +110,6 @@ public enum MessageCode {
return Optional.empty();
}
MessageCode(String description, String serviceId) {
this.description = description;
this.serviceId = serviceId;
// this.channels = channel.getValue();
}
MessageCode(String description, String serviceId, Channels channel) {
this.description = description;
@@ -89,8 +121,8 @@ public enum MessageCode {
public static enum Channels {
NONE (0b0000),
EMAIL (0b0001),
SMS (0b0010),
SWING (0b0100),
KAKAO_ALIMTALK (0b0010),
MESSENGER (0b0100),
;
@Getter
@@ -1,10 +1,9 @@
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;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@@ -54,23 +53,20 @@ public class MessageRequest implements Serializable {
private String username;
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String email;
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String phone;
@Column(name = "user_id")
private String userId;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", insertable = false, updatable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@NotFound(action = NotFoundAction.IGNORE)
@Comment("발송 대상 사용자")
private PortalUser user;
@Comment("UMS 연계용 UID")
@Column(name="ums_uid", length = 36)
@Column(name="ums_uid", length = 16)
private String umsUid;
@Column(name = "messenger_id")
@@ -79,23 +75,10 @@ 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);
}
}
@@ -11,8 +11,7 @@ import lombok.*;
@Builder
public class MessageRecipient {
private String username;
private String userId;
private String email;
private String userId; //email
private String phone;
private String messengerId;
@@ -20,8 +19,7 @@ public class MessageRecipient {
public static MessageRecipient of(PortalUser user) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(user.getUserName());
recipient.setUserId(user.getLoginId());
recipient.setEmail(user.getEmailAddr());
recipient.setUserId(user.getEmailAddr());
recipient.setPhone(user.getPhoneNumber());
return recipient;
}
@@ -37,31 +37,29 @@ 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) {
List<MessageRecipient> recipients = new ArrayList<>();
for (UserInfo user : template.getAdditionalRecipients()) {
MessageRecipient messageRecipient = new MessageRecipient(user.getUsername(), user.getUserid(), user.getEmad(), user.getCphnno(), user.getUserid());
MessageRecipient messageRecipient = new MessageRecipient(user.getUsername(), user.getUserid(), user.getCphnno(), user.getUserid());
recipients.add(messageRecipient);
}
@@ -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("message.integration.eaiId.sms");
MessageRequest smsRequest = new MessageRequest();
smsRequest.setMessageCode(MessageCode.valueOf(template.getMessageCode().toUpperCase()));
smsRequest.setSubject(subject);
@@ -93,37 +88,30 @@ public class MessageSendService {
smsRequest.setUsername(user.getUsername());
smsRequest.setPhone(user.getPhone());
smsRequest.setEaiInterfaceId(smsEaiInterfaceId);
smsRequest.setEaiTxId(smsEaiTxId);
smsRequest.setServiceId(messageCode.getServiceId());
smsRequest.setUserId(user.getUserId());
smsRequest.setMessageType("SMS");
created.add(messageRequestRepository.save(smsRequest));
smsRequest.setServiceId(portalProperties.get("message.integration.template.sms." + template.getMessageCode()));
smsRequest.setMessageType("KAKAO");
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("message.integration.eaiId.email");
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("message.integration.template.email." + template.getMessageCode()));
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");
MessageRequest messengerRequest = new MessageRequest();
messengerRequest.setMessageCode(MessageCode.valueOf(template.getMessageCode()));
messengerRequest.setSubject(subject);
@@ -131,44 +119,16 @@ public class MessageSendService {
messengerRequest.setMessage(messengerTemplate);
messengerRequest.setRequestDate(LocalDateTime.now());
messengerRequest.setUsername(user.getUsername());
messengerRequest.setMessengerId(user.getMessengerId());
messengerRequest.setEaiInterfaceId(swingEaiInterfaceId);
messengerRequest.setEaiTxId(swingEaiTxId);
messengerRequest.setServiceId(messageCode.getServiceId());
messengerRequest.setMessageType("MESSENGER");
messengerRequest.setUserId(user.getUserId());
created.add(messageRequestRepository.save(messengerRequest));
logger.debug(messengerRequest.toString());
messengerRequest.setMessengerId(user.getUserId());
messageRequestRepository.save(messengerRequest);
}
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) {
// 공통 자동주입. 대문자 별칭(USER_ID/USER_NAME) DB 템플릿 변수 표기와 맞춘다.
// putIfAbsent 이므로 개별 이벤트(createEvent) 먼저 넣은 값이 있으면 그대로 둔다(오버라이드).
messageParams.put("userId", user.getUserId());
messageParams.putIfAbsent("USER_ID", user.getUserId());
if (StringUtils.hasText(user.getUsername())) {
messageParams.put("userName", user.getUsername());
messageParams.putIfAbsent("USER_NAME", user.getUsername());
}
}
@@ -1,22 +1,28 @@
package com.eactive.apim.portal.user.entity;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import com.eactive.eai.data.converter.StringTrimConverter;
import com.eactive.eai.data.entity.AbstractEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
import java.io.Serializable;
import java.time.LocalDateTime;
import javax.persistence.Cacheable;
import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
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.*;
import java.io.Serializable;
import java.time.LocalDateTime;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import com.eactive.eai.data.converter.StringTrimConverter;
import com.eactive.eai.data.entity.AbstractEntity;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true)
@@ -48,7 +54,7 @@ public class UserInfo extends AbstractEntity<String> implements Serializable {
@Column(length = 40)
@Comment("휴대폰 번호")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String cphnno;
@Column(length = 40)
@@ -57,7 +63,7 @@ public class UserInfo extends AbstractEntity<String> implements Serializable {
@Column(length = 100)
@Comment("이메일 주소")
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String emad;
@Column(length = 3)
@@ -74,12 +80,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,20 +143,8 @@ public class UserInfo extends AbstractEntity<String> implements Serializable {
@Comment("사용자 계정 상태")
private String status;
@Column
@Comment("로그인 실패 횟수")
private Integer loginfailcount;
@Override
public @NonNull String getId() {
return userid;
}
/** 저장 시 전화번호를 하이픈 구분 정규형으로 통일 */
@PrePersist
@PreUpdate
private void normalizePhones() {
this.cphnno = PhoneNumberUtil.normalize(this.cphnno);
this.ofctelno = PhoneNumberUtil.normalize(this.ofctelno);
}
}
@@ -1,11 +1,18 @@
package com.eactive.apim.portal.user.entity;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import com.eactive.eai.data.converter.LocalDateTimeToStringConverter14;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.TableGenerator;
import kjb.safedb.KjbNotRnnoJpaAttributeConverter;
import lombok.Data;
@TableGenerator(
name = "USER_LOGIN_LOG_GEN",
@@ -25,7 +32,7 @@ public class UserLog {
private Long logId;
@Column(name = "login_id", nullable = false)
@Convert(converter = PersonalDataEncryptConverter.class)
@Convert(converter = KjbNotRnnoJpaAttributeConverter.class)
private String loginId;
@Column(name = "login_time")
@@ -45,27 +52,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);
}
@@ -0,0 +1,116 @@
package kjb.safedb;
//import com.eactive.ext.kjb.safedb.KjbSafedbWrapper;
//import com.eactive.ext.kjb.safedb.SafeDBColumns;
//import com.eactive.ext.kjb.safedb.Utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Converter
@Slf4j
public class KjbNotRnnoJpaAttributeConverter implements AttributeConverter<String, String> {
private static final Logger needFixLogger = LoggerFactory.getLogger("eapim.portal.needfix");
/**
* 암호화
* @param attribute the entity attribute value to be converted
* @return
*/
@Override
public String convertToDatabaseColumn(String attribute) {
// log.debug("DB 암호화 #1 - {} / {}", attribute, SafeDBColumns.NOT_RNNO);
// String originalAttribute = attribute;
// if (StringUtils.isNotEmpty(attribute)) {
// KjbSafedbWrapper wrapper = KjbSafedbWrapper.getInstance();
// attribute = wrapper.encryptNotRnnoString(attribute);
//
// if (log.isDebugEnabled()) {
// // originalAttribute 홀수 글자 마스킹
// StringBuilder sb = new StringBuilder();
// for (int i = 0; i < originalAttribute.length(); i++) {
// if (i % 2 == 0) {
// sb.append(originalAttribute.charAt(i));
// } else {
// sb.append("*");
// }
// }
//
// originalAttribute = sb.toString();
// log.debug("DB 암호화 #2 : {} -> {}", originalAttribute, attribute);
// }
// }
return attribute;
}
/**
* 복호화
* @param dbData the data from the database column to be
* converted
* @return
*/
@Override
public String convertToEntityAttribute(String dbData) {
// log.debug("DB 복호화 #1 - {} / {}", dbData, SafeDBColumns.NOT_RNNO);
// String dbDataOriginal = dbData;
// if (StringUtils.isNotEmpty(dbData)) {
// if (this.isEncrypted(dbData)) {
// KjbSafedbWrapper safeDBWrapper = KjbSafedbWrapper.getInstance();
// try {
// dbData = safeDBWrapper.decryptNotRnno(dbData);
// } catch (Exception e) {
// // 복호화 실패시 원본 반환
// String logData = dbData.length() <= 3 ? dbData : dbData.substring(0, 3) + "...";
// log.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length());
// needFixLogger.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length(), e);
// return dbData;
// }
// } else {
// log.debug("DB 복호화 경고: Base64 형식이 아님. 복호화하지 않고 원본 반환. dbData={}", dbData);
// }
// }
// log.debug("DB 복호화 #2 : {} -> {}", dbDataOriginal, dbData);
return dbData;
}
/**
* 암호화 여부 확인(base64 인코딩 여부로 판단, 또는 0-9, - 구성된 경우 암호화 되지 않은 걸로 판단(연락처))
* @param data
* @return
*/
private boolean isEncrypted(String data) {
// if (StringUtils.isEmpty(data)) {
// return false;
// }
//
// // 숫자, - 로만 구성된 경우 암호화 되지 않은 걸로 판단 (ex: 연락처)
// if (data.matches("^[0-9-]+$")) {
// return false;
// }
//
// data = data.trim();
//
// // Base64 형식 체크
// if (!Utils.isBase64(data)) {
// return false;
// }
//
// // Base64 길이 체크 (4의 배수)
// if (data.length() % 4 != 0) {
// return false;
// }
//
//
// return true;
return false;
}
}
@@ -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", " "));
}
}