세션 유지 기능 추가 및 FAQ 관리 개선:
- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원 - FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`) - 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
This commit is contained in:
+13
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
public class FaqController {
|
||||
@@ -47,4 +48,16 @@ public class FaqController {
|
||||
|
||||
return "apps/community/mainFaqList";
|
||||
}
|
||||
|
||||
@GetMapping("/faq_view")
|
||||
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return "redirect:/faq_list";
|
||||
}
|
||||
|
||||
FaqDTO faq = faqFacade.getFaq(id);
|
||||
model.addAttribute("faq", faq);
|
||||
|
||||
return "apps/community/mainFaqDetail";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface FaqFacade {
|
||||
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
|
||||
|
||||
FaqDTO getFaq(String id);
|
||||
}
|
||||
|
||||
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
|
||||
return faqPage.map(faqMapper::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaqDTO getFaq(String id) {
|
||||
return faqMapper.map(faqService.findById(id));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ public class UserSessionService {
|
||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||
|
||||
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
|
||||
|
||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||
|
||||
@@ -133,6 +137,20 @@ public class UserSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
|
||||
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
|
||||
*/
|
||||
public boolean isKeepAliveEnabled() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP,
|
||||
KEEPALIVE_PROPERTY_NAME,
|
||||
DEFAULT_KEEPALIVE_ENABLED,
|
||||
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
|
||||
);
|
||||
return value != null && Boolean.parseBoolean(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
@@ -25,6 +27,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private ClientGuardService clientGuardService;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -60,6 +65,17 @@ public class GlobalControllerAdvice {
|
||||
return userSessionService.getSessionTimeoutMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
|
||||
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
|
||||
*/
|
||||
@ModelAttribute("sessionKeepAliveAllowed")
|
||||
public boolean sessionKeepAliveAllowed() {
|
||||
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
|
||||
return !prod && userSessionService.isKeepAliveEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
|
||||
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
|
||||
|
||||
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 10m
|
||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
||||
# timeout: 10m
|
||||
cookie:
|
||||
name: JSESSIONID_PORTAL
|
||||
encoding:
|
||||
@@ -314,6 +318,9 @@ page:
|
||||
faq_list:
|
||||
name: FAQ
|
||||
path: "/faq_list"
|
||||
faq_detail:
|
||||
name: FAQ
|
||||
path: "/faq_view"
|
||||
inquiry:
|
||||
name: "Q&A"
|
||||
path: "/inquiry"
|
||||
|
||||
@@ -5264,14 +5264,44 @@ select.form-control {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-timer-mobile {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
.session-float {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.session-timer-mobile .session-timer-text {
|
||||
min-width: 34px;
|
||||
.session-float .session-keepalive {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-float .session-keepalive input {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: #FFFFFF;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -79,7 +79,9 @@
|
||||
li.innerHTML = ''
|
||||
+ '<div class="djb-comment-meta">'
|
||||
+ ' <div class="djb-comment-meta-left">'
|
||||
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
||||
+ (c.adminYn === 'Y'
|
||||
? ' <span class="djb-comment-admin-badge">관리자</span>'
|
||||
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
|
||||
+ ' </div>'
|
||||
+ ' <div class="djb-comment-meta-right">'
|
||||
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
|
||||
|
||||
@@ -32,12 +32,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
|
||||
.session-timer-mobile {
|
||||
margin-right: $spacing-sm;
|
||||
font-size: $font-size-xs;
|
||||
// -----------------------------------------------------------------------------
|
||||
// 우측 상단 Float 세션 타이머 위젯
|
||||
// 데스크톱: 헤더 우측(유저 메뉴 우측)에 상단 정렬로 고정(fixed).
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 배치.
|
||||
// -----------------------------------------------------------------------------
|
||||
.session-float {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.session-timer-text {
|
||||
min-width: 34px;
|
||||
// 세션 유지 체크박스 (비운영 전용, 조건부 렌더)
|
||||
.session-keepalive {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-gray;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="user-management-container">
|
||||
<section class="user-management-container notice-list-container">
|
||||
|
||||
<!-- Search and Filter Controls -->
|
||||
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
||||
@@ -38,21 +38,36 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- FAQ Accordion -->
|
||||
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
|
||||
<div class="faq-item" th:each="faq : ${page.content}">
|
||||
<div class="faq-question">
|
||||
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
|
||||
<span class="faq-icon">
|
||||
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- FAQ Table -->
|
||||
<div class="list-table" th:if="${!#lists.isEmpty(page.content)}">
|
||||
<!-- Table Header -->
|
||||
<div class="list-table-header">
|
||||
<div class="header-cell" style="width: 80px;">NO</div>
|
||||
<div class="header-cell" style="flex: 1; min-width: 200px;">질문</div>
|
||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="list-table-body">
|
||||
<div class="list-table-row"
|
||||
th:each="faq, status : ${page.content}"
|
||||
th:onclick="'location.href=\'' + @{/faq_view(id=${faq.id})} + '\''"
|
||||
style="cursor: pointer;">
|
||||
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="질문">
|
||||
<a th:href="@{/faq_view(id=${faq.id})}" class="notice-title-link">
|
||||
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||
<span class="file-icon" th:if="${faq.fileId != null and !faq.fileId.isEmpty()}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
|
||||
답변 내용이 여기에 표시됩니다.
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||
th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,37 +110,6 @@
|
||||
document.faqForm.page.value = 1;
|
||||
document.faqForm.submit();
|
||||
}
|
||||
|
||||
function decodeHTMLEntities(text) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.innerHTML = text;
|
||||
return textArea.value;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Decode HTML entities in FAQ answers
|
||||
document.querySelectorAll('.faq-answer-content').forEach(function(element) {
|
||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||
});
|
||||
|
||||
// FAQ Accordion functionality
|
||||
document.querySelectorAll('.faq-question').forEach(function(question) {
|
||||
question.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const faqItem = this.closest('.faq-item');
|
||||
const isActive = faqItem.classList.contains('active');
|
||||
|
||||
// Close all other items (optional - for single open behavior)
|
||||
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
|
||||
// item.classList.remove('active');
|
||||
// });
|
||||
|
||||
// Toggle active state
|
||||
faqItem.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
|
||||
@@ -157,6 +157,27 @@
|
||||
|
||||
$(function () {
|
||||
|
||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
||||
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
||||
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
||||
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
||||
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
||||
var loginIdleTimer;
|
||||
function scheduleIdleRedirect() {
|
||||
clearTimeout(loginIdleTimer);
|
||||
loginIdleTimer = setTimeout(function () {
|
||||
var idEl = document.getElementById('id');
|
||||
var pwEl = document.getElementById('password');
|
||||
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
||||
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
||||
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
||||
window.location.href = /*[[@{/}]]*/ '/';
|
||||
}, LOGIN_IDLE_LIMIT_MS);
|
||||
}
|
||||
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
||||
scheduleIdleRedirect();
|
||||
|
||||
var successMsg = [[${success}]];
|
||||
console.log("Success message:", successMsg);
|
||||
if (successMsg) {
|
||||
|
||||
@@ -54,11 +54,6 @@
|
||||
<!-- Logout State (Authenticated) -->
|
||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||
<div class="header-user-info">
|
||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||
</span>
|
||||
<span class="divider">•</span>
|
||||
<div class="user-identity">
|
||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||
@@ -97,10 +92,6 @@
|
||||
</div>
|
||||
|
||||
<div class="mobile-right">
|
||||
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text">--:--</span>
|
||||
</span>
|
||||
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
@@ -113,6 +104,19 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 우측 상단 Float 세션 타이머 (인증 사용자). 모바일에선 헤더/햄버거 아래로 내려 겹침 방지 -->
|
||||
<div class="session-float" sec:authorize="isAuthenticated()">
|
||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||
<i class="fas fa-clock session-timer-icon"></i>
|
||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||
</span>
|
||||
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
||||
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
||||
<input type="checkbox" id="sessionKeepAliveToggle">
|
||||
<span>세션 유지</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Drawer/Modal -->
|
||||
<div class="mobile-drawer" id="mobileDrawer">
|
||||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||||
@@ -260,6 +264,7 @@
|
||||
var countdownTimer = null;
|
||||
var promptShown = false; // 연장 확인 모달 중복 표시 방지
|
||||
var redirecting = false;
|
||||
var keepAliveActive = false; // 세션 유지 체크 시 true → 카운트다운 정지, 무제한(∞) 표시
|
||||
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
@@ -287,6 +292,14 @@
|
||||
|
||||
function render() {
|
||||
if (!timerTexts.length) return;
|
||||
// 세션 유지 활성 시 카운트다운 대신 무제한(∞) 표시
|
||||
if (keepAliveActive) {
|
||||
for (var k = 0; k < timerTexts.length; k++) {
|
||||
timerTexts[k].textContent = '∞';
|
||||
timerTexts[k].classList.remove('session-timer-warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
var label = formatTime(remainingSeconds);
|
||||
var warn = remainingSeconds <= WARNING_SECONDS;
|
||||
for (var i = 0; i < timerTexts.length; i++) {
|
||||
@@ -350,6 +363,11 @@
|
||||
}
|
||||
|
||||
function tick() {
|
||||
// 세션 유지 활성 시 로컬 카운트다운·만료 처리 중단 (heartbeat가 세션 연장)
|
||||
if (keepAliveActive) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
remainingSeconds--;
|
||||
if (remainingSeconds <= 0) {
|
||||
goTo(EXPIRED_URL);
|
||||
@@ -387,6 +405,44 @@
|
||||
render();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
setInterval(pollStatus, POLL_INTERVAL_MS);
|
||||
|
||||
// 세션 유지(타임아웃 무시): 비운영 + DB property 활성 시에만 동작(prod는 서버가 false 주입 → 체크박스도 미렌더).
|
||||
// 체크 시 주기적 heartbeat로 세션을 계속 연장. 탭을 닫으면 heartbeat 중단 → 정상 만료.
|
||||
var KEEPALIVE_ALLOWED = /*[[${sessionKeepAliveAllowed}]]*/ false;
|
||||
var KEEPALIVE_LS_KEY = 'sessionKeepAlive';
|
||||
var KEEPALIVE_INTERVAL_MS = 60000; // 세션 타임아웃보다 충분히 짧게
|
||||
var keepAliveTimer = null;
|
||||
var keepAliveToggle = document.getElementById('sessionKeepAliveToggle');
|
||||
|
||||
function startKeepAlive() {
|
||||
keepAliveActive = true;
|
||||
render(); // 즉시 ∞ 표시
|
||||
if (keepAliveTimer) return;
|
||||
keepAliveTimer = setInterval(extendSession, KEEPALIVE_INTERVAL_MS);
|
||||
extendSession(); // 즉시 1회 연장
|
||||
}
|
||||
function stopKeepAlive() {
|
||||
keepAliveActive = false;
|
||||
if (keepAliveTimer) { clearInterval(keepAliveTimer); keepAliveTimer = null; }
|
||||
render(); // 카운트다운 표시 복귀
|
||||
pollStatus(); // 정지 중 stale 해소: 서버 잔여시간 즉시 재동기화
|
||||
}
|
||||
|
||||
if (KEEPALIVE_ALLOWED && keepAliveToggle) {
|
||||
if (localStorage.getItem(KEEPALIVE_LS_KEY) === 'true') {
|
||||
keepAliveToggle.checked = true;
|
||||
startKeepAlive();
|
||||
}
|
||||
keepAliveToggle.addEventListener('change', function () {
|
||||
if (keepAliveToggle.checked) {
|
||||
localStorage.setItem(KEEPALIVE_LS_KEY, 'true');
|
||||
startKeepAlive();
|
||||
} else {
|
||||
localStorage.removeItem(KEEPALIVE_LS_KEY);
|
||||
stopKeepAlive();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user