클라이언트 표현 수정 및 내부 사용자 이메일 도메인 설정 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- "앱" -> "클라이언트"로 명칭 일괄 수정 (HTML/DTO/JS)
- 내부 사용자 이메일 도메인 동적 설정 로직 및 기본값 추가
This commit is contained in:
Rinjae
2026-08-13 12:50:50 +09:00
parent eb2cce8002
commit 7c0cd371f0
8 changed files with 129 additions and 32 deletions
+6
View File
@@ -73,6 +73,12 @@ dependencies {
runtimeOnly('org.springframework.boot:spring-boot-starter-actuator')
// Spring Boot Admin client. 2.7.16 = Spring Boot 2.7.x 대응 마지막 계열(Java 8 호환).
runtimeOnly('de.codecentric:spring-boot-admin-starter-client:2.7.16')
// 위 runtimeOnly 원칙의 유일한 예외. InterceptorsEndpointConfig 가 actuator API
// (@Endpoint / InfoContributor)를 참조해야 하므로 compileClasspath 에만 올린다.
// compileOnly 는 runtimeClasspath 에 포함되지 않으므로 war/bootWar 산출물에는 영향이 없다.
// 해당 클래스는 @ConditionalOnClass 로 actuator 부재 시 로드되지 않으니
// 배포본(actuator 제외)에서 NoClassDefFoundError 가 나지 않는다.
compileOnly('org.springframework.boot:spring-boot-actuator')
implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.0'
implementation group: 'com.fasterxml.woodstox', name: 'woodstox-core', version: '6.5.1'
@@ -36,12 +36,12 @@ public class ApiKeyRegistrationDTO implements Serializable {
private byte[] appIconData;
private String appIconContentType;
@NotBlank(message = " 이름을 입력해주세요.")
@Length(max = 100, message = " 이름은 100자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 이름을 입력해주세요.")
@Length(max = 100, message = "클라이언트 이름은 100자를 초과할 수 없습니다.")
private String appName;
@NotBlank(message = " 설명을 입력해주세요.")
@Length(max = 500, message = " 설명은 500자를 초과할 수 없습니다.")
@NotBlank(message = "클라이언트 설명을 입력해주세요.")
@Length(max = 500, message = "클라이언트 설명은 500자를 초과할 수 없습니다.")
private String appDescription;
private String callbackUrl;
@@ -3,9 +3,18 @@ package com.eactive.apim.portal.common.util;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 사용자 유형 판별 유틸리티
* 내부 운영자와 외부 사용자를 구분하는 공통 로직 제공
*
* 내부 사용자로 인정할 이메일 도메인은 application.yml 의
* {@code portal.internal-user.email-domains} 로 관리한다.
* 설정이 없으면 {@link #DEFAULT_INTERNAL_EMAIL_DOMAINS} 를 사용한다.
*/
public final class UserTypeUtil {
@@ -13,7 +22,47 @@ public final class UserTypeUtil {
throw new IllegalStateException("Utility class");
}
private static final String INTERNAL_EMAIL_DOMAIN = "@kjbank.com";
/** 설정 미주입 시 사용할 기본 내부 도메인 */
private static final List<String> DEFAULT_INTERNAL_EMAIL_DOMAINS =
Collections.unmodifiableList(Arrays.asList("@djbank.com", "@unit-test.com"));
private static volatile List<String> internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
/**
* 내부 사용자 이메일 도메인 목록 설정.
* {@code UserTypeConfigurer} 가 기동 시 application.yml 값으로 주입한다.
*
* @param domains 도메인 목록 ("@" 접두사 유무 무관, 대소문자 무관)
*/
public static void setInternalEmailDomains(List<String> domains) {
if (domains == null || domains.isEmpty()) {
internalEmailDomains = DEFAULT_INTERNAL_EMAIL_DOMAINS;
return;
}
List<String> normalized = new ArrayList<>();
for (String domain : domains) {
if (StringUtils.isBlank(domain)) {
continue;
}
String value = domain.trim().toLowerCase();
if (!value.startsWith("@")) {
value = "@" + value;
}
if (!normalized.contains(value)) {
normalized.add(value);
}
}
internalEmailDomains = normalized.isEmpty()
? DEFAULT_INTERNAL_EMAIL_DOMAINS
: Collections.unmodifiableList(normalized);
}
/**
* 현재 적용 중인 내부 사용자 이메일 도메인 목록
*/
public static List<String> getInternalEmailDomains() {
return internalEmailDomains;
}
/**
* 현재 사용자가 내부 운영자인지 확인
@@ -21,10 +70,28 @@ public final class UserTypeUtil {
* @return 내부 운영자 여부
*/
public static boolean isInternalUser(PortalAuthenticatedUser user) {
if (user == null || StringUtils.isEmpty(user.getEmailAddr())) {
if (user == null) {
return false;
}
return user.getEmailAddr().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN);
return isInternalEmail(user.getEmailAddr());
}
/**
* 이메일 주소의 호스트(도메인)로 내부 운영자 여부 판별
* @param emailAddr 이메일 주소
* @return 내부 운영자 여부
*/
public static boolean isInternalEmail(String emailAddr) {
if (StringUtils.isEmpty(emailAddr)) {
return false;
}
String email = emailAddr.trim().toLowerCase();
for (String domain : internalEmailDomains) {
if (email.endsWith(domain)) {
return true;
}
}
return false;
}
/**
@@ -5,6 +5,7 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@@ -25,6 +26,20 @@ public class PortalProperties {
private FileProperties file = new FileProperties();
private InternalUserProperties internalUser = new InternalUserProperties();
/**
* 내부 사용자(운영자) 판별 설정
*/
@Data
public static class InternalUserProperties {
/**
* 내부 사용자로 분류할 이메일 도메인 목록.
* "@" 접두사는 있어도 되고 없어도 되며, 대소문자를 구분하지 않는다.
*/
private List<String> emailDomains = new ArrayList<>();
}
/**
* 파일 업로드 관련 설정
*/
+9
View File
@@ -127,6 +127,15 @@ portal:
logging:
log-path: /logs/eapim
# 내부 사용자(운영자) 판별 설정
# 로그인 계정 이메일의 호스트가 아래 도메인이면 내부 사용자로 분류한다.
# (첨부파일 형식/크기 검증 우회 등 FileService 내부 사용자 컨텍스트에 사용)
# "@" 접두사 유무·대소문자 무관
internal-user:
email-domains:
- djbank.com
- unit-test.com
auth-ttl: 300
auth:
resend_limit_seconds: 30
@@ -90,18 +90,18 @@
<!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- 이름 -->
<!-- 클라이언트 이름 -->
<div class="s1-field">
<label class="s1-label"> 이름 <span class="s1-required">*</span></label>
<label class="s1-label">클라이언트 이름 <span class="s1-required">*</span></label>
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
placeholder=" 이름을 입력하세요." required maxlength="100">
placeholder="클라이언트 이름을 입력하세요." required maxlength="100">
</div>
<!-- 설명 -->
<!-- 클라이언트 설명 -->
<div class="s1-field">
<label class="s1-label"> 설명 <span class="s1-required">*</span></label>
<label class="s1-label">클라이언트 설명 <span class="s1-required">*</span></label>
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
placeholder=" 설명을 입력하세요." required maxlength="500"></textarea>
placeholder="클라이언트 설명을 입력하세요." required maxlength="500"></textarea>
<div class="s1-counter-row">
<span id="charCounter" class="s1-counter">0 / 500</span>
</div>
@@ -132,9 +132,9 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
<!-- 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<!-- 클라이언트 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label"> 아이콘</label>
<label class="s1-label">클라이언트 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
@@ -154,7 +154,7 @@
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title"> 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-title">클라이언트 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
@@ -364,14 +364,14 @@
if (!name) {
e.preventDefault();
customPopups.showAlert(' 이름을 입력해주세요.');
customPopups.showAlert('클라이언트 이름을 입력해주세요.');
document.getElementById('appName').focus();
return;
}
if (!desc) {
e.preventDefault();
customPopups.showAlert(' 설명을 입력해주세요.');
customPopups.showAlert('클라이언트 설명을 입력해주세요.');
textarea.focus();
return;
}
@@ -29,7 +29,7 @@
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 정보 입력 (document icon) -->
<!-- Step 1 Active: 클라이언트 정보 입력 (document icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
@@ -44,7 +44,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보 입력</span>
<span class="s1-step-name">클라이언트 정보 입력</span>
</div>
<div class="s1-step-line"></div>
@@ -100,18 +100,18 @@
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 이름 -->
<!-- 클라이언트 이름 -->
<div class="s1-field">
<label class="s1-label"> 이름 <span class="s1-required">*</span></label>
<label class="s1-label">클라이언트 이름 <span class="s1-required">*</span></label>
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
placeholder=" 이름을 입력하세요." required maxlength="100">
placeholder="클라이언트 이름을 입력하세요." required maxlength="100">
</div>
<!-- 설명 -->
<!-- 클라이언트 설명 -->
<div class="s1-field">
<label class="s1-label"> 설명 <span class="s1-required">*</span></label>
<label class="s1-label">클라이언트 설명 <span class="s1-required">*</span></label>
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
placeholder=" 설명을 입력하세요." required maxlength="500"></textarea>
placeholder="클라이언트 설명을 입력하세요." required maxlength="500"></textarea>
<div class="s1-counter-row">
<span id="charCounter" class="s1-counter">0 / 200</span>
</div>
@@ -150,14 +150,14 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
<!-- 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<!-- 클라이언트 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label"> 아이콘</label>
<label class="s1-label">클라이언트 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title"> 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-title">클라이언트 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
@@ -382,14 +382,14 @@
if (!name) {
e.preventDefault();
customPopups.showAlert(' 이름을 입력해주세요.');
customPopups.showAlert('클라이언트 이름을 입력해주세요.');
document.getElementById('appName').focus();
return;
}
if (!desc) {
e.preventDefault();
customPopups.showAlert(' 설명을 입력해주세요.');
customPopups.showAlert('클라이언트 설명을 입력해주세요.');
textarea.focus();
return;
}
@@ -153,7 +153,7 @@
<div class="s3-message-wrapper">
<h1 class="s3-success-title">클라이언트 신청이 완료되었습니다.</h1>
<p class="s3-success-desc">
담당자 승인 후 <span class="s3-highlight">[ 정보]</span> 화면에서
담당자의 클라이언트 승인 후 <span class="s3-highlight">[클라이언트 정보]</span> 화면에서
<br>
<span class="s3-highlight">Client ID와 Client Secret을 확인</span>할 수 있습니다.
</p>