푸터 "관련 사이트" 조회 및 렌더링 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- RelatedSite/RelatedSiteService 생성 및 PortalProperty 연계
- GlobalControllerAdvice에서 셀렉트 라벨/목록 주입
- footer.html 템플릿 및 관련 JS 수정
This commit is contained in:
Rinjae
2026-08-05 15:23:59 +09:00
parent 47099ec485
commit 3b7c2e5a8f
4 changed files with 194 additions and 6 deletions
@@ -12,6 +12,8 @@ import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.security.ClientGuardService;
import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice
@@ -35,6 +37,9 @@ public class GlobalControllerAdvice {
@Autowired
private AuthNoticeProperties authNoticeProperties;
@Autowired
private RelatedSiteService relatedSiteService;
@Autowired
private Environment environment;
@@ -111,4 +116,21 @@ public class GlobalControllerAdvice {
return portalPropertyService.getOrCreateProperty(
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
}
/**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/
@ModelAttribute("relatedSitesLabel")
public String relatedSitesLabel() {
return relatedSiteService.getLabel();
}
/**
* 푸터 관련 사이트 목록. PortalProperty(Portal/footer.related-sites)의 '이름=URL' 줄 목록을 파싱한 결과.
* 비어 있으면 푸터에서 셀렉트 자체를 렌더하지 않는다.
*/
@ModelAttribute("relatedSites")
public List<RelatedSite> relatedSites() {
return relatedSiteService.getSites();
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.footer;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
/**
* 푸터 "관련 사이트" 셀렉트에 노출되는 사이트 한 건.
*
* <p>{@link RelatedSiteService} 가 PortalProperty 문자열을 파싱해 만든다.</p>
*/
@Getter
@ToString
@AllArgsConstructor
public class RelatedSite {
/** 셀렉트에 표시되는 이름 (예: 신한은행) */
private final String name;
/** 이동 대상 URL (http/https 절대주소 또는 `/` 로 시작하는 사이트 상대주소) */
private final String url;
}
@@ -0,0 +1,131 @@
package com.eactive.apim.portal.djb.footer;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 푸터 "관련 사이트" 셀렉트의 라벨과 목록을 DB(PortalProperty)에서 조회한다.
*
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
* 의 자동 생성(self-seed)이 동작하도록 한다.</p>
*
* <ul>
* <li>{@code footer.related-sites.label} - 셀렉트 첫 항목(플레이스홀더) 문구</li>
* <li>{@code footer.related-sites} - 사이트 목록. 한 줄에 하나씩 {@code 이름=URL}</li>
* </ul>
*
* <h3>목록 표기 규칙</h3>
* <pre>
* # 주석 (# 으로 시작하는 줄은 무시)
* 신한은행=http://www.shinhan.com
* 제주은행 = https://www.jejubank.co.kr ← = 앞뒤 공백 허용
* </pre>
* <ul>
* <li>빈 줄 · {@code #} 로 시작하는 줄은 건너뛴다.</li>
* <li>줄 순서가 곧 화면 노출 순서다.</li>
* <li>URL 에 {@code =} 가 들어가도 <b>첫 번째</b> {@code =} 만 구분자로 쓰므로 안전하다.</li>
* <li>{@code =} 가 없거나 이름/URL 이 비었거나 허용되지 않은 스킴이면 그 줄만 버리고 WARN 로그를
* 남긴다. (한 줄이 잘못돼도 나머지 사이트는 정상 노출)</li>
* </ul>
*
* <p>허용 스킴을 {@code http/https} 와 사이트 상대경로로 제한하는 이유는, 이 값이 관리자 화면에서
* 편집되어 그대로 앵커/스크립트 이동 대상이 되기 때문이다({@code javascript:} 등 차단).</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RelatedSiteService {
private static final String GROUP = "Portal";
static final String NAME_LABEL = "footer.related-sites.label";
static final String NAME_SITES = "footer.related-sites";
static final String DESC_LABEL = "푸터 관련 사이트 셀렉트 라벨(첫 항목 문구)";
static final String DESC_SITES = "푸터 관련 사이트 목록. 한 줄에 하나씩 '이름=URL' (빈 줄·# 주석 무시)";
static final String DEFAULT_LABEL = "DJBank 관련 사이트";
/** 기본값: 신한금융그룹 Family site (shinhangroup.com 하단 목록 기준) */
static final String DEFAULT_SITES = String.join("\n",
"신한은행=http://www.shinhan.com",
"신한카드=http://www.shinhancard.com",
"신한투자증권=http://www.shinhansec.com",
"신한라이프=http://www.shinhanlife.co.kr",
"신한캐피탈=http://www.shcap.co.kr",
"신한자산운용=https://www.shinhanfund.com",
"제주은행=https://www.jejubank.co.kr",
"신한저축은행=http://www.shinhansavings.co.kr",
"신한자산신탁=http://www.shinhantrust.kr",
"신한DS=http://www.shinhansys.co.kr",
"신한펀드파트너스=https://www.shinhanfundpartners.com",
"신한리츠운용=http://shinhanrem.com",
"신한벤처투자=http://www.shinhanvc.com",
"신한EZ손해보험=http://www.shinhanez.co.kr",
"신한장학재단=http://www.shsf.or.kr",
"신한금융희망재단=http://www.shinhanfoundation.or.kr");
private final PortalPropertyService portalPropertyService;
/** 셀렉트 첫 항목에 노출할 라벨 */
@Transactional
public String getLabel() {
String label = portalPropertyService.getOrCreateProperty(GROUP, NAME_LABEL, DEFAULT_LABEL, DESC_LABEL);
return (label == null || label.trim().isEmpty()) ? DEFAULT_LABEL : label.trim();
}
/** 셀렉트에 노출할 사이트 목록. 파싱 결과가 없으면 빈 리스트(푸터에서 셀렉트 미노출) */
@Transactional
public List<RelatedSite> getSites() {
return parse(portalPropertyService.getOrCreateProperty(GROUP, NAME_SITES, DEFAULT_SITES, DESC_SITES));
}
/**
* {@code 이름=URL} 줄 목록을 파싱한다. 잘못된 줄은 건너뛴다.
*/
static List<RelatedSite> parse(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return Collections.emptyList();
}
List<RelatedSite> sites = new ArrayList<>();
for (String rawLine : raw.split("\\r?\\n")) {
String line = rawLine.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int sep = line.indexOf('=');
if (sep <= 0) {
log.warn("관련 사이트 설정 형식 오류(= 구분자 없음) - 해당 줄 무시: {}", line);
continue;
}
String name = line.substring(0, sep).trim();
String url = line.substring(sep + 1).trim();
if (name.isEmpty() || url.isEmpty()) {
log.warn("관련 사이트 설정 형식 오류(이름 또는 URL 없음) - 해당 줄 무시: {}", line);
continue;
}
if (!isAllowedUrl(url)) {
log.warn("관련 사이트 설정 URL 스킴 불허(http/https 또는 / 로 시작해야 함) - 해당 줄 무시: {}", line);
continue;
}
sites.add(new RelatedSite(name, url));
}
return sites;
}
private static boolean isAllowedUrl(String url) {
String lower = url.toLowerCase();
return lower.startsWith("http://") || lower.startsWith("https://") || url.startsWith("/");
}
}
@@ -24,18 +24,31 @@
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<div class="footer-right">
<div class="footer-related-sites">
<select class="related-sites-select">
<option>DJBank 관련 사이트</option>
<option>DJBank 홈페이지</option>
<option>DJBank 인터넷뱅킹</option>
<option>DJBank 모바일뱅킹</option>
<!-- 관련 사이트: PortalProperty(Portal/footer.related-sites[.label])로 제어. 목록이 비면 미노출 -->
<div class="footer-related-sites" th:if="${!#lists.isEmpty(relatedSites)}">
<select class="related-sites-select" data-related-sites th:aria-label="${relatedSitesLabel}">
<option value="" th:text="${relatedSitesLabel}">DJBank 관련 사이트</option>
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
<script th:inline="none">
// 관련 사이트 셀렉트: 선택 시 새 창으로 이동 후 라벨(첫 항목)로 되돌린다.
(function () {
document.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.hasAttribute || !el.hasAttribute('data-related-sites')) return;
var url = el.value;
el.selectedIndex = 0;
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
});
})();
</script>
</footer>
</body>