18 Commits

Author SHA1 Message Date
Rinjae fc96c13df2 - 로그인 중복 세션 확인 및 기존 세션 강제 로그아웃 처리 추가
- 세션 타이머 데스크톱·모바일 표시 및 경고 스타일 동기화
2026-06-19 18:06:40 +09:00
Rinjae 7265ad6589 - 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정
- PTL_USER_SESSION 기반 세션 검증 필터/API 도입
- 로그인 시 중복 세션 강제 로그아웃 처리
- 세션 테이블 DDL 및 로그인 안내 메시지 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:45:37 +09:00
Rinjae fe4b176512 CSS source map 경로 수정 및 매핑 데이터 규칙 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-19 10:27:17 +09:00
Rinjae 17c27e4398 문의 상태값 수정: IN_PROGRESS → RESPONDED
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 문의 상태 필드 및 관련 로직/뷰 업데이트
- Validation 패턴 및 테스트 코드 반영
- 뷰 상태 배지 및 응답 섹션 표시 로직 수정
2026-06-19 10:16:16 +09:00
Rinjae 10ff5d6f2f 마스킹 표준화 적용 (eapim-admin/portal/JS)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin: elink-online-core 표준 위임(e메일/이름/전화/IP)으로 규칙 일원화
- portal/JS: 표준 로직 직접 구현 + 기존 owner 비마스킹 유지
- 호출처 무수정/인라인 중복 제거/테스트 통과
2026-06-18 17:56:32 +09:00
Rinjae-gf63 f671f491b7 - PortalUser-테이블명 대소문자 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Credential-EqualsAndHashCode 어노테이션 추가
- 사업제휴신청 메시지 수정 및 파일 null 검사 추가
2026-06-18 16:28:22 +09:00
Rinjae-gf63 3d68589ae7 - 불필요 로그 설정 및 springProfile(gf63) 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "사업 제휴 문의" 텍스트를 "피드백/개선요청"으로 변경
- 과금 관리 관련 링크 제거
2026-06-18 16:03:03 +09:00
rinjae 7268aef211 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:51:14 +09:00
rinjae 85b2f01ce0 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:43:24 +09:00
rinjae 0f255d7337 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:17:31 +09:00
rinjae a8323e78f5 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:11:00 +09:00
Rinjae fa8fce4f2d Jenkinsfile 추가 및 CI/CD 스크립트 통합
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기존 *.sh 스크립트 제거 및 Jenkinsfile.deploy로 통합
- 테스트 전용 Jenkinsfile.test-build 추가
2026-06-18 11:35:12 +09:00
Rinjae 4e7044b9fe 폰트 패밀리 정의 분리 및 웹폰트 리소스 경로 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OneShinhan, Spoqa Han Sans Neo 등 역할별 폰트 패밀리 분리
- 제목/본문 영역에 맞는 폰트 설정 및 재정의
- 웹폰트 리소스 URL 경로 및 성능 최적화
2026-06-17 20:47:01 +09:00
Rinjae bddd7e3f85 공지사항/Q&A 개선 - 장애 점검 정보 노출 및 상단고정 정렬 - Q&A 이벤트 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-16 14:54:02 +09:00
Rinjae 71f4cb6f79 Q&A 이벤트 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryCommentCreatedAdminEvent 삭제 및 경로 변경
- userName 정보 추가 및 description 업데이트
- inquirySubject 처리 로직 개선
2026-06-15 18:57:51 +09:00
Rinjae 171feee9ea Q&A 댓글 관련 코드 리팩토링 및 역할 기반 알림 기능 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryComment 코드 구조 리팩토링 및 역할 분리
- portal-admin 알림 기능 추가 및 예외 처리 개선
- InquiryComment 관련 불필요 클래스/메서드 제거
2026-06-15 18:49:14 +09:00
Rinjae 3f664bcd5c Q&A 댓글 기능 추가 및 첨부파일 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- djb 패키지 분리 - 댓글 CRUD/권한/알림 일체
- 같은 법인 공유 - 상세/댓글 조회 정책 일원화
- 작성자명 매핑 - PortalUser/TSEAIRM02 양쪽 조회
- 목록 - 댓글 수 표시 및 첨부 UI/로직 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 12:09:39 +09:00
Rinjae c77c0a2f91 ci - 워크플로 정비 및 단위테스트 안정화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gitea Generic Package 업로드 단계 추가
- 단위테스트, e2e 워크플로 분리
- systemd 유닛 동기화 단계 제거
- checkout step 이름·주석 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 21:06:11 +09:00
98 changed files with 2901 additions and 719 deletions
+45 -10
View File
@@ -1,10 +1,6 @@
pipeline {
agent { label 'djb-vm' }
parameters {
string(name: 'BRANCH', defaultValue: 'master', description: 'Branch to deploy')
}
options {
timestamps()
disableConcurrentBuilds()
@@ -23,16 +19,13 @@ pipeline {
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
DEPLOY_HTTP_PORT = '39130'
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
}
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.BRANCH}"]],
userRemoteConfigs: [[url: 'ssh://git@172.30.1.50:2222/djb-eapim/eapim-portal.git']]
])
checkout scm
}
}
@@ -43,6 +36,7 @@ pipeline {
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
@@ -54,6 +48,7 @@ pipeline {
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
@@ -76,9 +71,21 @@ pipeline {
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps {
sh 'gradle clean build -x test --no-daemon'
sh 'gradle build -x test --no-daemon'
}
post {
success {
@@ -179,4 +186,32 @@ pipeline {
}
}
}
post {
success {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
'''
}
failure {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
'''
}
}
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
echo "========================"
java -version
javac -version
echo "========================"
gradle --version
echo "========================"
gradle clean build -x test --no-daemon
+5 -5
View File
@@ -150,11 +150,11 @@ compileJava {
}
processResources {
exclude { details ->
details.file.name.startsWith('application-') &&
details.file.name.endsWith('.yml') &&
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
}
// exclude { details ->
// details.file.name.startsWith('application-') &&
// details.file.name.endsWith('.yml') &&
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
// }
}
test {
-3
View File
@@ -1,3 +0,0 @@
./gradlew bootWar
docker build -t eactive-portal .
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
# Unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
echo "deploy successful"
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
STATIC_DIR="/app/apim/deploy/static"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$STATIC_DIR" ]; then
echo "Backing up existing static directory..."
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
fi
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
mkdir -p $STATIC_DIR
# unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
mv $DEST_DIR/static/* $STATIC_DIR/
echo "deploy successful"
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
# gradle 명령을 대체합니다.
command gradle $@
Vendored Regular → Executable
View File
@@ -0,0 +1,36 @@
-- =============================================================================
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
-- =============================================================================
CREATE TABLE PTL_USER_SESSION
(
SESSION_ID VARCHAR2(128) NOT NULL,
USER_ID VARCHAR2(36) NOT NULL,
LOGIN_ID VARCHAR2(200) NOT NULL,
LOGIN_TIME TIMESTAMP(6) NOT NULL,
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
IP_ADDRESS VARCHAR2(45),
USER_AGENT VARCHAR2(500),
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
);
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
-- COMMIT;
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IncidentAffectedApiDTO {
private String apiId;
private String apiName;
}
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PortalNoticeDTO {
public static final String NOTICE_TYPE_NORMAL = "1";
public static final String NOTICE_TYPE_MAINTENANCE = "2";
public static final String NOTICE_TYPE_INCIDENT = "3";
private String id;
@NotEmpty(message = "제목을 입력해주세요.")
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
private boolean useYn;
private String fixYn;
private String noticeType;
private String inquirerName;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
// ───── 장애/점검 연동 필드 ─────
private String summary;
private LocalDateTime startedAt;
private LocalDateTime endAt;
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
}
public boolean isMaintenanceType() {
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
}
public boolean isIncidentOrMaintenance() {
return isIncidentType() || isMaintenanceType();
}
}
@@ -1,8 +1,12 @@
package com.eactive.apim.portal.apps.community.notice.service;
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service("portalNoticeFacade")
@RequiredArgsConstructor
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeService portalNoticeService;
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
PortalNoticeSearch search = new PortalNoticeSearch();
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
// Pageable pageable = Pageable.ofSize(3);
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
return notices.getContent();
}
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
Pageable fixedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
fixYnFirst.and(pageable.getSort())
);
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
return noticesPage.map(portalNoticeMapper::map);
}
@Override
public PortalNoticeDTO getNotice(String id) {
PortalNotice portalNotice = portalNoticeService.getNotice(id);
return portalNoticeMapper.map(portalNotice);
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
populateIncident(dto);
return dto;
}
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
dto.setAffectedApis(apis);
}
}
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
httpServletRequest.getSession().removeAttribute("previousPage");
return "redirect:/partnership";
@@ -27,7 +27,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
FileInfo file = null;
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
@@ -6,7 +6,11 @@ import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -29,9 +33,12 @@ public class InquiryController {
public static final String APPS_COMMUNITY_MAIN_INQUIRY_FORM = "apps/community/mainInquiryForm";
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
private final InquiryFacade inquiryFacade;
private final InquiryCommentFacade inquiryCommentFacade;
public InquiryController(InquiryFacade inquiryFacade) {
public InquiryController(InquiryFacade inquiryFacade,
InquiryCommentFacade inquiryCommentFacade) {
this.inquiryFacade = inquiryFacade;
this.inquiryCommentFacade = inquiryCommentFacade;
}
/**
@@ -42,8 +49,13 @@ public class InquiryController {
Model model) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
Page<InquiryDTO> inquiries = inquiryFacade.getInquiries(search, pageable, user);
List<String> ids = inquiries.getContent().stream()
.map(InquiryDTO::getId)
.collect(Collectors.toList());
Map<String, Long> commentCounts = inquiryCommentFacade.countActiveByInquiryIds(ids);
model.addAttribute("inquiries", inquiries.getContent());
model.addAttribute("page", inquiries);
model.addAttribute("commentCounts", commentCounts);
return "apps/community/mainInquiryList";
}
@@ -57,8 +69,9 @@ public class InquiryController {
return REDIRECT_INQUIRY;
}
InquiryDTO inquiry = inquiryFacade.getMyInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
InquiryDTO inquiry = inquiryFacade.getAccessibleInquiry(SecurityUtil.getPortalAuthenticatedUser(), id);
model.addAttribute("inquiry", inquiry);
model.addAttribute("commentWritable", !"CLOSED".equals(inquiry.getInquiryStatus()));
return "apps/community/mainInquiryDetail";
}
@@ -5,13 +5,10 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.NotEmpty;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@@ -28,7 +25,7 @@ public class InquiryDTO {
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
private String inquiryDetail;
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
private String inquiryStatus;
private String inquirerName;
@@ -50,8 +47,6 @@ public class InquiryDTO {
private LocalDateTime lastModifiedDate;
private List<MultipartFile> files;
private String attachFile;
private String maskedInquirerName;
@@ -15,6 +15,8 @@ public interface InquiryFacade {
InquiryDTO getMyInquiry(PortalAuthenticatedUser user, String id);
InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id);
void createInquiry(InquiryDTO inquiryDTO) throws IOException;
void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException;
@@ -6,12 +6,10 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -20,14 +18,15 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class InquiryFacadeImpl implements InquiryFacade {
private final FileService fileService;
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final InquiryAdminNotifier inquiryAdminNotifier;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -79,55 +78,30 @@ public class InquiryFacadeImpl implements InquiryFacade {
return inquiryMapper.map(inquiry);
}
@Override
public InquiryDTO getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(user, id);
return inquiryMapper.map(inquiry);
}
@Override
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
FileInfo file = null;
if (!inquiryDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
file = fileService.createFile(inquiryDTO.getFiles());
} finally {
FileService.clearInternalUserContext();
}
}
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
if (file != null) {
inquiry.setAttachFile(file.getFileId());
}
inquiry.setInquirer(SecurityUtil.getPortalAuthenticatedUser());
inquiry.setInquirer(current);
inquiryService.createInquiry(inquiry);
HashMap<String, Object> params = new HashMap<>();
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("subject", inquiry.getInquirySubject());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
public void updateUserInquiry(PortalAuthenticatedUser user, String id, InquiryDTO inquiryDTO) throws IOException {
Inquiry existingInquiry = inquiryService.getMyInquiry(user, id);
if (!inquiryDTO.getFiles().isEmpty() && !inquiryDTO.getFiles().get(0).isEmpty()) {
try {
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
FileTypeContext.setFileType("qna");
FileInfo file = fileService.createFile(inquiryDTO.getFiles());
if(file != null) {
inquiryDTO.setAttachFile(file.getFileId());
}
} finally {
FileService.clearInternalUserContext();
}
} else if (existingInquiry.getAttachFile() != null && inquiryDTO.getAttachFile() == null) {
fileService.deleteFile(inquiryDTO.getAttachFile());
inquiryDTO.setAttachFile(null);
}
inquiryDTO.setAttachFile(existingInquiry.getAttachFile());
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(existingInquiry.getInquirer());
@@ -4,6 +4,9 @@ package com.eactive.apim.portal.apps.community.qna.service;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -90,5 +93,36 @@ public class InquiryService {
return inquiryRepository.findByInquirerAndId(user, id).orElseThrow(() -> new NotFoundException("내 질문을 찾을 수 없습니다."));
}
/**
* 본인 또는 같은 법인 소속 이용자가 작성한 Q&A를 조회한다.
* - ROLE_USER: 본인 글만 접근 가능
* - ROLE_CORP_*: 같은 법인 소속 이용자가 작성한 글까지 접근 가능
*/
@Transactional(readOnly = true)
public Inquiry getAccessibleInquiry(PortalAuthenticatedUser user, String id) {
Inquiry inquiry = getInquiry(id);
if (!isAccessibleByUser(user, inquiry)) {
throw new NotFoundException("문의글을 찾을 수 없습니다. id=" + id);
}
return inquiry;
}
private boolean isAccessibleByUser(PortalAuthenticatedUser user, Inquiry inquiry) {
PortalUser inquirer = inquiry.getInquirer();
if (inquirer == null || user == null) {
return false;
}
if (user.getId() != null && user.getId().equals(inquirer.getId())) {
return true;
}
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
return false;
}
PortalOrg userOrg = user.getPortalOrg();
PortalOrg inquirerOrg = inquirer.getPortalOrg();
return userOrg != null && inquirerOrg != null
&& userOrg.getId() != null
&& userOrg.getId().equals(inquirerOrg.getId());
}
}
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,156 @@
package com.eactive.apim.portal.apps.session.service;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진).
*/
@Scheduled(fixedRate = 300000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
}
return null;
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
}
@@ -1,10 +1,24 @@
package com.eactive.apim.portal.common.util;
import java.util.Arrays;
/**
* 문자열 마스킹 처리를 위한 유틸리티 클래스
*
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
*
* <pre>
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-1234→010-12**-12**)
* </pre>
*/
public class StringMaskingUtil {
private static final char MASK = '*';
// ID 비교
private static boolean isOwner(String ownerId, String currentUserId) {
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
@@ -21,8 +35,15 @@ public class StringMaskingUtil {
return name;
}
return name.length() > 1 ?
name.substring(0, name.length() - 1) + "*" : name;
int len = name.length();
if (len == 1) {
return name;
}
if (len == 2) {
return name.charAt(0) + String.valueOf(MASK);
}
// 3자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
}
// 이메일 마스킹 처리
@@ -31,32 +52,32 @@ public class StringMaskingUtil {
return email;
}
String[] parts = email.split("@", 2);
if (parts.length != 2) {
return email;
int atIdx = email.indexOf('@');
String local = email.substring(0, atIdx);
String domain = email.substring(atIdx);
if (local.length() <= 4) {
return stars(local.length()) + domain;
}
String localPart = parts[0];
String maskedLocal;
if (localPart.length() <= 2) {
maskedLocal = "***";
} else {
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
}
return maskedLocal + "@" + parts[1];
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
}
// 휴대폰 번호 마스킹 처리
// 휴대폰/전화/FAX 번호 마스킹 처리
public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
return number;
}
String[] parts = number.split("-", 3);
return parts.length == 3 ?
parts[0] + "-***-" + parts[2] : number;
boolean hasHyphen = number.contains("-");
String normalized = hasHyphen ? number : normalizePhoneNumber(number);
String[] parts = normalized.split("-");
if (parts.length == 3) {
String m0 = parts[0];
String m1 = maskSegmentTail(parts[1], 2);
String m2 = maskSegmentTail(parts[2], 2);
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
}
return number;
}
// 기존 메서드 오버로딩 (하위 호환성)
@@ -71,4 +92,41 @@ public class StringMaskingUtil {
public static String maskMobileNumber(String number) {
return maskMobileNumber(number, null, null);
}
}
// =========================================================================
// private helpers
// =========================================================================
private static String stars(int count) {
if (count <= 0) return "";
char[] arr = new char[count];
Arrays.fill(arr, MASK);
return new String(arr);
}
private static String maskSegmentTail(String segment, int count) {
if (segment.length() <= count) return stars(segment.length());
return segment.substring(0, segment.length() - count) + stars(count);
}
/**
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
* <pre>
* 02 + 7자리 → 02-XXX-XXXX
* 02 + 8자리 → 02-XXXX-XXXX
* 0XX + 7자리 → 0XX-XXX-XXXX
* 0XX + 8자리 → 0XX-XXXX-XXXX
* </pre>
*/
private static String normalizePhoneNumber(String digits) {
int len = digits.length();
if (digits.startsWith("02")) {
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
} else 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 digits;
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
@@ -49,6 +50,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -112,6 +114,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -10,9 +11,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -33,15 +33,30 @@ public class PortalConfigSecurity {
private final PortalLogoutSuccessHandler logoutSuccessHandler;
private final SessionValidationFilter sessionValidationFilter;
@Autowired
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
PortalAuthenticationManager portalAuthenticationManager,
PortalLogoutSuccessHandler logoutSuccessHandler) {
PortalLogoutSuccessHandler logoutSuccessHandler,
SessionValidationFilter sessionValidationFilter) {
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.portalAuthenticationManager = portalAuthenticationManager;
this.logoutSuccessHandler = logoutSuccessHandler;
this.sessionValidationFilter = sessionValidationFilter;
}
/**
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
*/
@Bean
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
@@ -79,17 +94,15 @@ public class PortalConfigSecurity {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
)
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Allow only one session per user
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
.expiredUrl("/login?expired") // Redirect when session expires
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
.and()
.sessionFixation()
.changeSessionId()
);
)
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
} catch (Exception e) {
@@ -97,11 +110,6 @@ public class PortalConfigSecurity {
}
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import org.slf4j.Logger;
@@ -26,12 +27,21 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final UserSessionService userSessionService;
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
// DB 세션 레코드 정리 (중복세션 오탐 방지)
userSessionService.removeSession(session.getId());
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.djb.community.qna.comment.controller;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryCommentFacade;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/djb/inquiry")
@Secured("ROLE_INQUIRY")
@RequiredArgsConstructor
public class InquiryCommentController {
private final InquiryCommentFacade inquiryCommentFacade;
@GetMapping("/{inquiryId}/comments")
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
return ResponseEntity.ok(inquiryCommentFacade.getComments(inquiryId, current));
}
@PostMapping("/{inquiryId}/comments")
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
@Valid @RequestBody InquiryCommentCreateRequest request) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(inquiryId, request.getContent(), current);
return ResponseEntity.ok(created);
}
@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> delete(@PathVariable String commentId) {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
inquiryCommentFacade.deleteOwnComment(commentId, current);
return ResponseEntity.noContent().build();
}
@ExceptionHandler(InquiryClosedException.class)
public ResponseEntity<Map<String, String>> handleClosed(InquiryClosedException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorBody("INQUIRY_CLOSED", ex.getMessage()));
}
@ExceptionHandler(InquiryCommentNotOwnedException.class)
public ResponseEntity<Map<String, String>> handleNotOwned(InquiryCommentNotOwnedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
}
private Map<String, String> errorBody(String code, String message) {
Map<String, String> body = new HashMap<>();
body.put("code", code);
body.put("message", message);
return body;
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.djb.community.qna.comment.dto;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class InquiryCommentCreateRequest {
@NotBlank(message = "댓글 내용을 입력해주세요.")
@Size(max = 2000, message = "댓글은 2000자를 초과할 수 없습니다.")
private String content;
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.djb.community.qna.comment.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InquiryCommentDTO {
private String id;
private String content;
private String writerId;
private String writerName;
private String adminYn;
private LocalDateTime createdDate;
private boolean deletable;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface InquiryCommentRepository extends JpaRepository<InquiryComment, String> {
List<InquiryComment> findByInquiry_IdAndDelYnOrderByCreatedDateAsc(String inquiryId, String delYn);
@Query("select c.inquiry.id, count(c) from InquiryComment c"
+ " where c.inquiry.id in :inquiryIds and c.delYn = :delYn"
+ " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@Param("delYn") String delYn);
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InquiryAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
if (admins == null || admins.isEmpty()) {
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
return;
}
for (UserInfo admin : admins) {
try {
MessageRecipient recipient = toRecipient(admin);
messageHandlerService.publishEvent(code, recipient, params);
} catch (Exception e) {
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
admin.getUserid(), code.name(), e);
}
}
} catch (Exception e) {
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
}
}
private MessageRecipient toRecipient(UserInfo admin) {
MessageRecipient r = new MessageRecipient();
r.setUsername(admin.getUsername());
r.setUserId(admin.getEmad());
r.setPhone(admin.getCphnno());
r.setMessengerId(admin.getUserid());
return r;
}
}
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public interface InquiryCommentFacade {
List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current);
InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current);
void deleteOwnComment(String commentId, PortalAuthenticatedUser current);
Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds);
}
@@ -0,0 +1,177 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.apps.community.qna.service.InquiryService;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private static final String ADMIN_N = "N";
private static final String DEL_N = "N";
private final InquiryService inquiryService;
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final InquiryAdminNotifier adminNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@Override
@Transactional(readOnly = true)
public List<InquiryCommentDTO> getComments(String inquiryId, PortalAuthenticatedUser current) {
inquiryService.getAccessibleInquiry(current, inquiryId);
List<InquiryComment> comments = commentService.findActiveByInquiryId(inquiryId);
Map<String, String> writerNames = resolveWriterNames(comments);
return comments.stream()
.map(c -> toDto(c, current, writerNames))
.collect(Collectors.toList());
}
@Override
public InquiryCommentDTO createUserComment(String inquiryId, String content, PortalAuthenticatedUser current) {
Inquiry inquiry = inquiryService.getAccessibleInquiry(current, inquiryId);
permissionChecker.assertWritable(inquiry);
InquiryComment saved = commentService.create(inquiry, content, ADMIN_N);
publishAdminNotification(inquiry, saved, current);
Map<String, String> writerNames = new HashMap<>();
writerNames.put(current.getId(), current.getUserName());
return toDto(saved, current, writerNames);
}
@Override
@Transactional(readOnly = true)
public Map<String, Long> countActiveByInquiryIds(Collection<String> inquiryIds) {
if (inquiryIds == null || inquiryIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object[]> rows = commentRepository.countActiveGroupByInquiry(inquiryIds, DEL_N);
Map<String, Long> result = new HashMap<>();
for (Object[] row : rows) {
String inquiryId = (String) row[0];
Long count = ((Number) row[1]).longValue();
result.put(inquiryId, count);
}
return result;
}
@Override
public void deleteOwnComment(String commentId, PortalAuthenticatedUser current) {
InquiryComment comment = commentService.findById(commentId)
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
inquiryService.getAccessibleInquiry(current, comment.getInquiry().getId());
permissionChecker.assertDeletable(comment, current);
commentService.softDelete(commentId);
}
private void publishAdminNotification(Inquiry inquiry, InquiryComment comment, PortalAuthenticatedUser current) {
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
Set<String> ids = new HashSet<>();
for (InquiryComment c : comments) {
String createdBy = c.getCreatedBy();
if (createdBy != null && !createdBy.isEmpty()) {
ids.add(createdBy);
}
}
Map<String, String> map = new HashMap<>();
for (String id : ids) {
String name = lookupWriterName(id);
if (name != null && !name.isEmpty()) {
map.put(id, name);
} else {
log.warn("댓글 작성자명 조회 실패 — createdBy={}, isUuid={}", id, isUuid(id));
}
}
return map;
}
private String lookupWriterName(String createdBy) {
if (isUuid(createdBy)) {
String name = portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
}
return userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
}
String name = userInfoRepository.findById(createdBy)
.map(UserInfo::getUsername).orElse(null);
if (name != null && !name.isEmpty()) {
return name;
}
return portalUserRepository.findById(createdBy)
.map(PortalUser::getUserName).orElse(null);
}
private boolean isUuid(String value) {
if (value == null || value.length() != 36) {
return false;
}
try {
UUID.fromString(value);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private InquiryCommentDTO toDto(InquiryComment entity, PortalAuthenticatedUser current,
Map<String, String> writerNames) {
String writerId = entity.getCreatedBy();
boolean deletable = writerId != null
&& writerId.equals(current.getId())
&& entity.getInquiry() != null
&& !DjbInquiryStatus.isClosed(entity.getInquiry().getInquiryStatus());
String writerName = writerId != null ? writerNames.get(writerId) : null;
if (writerName == null || writerName.isEmpty()) {
writerName = "(알 수 없음)";
}
return InquiryCommentDTO.builder()
.id(entity.getId())
.content(entity.getCommentDetail())
.writerId(writerId)
.writerName(writerName)
.adminYn(entity.getAdminYn())
.createdDate(entity.getCreatedDate())
.deletable(deletable)
.build();
}
}
@@ -0,0 +1,46 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class InquiryCommentService {
private static final String DEL_N = "N";
private final InquiryCommentRepository repository;
@Transactional(readOnly = true)
public List<InquiryComment> findActiveByInquiryId(String inquiryId) {
return repository.findByInquiry_IdAndDelYnOrderByCreatedDateAsc(inquiryId, DEL_N);
}
public InquiryComment create(Inquiry inquiry, String content, String adminYn) {
InquiryComment comment = new InquiryComment();
comment.setInquiry(inquiry);
comment.setCommentDetail(content);
comment.setAdminYn(adminYn);
comment.setDelYn(DEL_N);
return repository.save(comment);
}
public Optional<InquiryComment> findById(String commentId) {
return repository.findById(commentId);
}
public void softDelete(String commentId) {
InquiryComment comment = repository.findById(commentId)
.orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다. id=" + commentId));
comment.markDeleted();
}
}
@@ -0,0 +1,10 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbAdminRole {
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
public static final String PORTAL_ADMIN = "portal-admin";
private DjbAdminRole() {
}
}
@@ -0,0 +1,15 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbInquiryStatus {
public static final String PENDING = "PENDING";
public static final String RESPONDED = "RESPONDED";
public static final String CLOSED = "CLOSED";
private DjbInquiryStatus() {
}
public static boolean isClosed(String status) {
return CLOSED.equals(status);
}
}
@@ -0,0 +1,12 @@
package com.eactive.apim.portal.djb.community.qna.exception;
public class InquiryClosedException extends RuntimeException {
public InquiryClosedException() {
super("종료된 문의에는 댓글을 작성/삭제할 수 없습니다.");
}
public InquiryClosedException(String message) {
super(message);
}
}
@@ -0,0 +1,12 @@
package com.eactive.apim.portal.djb.community.qna.exception;
public class InquiryCommentNotOwnedException extends RuntimeException {
public InquiryCommentNotOwnedException() {
super("본인이 작성한 댓글만 삭제할 수 있습니다.");
}
public InquiryCommentNotOwnedException(String message) {
super(message);
}
}
@@ -0,0 +1,45 @@
package com.eactive.apim.portal.djb.community.qna.support;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.springframework.stereotype.Component;
@Component
public class InquiryCommentPermissionChecker {
public boolean canWrite(Inquiry inquiry) {
return inquiry != null && !DjbInquiryStatus.isClosed(inquiry.getInquiryStatus());
}
public boolean canDelete(InquiryComment comment, PortalAuthenticatedUser current) {
if (comment == null || current == null) {
return false;
}
boolean owner = comment.getCreatedBy() != null
&& comment.getCreatedBy().equals(current.getId());
boolean notClosed = comment.getInquiry() == null
|| !DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus());
return owner && notClosed;
}
public void assertWritable(Inquiry inquiry) {
if (!canWrite(inquiry)) {
throw new InquiryClosedException();
}
}
public void assertDeletable(InquiryComment comment, PortalAuthenticatedUser current) {
if (comment.getInquiry() != null
&& DjbInquiryStatus.isClosed(comment.getInquiry().getInquiryStatus())) {
throw new InquiryClosedException();
}
if (comment.getCreatedBy() == null
|| !comment.getCreatedBy().equals(current.getId())) {
throw new InquiryCommentNotOwnedException();
}
}
}
-63
View File
@@ -37,18 +37,6 @@
</encoder>
</appender>
<appender name="NEED_FIX" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/need-fix.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/need-fix.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
</encoder>
</appender>
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/http-session.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
@@ -66,10 +54,6 @@
</appender>
<logger name="eapim.portal.needfix" level="INFO" additivity="false">
<appender-ref ref="NEED_FIX" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
@@ -96,51 +80,4 @@
<springProfile name="stage,dev">
</springProfile>
<springProfile name="gf63">
<logger name="sun.rmi" level="INFO"/>
<logger name="javax" level="INFO"/>
<logger name="jdk.event.security" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="com.atomikos" level="INFO"/>
<logger name="org.apache" level="INFO"/>
<logger name="org.codehaus.groovy.vmplugin.VMPluginFactory" level="INFO"/>
<!-- <logger name="org.hibernate" level="INFO" additivity="false"/>-->
<logger name="org.thymeleaf.TemplateEngine" level="INFO"/>
<!-- <logger name="org.springframework" level="INFO" />-->
<logger name="_org.springframework.web" level="INFO" />
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="com.eactive.eapim" level="DEBUG"/>
<logger name="kjb.safedb" level="DEBUG" additivity="false">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</logger>
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</root>
<!-- <root level="DEBUG">-->
<!-- <appender-ref ref="ROLLING"/>-->
<!-- <appender-ref ref="CONSOLE"/>-->
<!-- </root>-->
</springProfile>
</configuration>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,172 @@
(function () {
'use strict';
const section = document.querySelector('.djb-comment-section');
if (!section) return;
const inquiryId = section.getAttribute('data-inquiry-id');
const closed = section.getAttribute('data-closed') === 'true';
const listEl = document.getElementById('djbCommentList');
const emptyEl = document.getElementById('djbCommentEmpty');
const countEl = document.getElementById('djbCommentCount');
const formEl = document.getElementById('djbCommentForm');
const inputEl = document.getElementById('djbCommentInput');
const counterEl = document.getElementById('djbCommentCharCounter');
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function fetchJson(url, options) {
options = options || {};
const headers = Object.assign({}, options.headers || {});
const method = (options.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
headers['X-XSRF-TOKEN'] = getCsrfToken();
}
if (options.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json;charset=UTF-8';
}
return fetch(url, Object.assign({ credentials: 'same-origin' }, options, { headers: headers }));
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(value) {
if (!value) return '';
try {
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '.' + pad(d.getMonth() + 1) + '.' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
} catch (e) {
return '';
}
}
function render(comments) {
listEl.innerHTML = '';
if (!comments || comments.length === 0) {
const li = document.createElement('li');
li.className = 'djb-comment-empty';
li.textContent = '아직 등록된 댓글이 없습니다.';
listEl.appendChild(li);
return;
}
comments.forEach(function (c) {
const li = document.createElement('li');
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
li.setAttribute('data-comment-id', c.id);
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>' : '')
+ ' </div>'
+ ' <div class="djb-comment-meta-right">'
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
+ (c.deletable ? ' <button type="button" class="djb-comment-delete-btn" data-id="' + escapeHtml(c.id) + '">삭제</button>' : '')
+ ' </div>'
+ '</div>'
+ '<div class="djb-comment-body">' + escapeHtml(c.content) + '</div>';
listEl.appendChild(li);
});
}
function load() {
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments')
.then(function (res) {
if (!res.ok) throw new Error('댓글 조회에 실패했습니다.');
return res.json();
})
.then(render)
.catch(function (err) {
console.error('[djb-comments] load error', err);
});
}
function handleSubmit() {
const content = (inputEl.value || '').trim();
if (!content) {
if (window.customPopups) window.customPopups.showAlert('댓글 내용을 입력해 주세요.');
inputEl.focus();
return;
}
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
method: 'POST',
body: JSON.stringify({ content: content })
})
.then(function (res) {
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
if (!res.ok) throw new Error('댓글 등록에 실패했습니다.');
return res.json();
})
.then(function () {
inputEl.value = '';
updateCounter();
load();
})
.catch(function (err) {
if (window.customPopups) window.customPopups.showAlert(err.message);
else alert(err.message);
});
}
function handleDelete(commentId) {
if (!commentId) return;
function doDelete() {
fetchJson('/djb/inquiry/comments/' + encodeURIComponent(commentId), { method: 'DELETE' })
.then(function (res) {
if (res.status === 403) throw new Error('본인이 작성한 댓글만 삭제할 수 있습니다.');
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 삭제할 수 없습니다.');
if (!res.ok && res.status !== 204) throw new Error('댓글 삭제에 실패했습니다.');
load();
})
.catch(function (err) {
if (window.customPopups) window.customPopups.showAlert(err.message);
else alert(err.message);
});
}
if (window.customPopups) {
window.customPopups.showConfirm('댓글을 삭제하시겠습니까?', function (ok) {
if (ok) doDelete();
});
} else if (window.confirm('댓글을 삭제하시겠습니까?')) {
doDelete();
}
}
function updateCounter() {
if (!counterEl || !inputEl) return;
const max = inputEl.getAttribute('maxlength') || '2000';
counterEl.textContent = (inputEl.value || '').length + ' / ' + max;
}
if (!closed && formEl) {
formEl.addEventListener('submit', function (e) {
e.preventDefault();
handleSubmit();
});
inputEl.addEventListener('input', updateCounter);
updateCounter();
}
listEl.addEventListener('click', function (e) {
const target = e.target;
if (target && target.classList.contains('djb-comment-delete-btn')) {
handleDelete(target.getAttribute('data-id'));
}
});
load();
})();
@@ -5,7 +5,7 @@
// Color Palette - Vibrant and Modern
// Primary colors
$primary-blue: #0049b4; // 광주은행 블루
$primary-blue: #0049b4; // Vibrant blue
$secondary-blue: #c3dfea; // Deep blue
$accent-cyan: #00D4FF; // Sky blue accent
$accent-yellow: #FFD93D; // Yellow accent
@@ -35,7 +35,8 @@ $shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
$shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
// Typography
$font-family-primary: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-primary: 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-heading: 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-mono: 'Fira Code', 'Courier New', monospace;
// Font sizes
@@ -1,130 +1,97 @@
@use '../abstracts/variables' as *;
@use '../abstracts/color-functions' as *;
@use '../abstracts/mixins' as *;
// -----------------------------------------------------------------------------
// Webfont @font-face declarations
// - OneShinhan : 제목/강조 (Light 300, Medium 400·500, Bold 700)
// - Spoqa Han Sans Neo / SpoqaHanSans : 본문 (Regular 400, Bold 700)
// - Noto Sans KR : 한글 폴백 (Variable 100900)
// -----------------------------------------------------------------------------
@font-face {
font-family: 'Spoqa Han Sans Neo';
font-weight: 700;
src: local('Spoqa Han Sans Neo Bold'),
url('/font/kjb/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
url('/font/kjb/SpoqaHanSansNeo-Bold.woff') format('woff'),
url('/font/kjb/SpoqaHanSansNeo-Bold.ttf') format('truetype')
;
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
}
@font-face {
font-family: 'Spoqa Han Sans Neo';
font-weight: 400;
src: local('Spoqa Han Sans Neo Regular'),
url('/font/kjb/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
url('/font/kjb/SpoqaHanSansNeo-Regular.woff') format('woff'),
url('/font/kjb/SpoqaHanSansNeo-Regular.ttf') format('truetype')
;
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
}
/* OneShinhan 웹폰트 서빙 (제주은행 inbank/css/font/ 원본을 정적 리소스로 호스팅)
weight 300=Light / 400&500=Medium / 700=Bold */
/* OneShinhan */
@font-face {
font-family: 'OneShinhan';
font-weight: 300;
font-style: normal;
src: local('원신한 Light'),
local('OneShinhan Light'),
font-display: swap;
src: local('OneShinhan Light'),
local('OneShinhan-Light'),
url('/font/djb/OneShinhanLight.woff') format('woff');
url('/font/shinhan/OneShinhanLight.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 400;
font-style: normal;
src: local('원신한 Medium'),
local('OneShinhan Medium'),
font-display: swap;
src: local('OneShinhan Medium'),
local('OneShinhan-Medium'),
url('/font/djb/OneShinhanMedium.woff') format('woff');
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 500;
font-style: normal;
src: local('원신한 Medium'),
local('OneShinhan Medium'),
font-display: swap;
src: local('OneShinhan Medium'),
local('OneShinhan-Medium'),
url('/font/djb/OneShinhanMedium.woff') format('woff');
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: 'OneShinhan';
font-weight: 700;
font-style: normal;
src: local('원신한 Bold'),
local('OneShinhan Bold'),
font-display: swap;
src: local('OneShinhan Bold'),
local('OneShinhan-Bold'),
url('/font/djb/OneShinhanBold.woff') format('woff');
}
/* 한글 family name alias (변수에서 '원신한' 우선 매칭 시 동일 폰트 사용) */
@font-face {
font-family: '원신한';
font-weight: 300;
font-style: normal;
src: url('/font/djb/OneShinhanLight.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 400;
font-style: normal;
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 500;
font-style: normal;
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
}
@font-face {
font-family: '원신한';
font-weight: 700;
font-style: normal;
src: url('/font/djb/OneShinhanBold.woff') format('woff');
url('/font/shinhan/OneShinhanBold.woff') format('woff');
}
// KJB PIB
/* SpoqaHanSans */
/* Spoqa Han Sans Neo */
@font-face {
font-family:'SpoqaHanSans';
font-family: 'Spoqa Han Sans Neo';
font-weight: 400;
src:url('/font/kjb/SpoqaHanSansRegular.woff') format('woff'),
url('/font/kjb/SpoqaHanSansRegular.woff2') format('woff2'),
url('/font/kjb/SpoqaHanSansRegular.ttf') format('truetype');
font-style: normal;
font-display: swap;
src: local('Spoqa Han Sans Neo Regular'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff') format('woff'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.ttf') format('truetype');
}
@font-face {
font-family:'SpoqaHanSansBold';
font-family: 'Spoqa Han Sans Neo';
font-weight: 700;
src:url('/font/kjb/SpoqaHanSansBold.woff') format('woff'),
url('/font/kjb/SpoqaHanSansBold.woff2') format('woff2'),
url('/font/kjb/SpoqaHanSansBold.ttf') format('truetype');
font-style: normal;
font-display: swap;
src: local('Spoqa Han Sans Neo Bold'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff') format('woff'),
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.ttf') format('truetype');
}
/* HGGGothicssi */
/* SpoqaHanSans (Neo 미설치 환경 폴백) */
@font-face {
font-family:'HGGGothicssi';
font-weight: 300;
src:url('/font/kjb/HGGGothicssi40g.woff') format('woff'),
url('/font/kjb/HGGGothicssi40g.woff2') format('woff2'),
url('/font/kjb/HGGGothicssi40g.ttf') format('truetype');
}
@font-face {
font-family:'HGGGothicssi';
font-family: 'SpoqaHanSans';
font-weight: 400;
src:url('/font/kjb/HGGGothicssi60g.woff') format('woff'),
url('/font/kjb/HGGGothicssi60g.woff2') format('woff2'),
url('/font/kjb/HGGGothicssi60g.ttf') format('truetype');
font-style: normal;
font-display: swap;
src: local('Spoqa Han Sans Regular'),
url('/font/spoqa-hans/SpoqaHanSansRegular.woff2') format('woff2'),
url('/font/spoqa-hans/SpoqaHanSansRegular.woff') format('woff'),
url('/font/spoqa-hans/SpoqaHanSansRegular.ttf') format('truetype');
}
@font-face {
font-family:'HGGGothicssi';
font-family: 'SpoqaHanSans';
font-weight: 700;
src:url('/font/kjb/HGGGothicssi80g.woff') format('woff'),
url('/font/kjb/HGGGothicssi80g.woff2') format('woff2'),
url('/font/kjb/HGGGothicssi80g.ttf') format('truetype');
font-style: normal;
font-display: swap;
src: local('Spoqa Han Sans Bold'),
url('/font/spoqa-hans/SpoqaHanSansBold.woff2') format('woff2'),
url('/font/spoqa-hans/SpoqaHanSansBold.woff') format('woff'),
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
}
/* Noto Sans KR (Variable) */
@font-face {
font-family: 'Noto Sans KR';
font-weight: 100 900;
font-style: normal;
font-display: swap;
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype');
}
@@ -6,26 +6,38 @@
// Basic typography styles
// -----------------------------------------------------------------------------
// Noto Sans KR Font Face
@font-face {
font-family: 'Noto Sans KR';
src: url('/font/NotoSansKR-VariableFont_wght.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
// Body text
body {
font-family: $font-family-primary;
font-size: $font-size-base;
font-weight: $font-weight-regular;
color: $text-dark;
}
// Headings
// Headings OneShinhan Medium
h1, .h1,
h2, .h2,
h3, .h3,
h4, .h4,
h5, .h5,
h6, .h6 {
font-family: $font-family-heading;
font-weight: $font-weight-medium;
}
// "-title" / "__title" 으로 끝나는 클래스(.page-title, .section-title,
// .row-cell--title, .oauth2-2legged__title ) 사실상 제목 영역이므로
// 동일하게 OneShinhan Medium 적용한다.
[class$="-title"],
[class*="-title "],
[class$="__title"],
[class*="__title "] {
font-family: $font-family-heading;
font-weight: $font-weight-medium;
}
h1, .h1 {
font-size: $font-size-5xl;
font-weight: $font-weight-extrabold;
margin-bottom: $spacing-lg;
@include respond-to('sm') {
@@ -39,7 +51,6 @@ h1, .h1 {
h2, .h2 {
font-size: $font-size-4xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
@include respond-to('sm') {
@@ -53,7 +64,6 @@ h2, .h2 {
h3, .h3 {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
margin-bottom: $spacing-md;
@include respond-to('sm') {
@@ -63,19 +73,16 @@ h3, .h3 {
h4, .h4 {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
h5, .h5 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
h6, .h6 {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
margin-bottom: $spacing-sm;
}
@@ -65,9 +65,7 @@ $apikey-border-color: #dadada;
}
.common-title {
font-family: $font-family-primary;
font-size: 22px;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
@@ -0,0 +1,256 @@
// -----------------------------------------------------------------------------
// DJBank Custom Q&A 댓글 영역 (DJPGPT0002)
// 설계서: DJPCSQ0300P 참조
// -----------------------------------------------------------------------------
// 목록 화면 제목 댓글 개수 표시 (설계서 )
.inquiry-comment-count {
display: inline-block;
margin-left: 6px;
color: #DC2626;
font-weight: 600;
font-size: 14px;
}
// 상세 헤더 우측 그룹 (작성자 + 날짜)
.inquiry-header-right {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #6B7280;
.inquiry-detail-writer {
font-weight: 600;
color: #374151;
}
.inquiry-detail-divider {
color: #D1D5DB;
}
.inquiry-detail-date {
font-size: inherit;
color: inherit;
}
}
.djb-comment-section {
margin: 40px 0 48px 0;
}
// 댓글 목록 박스 (설계서 영역)
.djb-comment-list-box {
padding: 24px 28px;
background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
min-height: 120px;
margin-bottom: 28px;
}
.djb-comment-list {
list-style: none;
padding: 0;
margin: 0;
}
.djb-comment-empty {
padding: 28px 0;
text-align: center;
font-size: 14px;
color: #9CA3AF;
}
// 댓글 (설계서 작성자 + 날짜 + Contents)
.djb-comment-item {
padding: 18px 0;
border-bottom: 1px dashed #E5E7EB;
&:first-child {
padding-top: 4px;
}
&:last-child {
border-bottom: none;
padding-bottom: 4px;
}
&--admin .djb-comment-writer {
color: #1D4ED8;
}
.djb-comment-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.djb-comment-meta-left {
display: flex;
align-items: center;
gap: 8px;
}
.djb-comment-writer {
font-size: 18px;
font-weight: 700;
color: #111827;
letter-spacing: -0.2px;
}
.djb-comment-admin-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background-color: #DBEAFE;
color: #1D4ED8;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.djb-comment-meta-right {
display: flex;
align-items: center;
gap: 12px;
}
.djb-comment-date {
color: #6B7280;
font-size: 14px;
}
.djb-comment-delete-btn {
background: none;
border: 1px solid #E5E7EB;
border-radius: 4px;
padding: 3px 10px;
font-size: 12px;
color: #6B7280;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: #FEE2E2;
border-color: #FCA5A5;
color: #B91C1C;
}
}
.djb-comment-body {
padding: 12px 16px;
background-color: #F3F4F6;
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
color: #1F2937;
white-space: pre-wrap;
word-break: break-word;
}
}
// 작성 영역 (설계서 , )
.djb-comment-form-wrapper {
display: block;
width: 100%;
margin-top: 8px;
}
.djb-comment-form {
display: block;
width: 100%;
}
textarea.djb-comment-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px 18px;
border: 1px solid #D1D5DB;
border-radius: 8px;
background-color: #F9FAFB;
font-size: 14px;
line-height: 1.6;
color: #1F2937;
resize: vertical;
min-height: 120px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&::placeholder {
color: #9CA3AF;
}
&:hover {
border-color: #9CA3AF;
}
&:focus {
border-color: #1D4ED8;
background-color: #FFFFFF;
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15);
}
}
.djb-comment-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 12px;
}
.djb-comment-char-counter {
font-size: 13px;
color: #94A3B8;
}
button.djb-comment-submit {
display: inline-block;
min-width: 140px;
padding: 12px 28px;
background-color: #1D4ED8;
color: #FFFFFF;
border: 1px solid #1D4ED8;
border-radius: 8px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.2px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
line-height: 1.2;
text-align: center;
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.18);
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
&:hover {
background-color: #1E40AF;
border-color: #1E40AF;
box-shadow: 0 3px 10px rgba(29, 78, 216, 0.28);
}
&:active {
transform: translateY(1px);
}
&:disabled {
background-color: #94A3B8;
border-color: #94A3B8;
box-shadow: none;
cursor: not-allowed;
}
}
.djb-comment-readonly-msg {
margin: 0;
padding: 16px 20px;
background-color: #F3F4F6;
border-radius: 6px;
text-align: center;
color: #6B7280;
font-size: 13px;
}
@@ -0,0 +1,43 @@
@use '../abstracts/variables' as *;
// -----------------------------------------------------------------------------
// Session Timer Component
// 인증 사용자 헤더에 남은 세션 시간을 표시. 만료 임박(<=60s) 경고색.
// -----------------------------------------------------------------------------
.session-timer {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
white-space: nowrap;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-gray;
.session-timer-icon {
font-size: $font-size-xs;
color: $text-gray;
}
.session-timer-text {
// 카운트다운 흔들림 방지
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: center;
&.session-timer-warning {
color: #e03131;
font-weight: $font-weight-bold;
}
}
}
// 모바일 헤더(햄버거 버튼 좌측) 타이머
.session-timer-mobile {
margin-right: $spacing-sm;
font-size: $font-size-xs;
.session-timer-text {
min-width: 34px;
}
}
@@ -31,7 +31,6 @@
margin: 0;
font-size: 14px;
color: #E65100;
font-weight: 500;
}
&__description {
+2
View File
@@ -35,6 +35,7 @@
@use 'components/cta' as *;
@use 'components/password-popup' as *;
@use 'components/header-auth' as *;
@use 'components/session-timer' as *;
@use 'components/tables' as *;
@use 'components/accordion' as *;
@use 'components/page-title-banner' as *;
@@ -42,6 +43,7 @@
@use 'components/pagination' as *;
@use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -569,9 +569,7 @@
}
.result-title {
font-family: $font-family-primary;
font-size: 24px;
font-weight: 700;
color: #212529;
margin-bottom: 12px;
@@ -36,9 +36,7 @@
}
.app-list-title {
font-family: $font-family-primary;
font-size: 28px;
font-weight: $font-weight-bold;
color: $text-dark;
margin: 0;
@@ -150,9 +150,7 @@
}
.hero-title {
font-family: $font-family-primary;
font-size: 46px;
font-weight: 700;
line-height: 99.94%;
color: #000000;
margin: 0;
@@ -500,9 +498,7 @@
}
.search-title {
font-family: $font-family-primary;
font-size: 36px;
font-weight: 700;
line-height: 1.3;
color: #000000;
margin: 0;
@@ -820,9 +816,7 @@
}
.section-title {
font-family: $font-family-primary;
font-size: 44px;
font-weight: 700;
line-height: 1.3;
color: #000000;
margin: 0;
@@ -988,9 +982,7 @@
}
.card-title {
font-family: $font-family-primary;
font-size: 24px;
font-weight: 700;
line-height: 1.67;
color: #2A2A2A;
margin: 0 0 20px 0;
@@ -1137,9 +1129,7 @@
}
.info-title {
font-family: $font-family-primary;
font-size: 28px;
font-weight: 400;
line-height: 1.4;
color: #000000;
margin: 0 0 $spacing-lg 0;
@@ -1402,9 +1392,7 @@
}
.section-title {
font-family: $font-family-primary;
font-size: 28px;
font-weight: 400;
line-height: 1.4;
color: #000000;
margin: 0 0 $spacing-lg 0;
@@ -1793,7 +1781,6 @@
}
.section-title {
font-family: $font-family-primary;
line-height: 1.5;
margin: 0;
@@ -2061,9 +2048,7 @@
}
.cta-title {
font-family: $font-family-primary;
font-size: 40px;
font-weight: 700;
color: #000000;
line-height: normal;
margin: 0;
@@ -31,7 +31,6 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
padding: 20px;
background-color: #fff;
color: #000;
font-family: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', sans-serif;
display: flex;
flex-direction: column;
gap: 20px;
@@ -39,7 +38,6 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
&__title {
margin: 0;
font-size: 25px;
font-weight: 700;
line-height: 1.4;
letter-spacing: -0.01em;
}
@@ -66,7 +64,6 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
&__section-title {
margin: 0;
font-size: 20px;
font-weight: 500;
line-height: 1.5;
}
@@ -130,7 +127,6 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
&__title {
font-size: 48px;
font-weight: 700;
line-height: 1;
color: $service-primary-blue;
margin: 0 0 22px 0;
@@ -689,7 +685,6 @@ $o2leg-err-fg: #a23b3b;
padding: 20px;
background-color: #FFFFFF;
color: $o2leg-text-dark;
font-family: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', sans-serif;
display: flex;
flex-direction: column;
gap: 24px;
@@ -384,6 +384,11 @@
&--pending {
background-color: #8c959f;
}
// 종료 - 짙은 회색 배경
&--closed {
background-color: #4a5560;
}
}
// Inquiry Actions
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="commentSection(inquiry, commentWritable)" class="djb-comment-section"
th:attr="data-inquiry-id=${inquiry.id},data-closed=${inquiry.inquiryStatus == 'CLOSED' ? 'true' : 'false'}">
<div class="djb-comment-list-box">
<ul class="djb-comment-list" id="djbCommentList">
<li class="djb-comment-empty" id="djbCommentEmpty">아직 등록된 댓글이 없습니다.</li>
</ul>
</div>
<div class="djb-comment-form-wrapper" th:if="${commentWritable}">
<form id="djbCommentForm" class="djb-comment-form" onsubmit="return false;">
<textarea id="djbCommentInput"
class="djb-comment-input"
rows="4"
maxlength="2000"
placeholder="내용"
required></textarea>
<div class="djb-comment-form-actions">
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
<button type="submit" class="djb-comment-submit">댓글달기</button>
</div>
</form>
</div>
<div class="djb-comment-form-wrapper djb-comment-readonly" th:unless="${commentWritable}">
<p class="djb-comment-readonly-msg">종료된 문의에는 댓글을 작성할 수 없습니다.</p>
</div>
</div>
</body>
</html>
@@ -17,28 +17,18 @@
<div class="inquiry-detail-header">
<div class="inquiry-header-top">
<span class="inquiry-status-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : 'inquiry-status-badge--pending'}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : '답변대기'}">
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
답변대기
</span>
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div>
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div>
<!-- Attachment Section -->
<div class="inquiry-detail-attachment" th:if="${!#strings.isEmpty(inquiry.attachFile)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(inquiry.attachFile)}">
<div class="attachment-item" th:each="fileDetail : ${fileInfo.getFileDetails()}">
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="inquiry-attachment-link">
<svg width="22" height="22" 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 class="attachment-label">첨부파일</span>
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
</a>
<div class="inquiry-header-right">
<span class="inquiry-detail-writer"
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">작성자</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div>
</div>
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
</div>
<!-- Inquiry Content Section -->
@@ -49,7 +39,7 @@
</div>
<!-- Admin Response Section -->
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'RESPONDED'}">
<div class="inquiry-response-section" th:if="${inquiry.inquiryStatus == 'RESPONDED' or inquiry.inquiryStatus == 'CLOSED'}">
<div class="inquiry-response-header">
<span class="response-label">관리자 답변</span>
<span class="response-date" th:text="${#temporals.format(inquiry.responseDate, 'yyyy.MM.dd')}">2025.11.02</span>
@@ -59,12 +49,15 @@
</div>
</div>
<!-- Comment Section (DJBank Custom DJPGPT0002) -->
<th:block th:replace="~{apps/community/djb/fragments-inquiry-comments :: commentSection(${inquiry}, ${commentWritable})}"></th:block>
<!-- Action Buttons -->
<div class="inquiry-detail-actions">
<div class="actions-left">
<button type="button" class="btn-inquiry-list" th:onclick="|location.href='@{/inquiry}'|">목록</button>
</div>
<div class="actions-right" th:if="${inquiry.inquiryStatus != 'RESPONDED' && inquiry.inquirer.id == #authentication.principal.id}">
<div class="actions-right" th:if="${inquiry.inquiryStatus == 'PENDING' && inquiry.inquirer.id == #authentication.principal.id}">
<form id="deleteForm" th:action="@{/inquiry/{id}/delete(id=${inquiry.id})}" method="post" style="display: none;"></form>
<button type="button" class="btn-inquiry-edit" th:onclick="|location.href='@{/inquiry/edit(id=${inquiry.id})}'|">수정</button>
<button type="button" class="btn-inquiry-delete" onclick="confirmDelete()">삭제</button>
@@ -87,10 +80,6 @@
</script>
<script>
function fn_downloadFile(fileId, fileSn) {
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
}
function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea');
textArea.innerHTML = text;
@@ -117,6 +106,7 @@
}
});
</script>
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
</th:block>
</html>
@@ -22,7 +22,7 @@
<!-- Form Content -->
<div class="register-form-container">
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" enctype="multipart/form-data" method="post" class="register-form">
th:object="${inquiry}" method="post" class="register-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
<!-- Subject Field -->
@@ -62,53 +62,6 @@
</div>
</div>
<!-- File Upload Field -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">첨부파일</span>
</div>
<div class="form-field-wrapper">
<div class="file-upload-inline">
<div class="file-input-display" id="fileInputDisplay">
<span class="file-display-text" id="fileDisplayText">선택된 파일이 없습니다</span>
<button type="button" class="btn-remove-file-inline" id="btnRemoveFile" title="파일 삭제" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<button type="button" class="btn-file-attach" onclick="document.getElementById('file').click()">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="20" height="20" fill="url(#pattern0_1161_9889)"/>
<defs>
<pattern id="pattern0_1161_9889" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_1161_9889" transform="scale(0.0217391)"/>
</pattern>
<image id="image0_1161_9889" width="46" height="46" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAuCAYAAABXuSs3AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAeRJREFUeNrsme1tgzAQhiHqAGxQRiATlGxAJ6i7QTpByQTtBrBBRiCdADYInaB0AvcsnaUTNR82GPzDr2Q5ODh6cO7LJgwWiHMeQZfgZReGYRO4LAEMreD/dYeWuQodQ/vh4ypcBK8JYC4eBMczXHGpzCXojICdB0xIwtcugX9IWx65h8kns8nyMAOWQRfjZUbG84Epj+QeYevfA/eJKPRpK3LU3K4KG+AV30ZG8OEAtEgq1Llu0L5m/qYwFYafS4WpvBDTkyrBbF7XWG1GVqTWnJuSuanGP6m18oeBcboi3UZBi+nAH3aOsCKqNCbwe4P/QjuZwB8MQiTDqnANPUE7o/Mbmw0FzInTVGT8OuVIC5xTJbaWqUQK59WVjrPHxinfgi69RegrGfluP3DcJT2PZWzoUtejirE8uAe3BN5ORIR+CGtcAZelbaJKDphR3yU0RJDOCXAAKckqFphhI5kxoavIil/22BQrU77cZNg8V+mVBPlqzokJ5KgojGQ6f1tlN2MjcwKYcNITHgbJ1sL4bSurWJTy8QFaH8c9uAf34B7cg3twD74xOK0/4hWP3OYonlUnDdTEYvK9V642G0FT8KP222ryhm0vVUt2QnvBX6fMM5wBHwfLDjl11WKdP6o/AQYA6KS7NOFJR28AAAAASUVORK5CYII="/>
</defs>
</svg>
파일첨부
</button>
<input type="hidden" th:field="*{attachFile}">
<input type="file"
id="file"
name="files"
th:accept="${isInternalUser ? '*' : '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.hwp,.gif,.jpg,.jpeg,.png'}"
style="display: none;">
</div>
<div class="form-help-text">
<p><i class="fas fa-check-circle"></i>
<span th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요.'">첨부파일은 8MB 이내로 등록해 주세요.</span>
</p>
<p>
<i class="fas fa-check-circle"></i>
문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)
</p>
</div>
</div>
</div>
</form>
</div>
@@ -134,11 +87,6 @@
<script th:inline="javascript">
$(function () {
const btnSubmit = document.querySelector('.btn-submit-form');
const fileInput = document.getElementById('file');
const fileDisplayText = document.getElementById('fileDisplayText');
const fileInputDisplay = document.getElementById('fileInputDisplay');
const btnRemoveFile = document.getElementById('btnRemoveFile');
const attachFileInput = document.querySelector('input[name="attachFile"]');
const inquiryDetailTextarea = document.getElementById('inquiryDetail');
const charCounter = document.querySelector('.char-counter');
@@ -148,13 +96,11 @@
const maxLength = inquiryDetailTextarea.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
// Character counter for textarea
inquiryDetailTextarea.addEventListener('input', function() {
const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
// Change color when near limit
if (currentLength > maxLength * 0.9) {
charCounter.style.color = '#FF6B6B';
} else {
@@ -163,15 +109,6 @@
});
}
// Show existing file if present
const existingFile = /*[[${!#strings.isEmpty(inquiry.attachFile) ? @fileService.findById(inquiry.attachFile).fileDetails[0].originalFileName + '.' + @fileService.findById(inquiry.attachFile).fileDetails[0].fileExtension : ''}]]*/ '';
if (existingFile) {
fileDisplayText.textContent = existingFile;
fileDisplayText.classList.add('has-file');
fileInputDisplay.classList.add('has-file');
btnRemoveFile.style.display = 'flex';
}
// Form submission handler
btnSubmit.addEventListener('click', function (event) {
event.preventDefault();
@@ -180,7 +117,6 @@
const inquirySubject = document.getElementById('inquirySubject').value.trim();
const inquiryDetail = document.getElementById('inquiryDetail').value;
// Validation
if (!inquirySubject) {
customPopups.showAlert('제목을 작성해 주세요.');
document.getElementById('inquirySubject').focus();
@@ -195,47 +131,6 @@
form.submit();
});
fileInput.addEventListener('change', function () {
const file = fileInput.files[0];
if (file) {
/*[# th:if="${!isInternalUser}"]*/
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
customPopups.showAlert("허용된 파일 형식이 아닙니다.\n문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.");
fileInput.value = '';
return;
}
/*[/]*/
fileDisplayText.textContent = file.name;
fileDisplayText.classList.add('has-file');
fileInputDisplay.classList.add('has-file');
btnRemoveFile.style.display = 'flex';
if (attachFileInput) {
attachFileInput.value = '';
}
}
});
// File remove handler
btnRemoveFile.addEventListener('click', function (e) {
e.stopPropagation();
fileInput.value = '';
fileDisplayText.textContent = '선택된 파일이 없습니다';
fileDisplayText.classList.remove('has-file');
fileInputDisplay.classList.remove('has-file');
btnRemoveFile.style.display = 'none';
// Clear attachFile hidden input
if (attachFileInput) {
attachFileInput.value = '';
}
});
// Focus on subject field
document.getElementById('inquirySubject').focus();
});
@@ -62,18 +62,16 @@
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
<span class="file-icon" th:if="${!#strings.isEmpty(inquiry.attachFile)}">
<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>
<span class="inquiry-comment-count"
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a>
</div>
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
<div class="row-cell" style="width: 120px;" data-label="처리상태">
<span class="inquiry-status-badge"
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : 'inquiry-status-badge--pending'}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : '답변대기'}">
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'inquiry-status-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'inquiry-status-badge--closed' : 'inquiry-status-badge--pending')}"
th:text="${inquiry.inquiryStatus == 'RESPONDED' ? '답변완료' : (inquiry.inquiryStatus == 'CLOSED' ? '종료' : '답변대기')}">
답변대기
</span>
</div>
@@ -15,10 +15,50 @@
<!-- Notice Header: Title and Date -->
<div class="notice-detail-header">
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">개인정보처리 방침 정정 공지</h2>
<h2 class="notice-detail-title">
<span th:if="${portalNotice.noticeType == '3'}"
style="display:inline-block;padding:2px 10px;margin-right:8px;border-radius:12px;font-size:14px;vertical-align:middle;background:#fde7e9;color:#c0152a;">장애</span>
<span th:if="${portalNotice.noticeType == '2'}"
style="display:inline-block;padding:2px 10px;margin-right:8px;border-radius:12px;font-size:14px;vertical-align:middle;background:#fff4d6;color:#9a6700;">점검</span>
<span th:text="${portalNotice.noticeSubject}">개인정보처리 방침 정정 공지</span>
</h2>
<span class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
</div>
<!-- 장애/점검 정보 영역 -->
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}"
style="margin:16px 0;padding:16px;background:#f7f8fa;border:1px solid #e3e6eb;border-radius:8px;">
<table style="width:100%;border-collapse:collapse;">
<colgroup>
<col style="width:120px;"><col><col style="width:120px;"><col>
</colgroup>
<tr>
<th style="text-align:left;padding:6px 8px;">시작</th>
<td style="padding:6px 8px;"
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy.MM.dd HH:mm') : '-'}">-</td>
<th style="text-align:left;padding:6px 8px;">종료</th>
<td style="padding:6px 8px;"
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy.MM.dd HH:mm') : '진행중'}">-</td>
</tr>
<tr th:if="${portalNotice.incidentType}">
<th style="text-align:left;padding:6px 8px;">상태</th>
<td colspan="3" style="padding:6px 8px;" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
</tr>
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
<th style="text-align:left;padding:6px 8px;vertical-align:top;">영향 API</th>
<td colspan="3" style="padding:6px 8px;">
<ul style="margin:0;padding-left:18px;">
<li th:each="api : ${portalNotice.affectedApis}">
<strong th:text="${api.apiId}">API_ID</strong>
<span th:if="${api.apiName != null and !api.apiName.isEmpty()}"
th:text="| - ${api.apiName}|"></span>
</li>
</ul>
</td>
</tr>
</table>
</div>
<!-- Attachment Section -->
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
@@ -58,6 +58,15 @@
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/portalnotice/detail(id=${notice.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span class="notice-fix-badge"
th:if="${notice.fixYn == 'Y'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#e5f0ff;color:#0a4ea3;font-weight:600;">고정</span>
<span class="notice-type-badge notice-type-badge--incident"
th:if="${notice.noticeType == '3'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#fde7e9;color:#c0152a;">장애</span>
<span class="notice-type-badge notice-type-badge--maintenance"
th:if="${notice.noticeType == '2'}"
style="display:inline-block;padding:2px 8px;margin-right:6px;border-radius:10px;font-size:12px;background:#fff4d6;color:#9a6700;">점검</span>
<span th:text="${notice.noticeSubject}">공지사항 제목</span>
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -19,6 +19,14 @@
<i class="fas fa-info-circle"></i>
<span>로그아웃되었습니다.</span>
</div>
<div th:if="${param.expired}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
</div>
<div th:if="${param.forceLogout}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
@@ -107,6 +115,36 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
}
function fnInit() {
let form = document.getElementById('loginForm');
if (form) {
@@ -171,7 +209,7 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
form.submit();
checkDuplicateAndLogin(form);
}
}
form.classList.add('was-validated');
@@ -189,7 +189,7 @@
처음 만나는 DJBank API
</a>
<a href="#" class="action-btn action-btn-secondary">
사업 제휴 문의
피드백/개선요청
</a>
</div>
</div>
@@ -46,7 +46,7 @@
<li><a th:href="@{/portalnotice}">공지사항</a></li>
<li><a th:href="@{/faq_list}">FAQ</a></li>
<li><a th:href="@{/inquiry}">Q&amp;A</a></li>
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
</ul>
@@ -64,6 +64,11 @@
<!-- 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>
@@ -81,7 +86,6 @@
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
<a th:href="@{/commission/manage}"><i class="fas fa-money-bill"></i>과금 관리</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
@@ -103,6 +107,10 @@
</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>
@@ -243,6 +251,148 @@
<!-- Mobile Menu & Dropdown JavaScript -->
<th:block th:fragment="headerScript">
<!-- 세션 타이머 / 유휴 자동 로그아웃 / 연장 확인 (인증 사용자만) -->
<script sec:authorize="isAuthenticated()" th:inline="javascript">
(function () {
var STATUS_URL = /*[[@{/api/session/status}]]*/ '/api/session/status';
var HEARTBEAT_URL = /*[[@{/api/session/heartbeat}]]*/ '/api/session/heartbeat';
var EXPIRED_URL = /*[[@{/login?expired=true}]]*/ '/login?expired=true';
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
// 데스크톱·모바일 타이머 텍스트를 모두 갱신 (id 대신 클래스 사용)
var timerTexts = document.querySelectorAll('.session-timer-text');
var countdownTimer = null;
var promptShown = false; // 연장 확인 모달 중복 표시 방지
var redirecting = false;
function getCookie(name) {
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? decodeURIComponent(m.pop()) : '';
}
function csrfHeaders() {
var token = getCookie('XSRF-TOKEN');
return token ? { 'X-XSRF-TOKEN': token } : {};
}
function formatTime(sec) {
if (sec < 0) sec = 0;
var m = Math.floor(sec / 60);
var s = sec % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
function render() {
if (!timerTexts.length) return;
var label = formatTime(remainingSeconds);
var warn = remainingSeconds <= WARNING_SECONDS;
for (var i = 0; i < timerTexts.length; i++) {
timerTexts[i].textContent = label;
if (warn) {
timerTexts[i].classList.add('session-timer-warning');
} else {
timerTexts[i].classList.remove('session-timer-warning');
}
}
}
function goTo(url) {
if (redirecting) return;
redirecting = true;
if (countdownTimer) clearInterval(countdownTimer);
window.location.href = url;
}
function extendSession() {
$.ajax({
url: HEARTBEAT_URL,
type: 'POST',
headers: csrfHeaders(),
success: function (data) {
if (data && data.remainingSeconds > 0) {
remainingSeconds = data.remainingSeconds;
promptShown = false;
render();
} else {
goTo(EXPIRED_URL);
}
},
error: function () {
// 연장 실패 시 만료로 처리
goTo(EXPIRED_URL);
}
});
}
function showExtendConfirm() {
if (typeof customPopups === 'undefined' || !customPopups.showConfirm) {
return; // 팝업 미로딩 시 카운트다운만으로 만료 처리
}
var $yes = $('#customConfirmYesButton');
var $no = $('#customConfirmNoButton');
var prevYes = $yes.text();
var prevNo = $no.text();
$yes.text('연장하기');
$no.text('로그아웃');
customPopups.showConfirm('세션이 곧 만료됩니다. 로그인 상태를 연장하시겠습니까?', function (extend) {
// 버튼 라벨 원복 (다른 confirm 팝업에 영향 없도록)
$yes.text(prevYes);
$no.text(prevNo);
if (extend) {
extendSession();
} else {
goTo(LOGOUT_URL);
}
});
}
function tick() {
remainingSeconds--;
if (remainingSeconds <= 0) {
goTo(EXPIRED_URL);
return;
}
if (remainingSeconds <= WARNING_SECONDS && !promptShown) {
promptShown = true;
showExtendConfirm();
}
render();
}
function pollStatus() {
$.ajax({
url: STATUS_URL,
type: 'GET',
success: function (data) {
if (!data || !data.valid) {
goTo(FORCE_LOGOUT_URL);
return;
}
remainingSeconds = data.remainingSeconds;
if (remainingSeconds > WARNING_SECONDS) {
promptShown = false; // 다른 탭/활동으로 연장된 경우 모달 재무장
}
render();
},
error: function () {
// 폴링 실패 시 로컬 카운트다운 유지
}
});
}
// 초기화
render();
countdownTimer = setInterval(tick, 1000);
setInterval(pollStatus, POLL_INTERVAL_MS);
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Mobile Drawer
@@ -0,0 +1,116 @@
package com.eactive.apim.portal.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* eapim-portal {@link StringMaskingUtil} 보안아키텍처 표준 규칙으로 마스킹하며,
* 소유자 본인 조회 시에는 마스킹하지 않는 동작을 검증한다.
*/
class StringMaskingUtilTest {
// --- 이름 ---
@Test
@DisplayName("이름 — 2자: 뒤 1자리")
void maskName_2chars() {
assertEquals("가*", StringMaskingUtil.maskName("가나"));
}
@Test
@DisplayName("이름 — 3자: 가운데 1자리")
void maskName_3chars() {
assertEquals("가*다", StringMaskingUtil.maskName("가나다"));
}
@Test
@DisplayName("이름 — 4자: 앞뒤 제외 가운데 전체")
void maskName_4chars() {
assertEquals("가**라", StringMaskingUtil.maskName("가나다라"));
}
@Test
@DisplayName("이름 — 5자: 앞뒤 제외 가운데 전체")
void maskName_5chars() {
assertEquals("가***마", StringMaskingUtil.maskName("가나다라마"));
}
@Test
@DisplayName("이름 — 1자/null/빈문자 방어")
void maskName_oneAndNull() {
assertEquals("", StringMaskingUtil.maskName(""));
assertNull(StringMaskingUtil.maskName(null));
assertEquals("", StringMaskingUtil.maskName(""));
}
// --- 이메일 ---
@Test
@DisplayName("이메일 — 아이디 앞2·뒤2 마스킹")
void maskEmail_normal() {
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com"));
}
@Test
@DisplayName("이메일 — 아이디 4자: 전체 마스킹")
void maskEmail_fourChars() {
assertEquals("****@test.com", StringMaskingUtil.maskEmail("test@test.com"));
}
@Test
@DisplayName("이메일 — 아이디 1자: 전체 마스킹")
void maskEmail_oneChar() {
assertEquals("*@123.com", StringMaskingUtil.maskEmail("1@123.com"));
}
@Test
@DisplayName("이메일 — @ 없으면 원본 / null·빈문자 방어")
void maskEmail_invalidAndNull() {
assertEquals("notanemail", StringMaskingUtil.maskEmail("notanemail"));
assertNull(StringMaskingUtil.maskEmail(null));
assertEquals("", StringMaskingUtil.maskEmail(""));
}
// --- 휴대폰/전화 ---
@Test
@DisplayName("휴대폰 — 2·3번째 세그먼트 각 뒤 2자리")
void maskMobileNumber_normal() {
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234"));
assertEquals("010-12**-56**", StringMaskingUtil.maskMobileNumber("010-1234-5678"));
}
@Test
@DisplayName("휴대폰 — 하이픈 없는 11자리")
void maskMobileNumber_noHyphen() {
assertEquals("01012**56**", StringMaskingUtil.maskMobileNumber("01012345678"));
}
@Test
@DisplayName("휴대폰 — null/빈문자 방어")
void maskMobileNumber_null() {
assertNull(StringMaskingUtil.maskMobileNumber(null));
assertEquals("", StringMaskingUtil.maskMobileNumber(""));
}
// --- owner 인지 (본인 조회 비마스킹) ---
@Test
@DisplayName("owner — 본인 조회 시 마스킹하지 않음")
void ownerAware_noMaskWhenOwner() {
assertEquals("가나다", StringMaskingUtil.maskName("가나다", "user1", "user1"));
assertEquals("test123@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user1"));
assertEquals("010-1234-1234", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user1"));
}
@Test
@DisplayName("owner — 타인 조회 시 마스킹")
void ownerAware_maskWhenNotOwner() {
assertEquals("가*다", StringMaskingUtil.maskName("가나다", "user1", "user2"));
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user2"));
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user2"));
}
}
@@ -0,0 +1,117 @@
package com.eactive.apim.portal.djb.community.qna.support;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryClosedException;
import com.eactive.apim.portal.djb.community.qna.exception.InquiryCommentNotOwnedException;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InquiryCommentPermissionCheckerTest {
private InquiryCommentPermissionChecker checker;
@BeforeEach
void setUp() {
checker = new InquiryCommentPermissionChecker();
}
private Inquiry inquiry(String status) {
Inquiry i = new Inquiry();
i.setId("inq-1");
i.setInquiryStatus(status);
return i;
}
private InquiryComment comment(String createdBy, Inquiry inquiry) {
InquiryComment c = new InquiryComment();
c.setId("cmt-1");
c.setInquiry(inquiry);
c.setCommentDetail("test");
c.setAdminYn("N");
c.setDelYn("N");
c.setCreatedBy(createdBy);
return c;
}
private PortalAuthenticatedUser user(String id) {
PortalAuthenticatedUser u = new PortalAuthenticatedUser();
u.setId(id);
u.setLoginId(id);
return u;
}
@Test
void canWrite_pendingTrue() {
assertTrue(checker.canWrite(inquiry("PENDING")));
}
@Test
void canWrite_inProgressTrue() {
assertTrue(checker.canWrite(inquiry("RESPONDED")));
}
@Test
void canWrite_closedFalse() {
assertFalse(checker.canWrite(inquiry("CLOSED")));
}
@Test
void canDelete_ownerInOpenTrue() {
Inquiry i = inquiry("RESPONDED");
InquiryComment c = comment("alice", i);
assertTrue(checker.canDelete(c, user("alice")));
}
@Test
void canDelete_otherUserFalse() {
Inquiry i = inquiry("RESPONDED");
InquiryComment c = comment("alice", i);
assertFalse(checker.canDelete(c, user("bob")));
}
@Test
void canDelete_closedFalse() {
Inquiry i = inquiry("CLOSED");
InquiryComment c = comment("alice", i);
assertFalse(checker.canDelete(c, user("alice")));
}
@Test
void assertWritable_closedThrows() {
assertThrows(InquiryClosedException.class, () -> checker.assertWritable(inquiry("CLOSED")));
}
@Test
void assertWritable_pendingOk() {
assertDoesNotThrow(() -> checker.assertWritable(inquiry("PENDING")));
}
@Test
void assertDeletable_closedThrowsClosed() {
Inquiry i = inquiry("CLOSED");
InquiryComment c = comment("alice", i);
assertThrows(InquiryClosedException.class, () -> checker.assertDeletable(c, user("alice")));
}
@Test
void assertDeletable_notOwnerThrowsNotOwned() {
Inquiry i = inquiry("RESPONDED");
InquiryComment c = comment("alice", i);
assertThrows(InquiryCommentNotOwnedException.class,
() -> checker.assertDeletable(c, user("bob")));
}
@Test
void assertDeletable_ownerInOpenOk() {
Inquiry i = inquiry("RESPONDED");
InquiryComment c = comment("alice", i);
assertDoesNotThrow(() -> checker.assertDeletable(c, user("alice")));
}
}