commit
f1ecbca216
@ -0,0 +1,44 @@
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
/.junie/
|
||||
/logs/
|
||||
/build/
|
||||
/.idea/
|
||||
/.gradle/
|
||||
/.idea/
|
||||
/src/main/UbiService/logs/
|
||||
/src/main/UbiService/results/UBIHTML/
|
||||
/src/main/webapp/ubi4/logs/
|
||||
/.vscode/settings.json
|
||||
/CLAUDE.md
|
||||
/DEV-SERVER-REPORT-FILE/UbiService/logs/
|
||||
/DEV-SERVER-REPORT-FILE/UbiService/results/UBIHTML/
|
||||
/.claude/settings.local.json
|
||||
/DEV-SERVER-REPORT-FILE/UbiService/results/
|
||||
/gradle/wrapper/gradle-wrapper.properties
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
@ -0,0 +1,4 @@
|
||||
CREATE DEFINER=`root`@`%` FUNCTION `ibmsdb`.`ECL_DECRYPT`(PIN_JNO varchar(256)) RETURNS varchar(4000) CHARSET utf8
|
||||
BEGIN
|
||||
RETURN AES_DECRYPT(UNHEX(FROM_BASE64(PIN_JNO)),'Copyright(c)2015-xit.co.kr');
|
||||
END
|
||||
@ -0,0 +1,4 @@
|
||||
CREATE DEFINER=`root`@`%` FUNCTION `ibmsdb`.`ECL_ENCRYPT`(PIN_JNO varchar(256)) RETURNS varchar(4000) CHARSET utf8
|
||||
BEGIN
|
||||
RETURN TO_BASE64(HEX(AES_ENCRYPT(PIN_JNO,'Copyright(c)2015-xit.co.kr')));
|
||||
END
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE SEQUENCE seq_file_id
|
||||
START WITH 1000
|
||||
INCREMENT BY 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9999999999999999
|
||||
CACHE 1000
|
||||
CYCLE;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
CREATE SEQUENCE seq_login_log_id
|
||||
START WITH 1000
|
||||
INCREMENT BY 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9999999999999999
|
||||
CACHE 1000
|
||||
CYCLE;
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE SEQUENCE seq_menu_id
|
||||
START WITH 1000
|
||||
INCREMENT BY 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9999999999999999
|
||||
CACHE 1000
|
||||
CYCLE;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
CREATE SEQUENCE seq_user_id
|
||||
START WITH 1000
|
||||
INCREMENT BY 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9999999999999999
|
||||
CACHE 1000
|
||||
CYCLE;
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
create table tb_cd_detail
|
||||
(
|
||||
CD_GROUP_ID varchar(20) not null comment '코드 그룹 ID',
|
||||
CD_ID varchar(20) not null comment '코드 ID',
|
||||
CD_NM varchar(100) not null comment '코드 이름',
|
||||
CD_DC varchar(200) null comment '코드 설명',
|
||||
SORT_ORDR int default 0 null comment '정렬 순서',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
ATTRIBUTE1 varchar(200) null comment '속성1',
|
||||
ATTRIBUTE2 varchar(200) null comment '속성2',
|
||||
ATTRIBUTE3 varchar(200) null comment '속성3',
|
||||
ATTRIBUTE4 varchar(200) null comment '속성4',
|
||||
ATTRIBUTE5 varchar(200) null comment '속성5',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자',
|
||||
primary key (CD_GROUP_ID, CD_ID),
|
||||
constraint fk_cd_detail_group
|
||||
foreign key (CD_GROUP_ID) references tb_cd_group (CD_GROUP_ID)
|
||||
)
|
||||
comment '코드 상세';
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
create table tb_cd_group
|
||||
(
|
||||
CD_GROUP_ID varchar(20) not null comment '코드 그룹 ID'
|
||||
primary key,
|
||||
CD_GROUP_NM varchar(100) not null comment '코드 그룹 이름',
|
||||
CD_GROUP_DC varchar(200) null comment '코드 그룹 설명',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자'
|
||||
)
|
||||
comment '코드 그룹';
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
create table tb_group
|
||||
(
|
||||
GROUP_ID varchar(20) not null comment '그룹 ID'
|
||||
primary key,
|
||||
GROUP_NM varchar(100) not null comment '그룹 이름',
|
||||
GROUP_DC varchar(200) null comment '그룹 설명',
|
||||
SORT_ORDR int default 0 null comment '정렬 순서',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자'
|
||||
)
|
||||
comment '그룹';
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
create table tb_group_role
|
||||
(
|
||||
GROUP_ID varchar(20) not null comment '그룹 ID',
|
||||
ROLE_ID varchar(20) not null comment '역할 ID',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
primary key (GROUP_ID, ROLE_ID),
|
||||
constraint fk_group_role_group
|
||||
foreign key (GROUP_ID) references tb_group (GROUP_ID),
|
||||
constraint fk_group_role_role
|
||||
foreign key (ROLE_ID) references tb_role (ROLE_ID)
|
||||
)
|
||||
comment '그룹-역할 매핑';
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
create table tb_login_log
|
||||
(
|
||||
LOG_ID varchar(20) not null comment '로그 ID - 시퀀스 기반'
|
||||
primary key,
|
||||
USER_ID varchar(20) null comment '사용자 ID',
|
||||
USER_ACNT varchar(20) null comment '사용자 계정',
|
||||
LOGIN_DTTM datetime not null comment '로그인 시간',
|
||||
IP_ADDR varchar(50) null comment 'IP 주소',
|
||||
SUCCESS_YN varchar(1) not null comment '성공 여부',
|
||||
FAIL_REASON varchar(100) null comment '실패 사유',
|
||||
LOGIN_TYPE varchar(20) default 'WEB' null comment '로그인 유형',
|
||||
SESSION_ID varchar(100) null comment '세션 ID',
|
||||
USER_AGENT varchar(500) null comment '사용자 에이전트',
|
||||
DEVICE_INFO varchar(100) null comment '접속 디바이스 정보',
|
||||
REG_DTTM datetime default current_timestamp() null comment '등록 일시'
|
||||
)
|
||||
comment '로그인 로그';
|
||||
|
||||
create index IDX_TB_LOGIN_LOG_LOGIN_DTTM
|
||||
on tb_login_log (LOGIN_DTTM);
|
||||
|
||||
create index IDX_TB_LOGIN_LOG_SUCCESS_YN
|
||||
on tb_login_log (SUCCESS_YN);
|
||||
|
||||
create index IDX_TB_LOGIN_LOG_USER_ID
|
||||
on tb_login_log (USER_ID);
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
create table tb_menu
|
||||
(
|
||||
MENU_ID varchar(20) not null comment '메뉴 ID'
|
||||
primary key,
|
||||
MENU_NM varchar(100) not null comment '메뉴 이름',
|
||||
MENU_DC varchar(200) null comment '메뉴 설명',
|
||||
UPPER_MENU_ID varchar(20) null comment '상위 메뉴 ID',
|
||||
MENU_LEVEL int not null comment '메뉴 레벨',
|
||||
SORT_ORDR int not null comment '정렬 순서',
|
||||
MENU_URL varchar(200) null comment '메뉴 URL',
|
||||
URL_PATTERN varchar(2000) null comment 'URL 패턴',
|
||||
MENU_ICON varchar(100) null comment '메뉴 아이콘',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
VIEW_YN varchar(1) default 'Y' not null comment '화면 표시 여부',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자',
|
||||
constraint fk_menu_upper
|
||||
foreign key (UPPER_MENU_ID) references tb_menu (MENU_ID)
|
||||
)
|
||||
comment '메뉴';
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
create table tb_role
|
||||
(
|
||||
ROLE_ID varchar(20) not null comment '역할 ID'
|
||||
primary key,
|
||||
ROLE_NM varchar(100) not null comment '역할 이름',
|
||||
ROLE_DC varchar(200) null comment '역할 설명',
|
||||
SORT_ORDR int default 0 null comment '정렬 순서',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자'
|
||||
)
|
||||
comment '역할';
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
create table tb_role_menu
|
||||
(
|
||||
ROLE_ID varchar(20) not null comment '역할 ID',
|
||||
MENU_ID varchar(20) not null comment '메뉴 ID',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
primary key (ROLE_ID, MENU_ID),
|
||||
constraint fk_role_menu_menu
|
||||
foreign key (MENU_ID) references tb_menu (MENU_ID),
|
||||
constraint fk_role_menu_role
|
||||
foreign key (ROLE_ID) references tb_role (ROLE_ID)
|
||||
)
|
||||
comment '역할-메뉴 매핑';
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
create table tb_user
|
||||
(
|
||||
USER_ID varchar(11) not null comment '사용자 ID'
|
||||
primary key,
|
||||
USER_ACNT varchar(20) not null comment '사용자 계정',
|
||||
USER_NM varchar(50) not null comment '사용자 이름',
|
||||
PASSWD varchar(200) not null comment '비밀번호',
|
||||
PASSWD_HINT varchar(100) null comment '비밀번호 힌트',
|
||||
PASSWD_NSR varchar(100) null comment '비밀번호 힌트 답',
|
||||
EMP_NO varchar(20) null comment '사원 번호',
|
||||
GENDER varchar(1) null comment '성별',
|
||||
ZIP varchar(6) null comment '우편번호',
|
||||
ADDR varchar(150) null comment '주소',
|
||||
DADDR varchar(150) null comment '상세주소',
|
||||
AREA_NO varchar(10) null comment '지역 번호',
|
||||
EML_ADDR varchar(50) null comment '이메일 주소',
|
||||
ORG_CD varchar(20) null comment '조직 CD',
|
||||
USER_GROUP_ID varchar(20) null comment '그룹 ID',
|
||||
NSTT_CD varchar(8) not null comment '소속기관 코드',
|
||||
POS_NM varchar(60) null comment '직위 이름',
|
||||
CRTFC_DN varchar(20) null comment '인증 DN값',
|
||||
USER_STATUS_CD varchar(20) not null comment '사용자 상태',
|
||||
FXNO varchar(20) null comment '팩스번호',
|
||||
TELNO varchar(20) null comment '전화번호',
|
||||
MBL_TELNO varchar(20) null comment '휴대 전화번호',
|
||||
BRDT varchar(20) null comment '생년월일',
|
||||
DEPT_CD varchar(7) null comment '부서 코드',
|
||||
USE_YN varchar(1) not null comment '사용 여부',
|
||||
RSDNT_NO varchar(200) null comment '주민등록 번호',
|
||||
PASSWD_INIT_YN varchar(1) default 'N' null comment '비밀번호 초기화 여부',
|
||||
LOCK_YN varchar(1) null comment '잠김 여부',
|
||||
LOCK_CNT int not null comment '잠김 횟수',
|
||||
LOCK_DTTM datetime null comment '잠김 일시',
|
||||
REG_DTTM datetime null comment '등록 일시',
|
||||
RGTR varchar(20) null comment '등록자',
|
||||
MDFCN_DTTM datetime null comment '수정 일시',
|
||||
MDFR varchar(20) null comment '수정자',
|
||||
constraint tb_user_unique
|
||||
unique (USER_ACNT),
|
||||
constraint fk_user_group
|
||||
foreign key (USER_GROUP_ID) references tb_group (GROUP_ID)
|
||||
)
|
||||
comment '사용자';
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
create table tb_user_session
|
||||
(
|
||||
SESSION_ID varchar(100) not null comment '세션 ID'
|
||||
primary key,
|
||||
USER_ID varchar(20) not null comment '사용자 ID',
|
||||
USER_ACNT varchar(20) null comment '사용자 계정',
|
||||
LOGIN_DTTM datetime default current_timestamp() not null comment '로그인 시간',
|
||||
LAST_ACCESS_DTTM datetime default current_timestamp() not null comment '마지막 접속 시간',
|
||||
IP_ADDR varchar(50) null comment 'IP 주소',
|
||||
USER_AGENT varchar(500) null comment '사용자 에이전트'
|
||||
)
|
||||
comment '사용자 세션 정보';
|
||||
|
||||
create index IDX_TB_USER_SESSION_LOGIN_DTTM
|
||||
on tb_user_session (LOGIN_DTTM);
|
||||
|
||||
create index IDX_TB_USER_SESSION_USER_ID
|
||||
on tb_user_session (USER_ID);
|
||||
|
||||
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* XIT Framework Gradle 빌드 설정 파일
|
||||
*
|
||||
* 주요 버전 정보:
|
||||
* - Spring Boot: 2.7.18 (스프링 부트 버전)
|
||||
* - Java: 1.8 (소스 및 타겟 호환성)
|
||||
* - 전자정부 프레임워크: 4.3.0
|
||||
*/
|
||||
|
||||
plugins {
|
||||
// 스프링 부트 플러그인 - 스프링 부트 애플리케이션 빌드 및 실행을 지원
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
// 스프링 의존성 관리 플러그인 - 스프링 부트 버전에 맞는 의존성 버전 관리
|
||||
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
|
||||
// 자바 플러그인 - 자바 소스 컴파일 지원
|
||||
id 'java'
|
||||
// WAR 플러그인 - 웹 애플리케이션 아카이브 생성 지원
|
||||
id 'war'
|
||||
}
|
||||
|
||||
// 프로젝트 그룹 및 버전 정보
|
||||
group = 'go.kr.project'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
// 자바 버전 설정 (소스 및 타겟 호환성)
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
// Java 컴파일 옵션 설정
|
||||
//tasks.withType(JavaCompile) {
|
||||
// options.encoding = 'UTF-8'
|
||||
// options.compilerArgs += [
|
||||
// '-Xlint:deprecation',
|
||||
// '-Xlint:unchecked'
|
||||
// ]
|
||||
//}
|
||||
|
||||
// 설정 구성
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 설정
|
||||
repositories {
|
||||
mavenCentral() // 메이븐 중앙 저장소
|
||||
maven { url 'https://maven.egovframe.go.kr/maven/' } // 전자정부 프레임워크 저장소
|
||||
}
|
||||
|
||||
// 버전 변수 정의
|
||||
ext {
|
||||
// 서버 관련 버전
|
||||
tomcatVersion = '9.0.78' // 톰캣 9 버전 (Servlet 3.1 지원)
|
||||
|
||||
// 라이브러리 버전
|
||||
tilesVersion = '3.0.8' // 타일즈 버전
|
||||
mybatisVersion = '2.3.1' // 마이바티스 스프링 부트 스타터 버전
|
||||
commonsTextVersion = '1.10.0' // 아파치 커먼스 텍스트 버전
|
||||
egovFrameVersion = '4.3.0' // 전자정부 프레임워크 버전
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// ===== 스프링 부트 핵심 의존성 =====
|
||||
// 스프링 부트 웹 스타터 - MVC, REST, 내장 톰캣 등 웹 개발에 필요한 기본 의존성 포함
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
// 스프링 부트 유효성 검사 스타터 - Bean Validation API 구현체 포함
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
// 스프링 부트 AOP 스타터 - 관점 지향 프로그래밍 지원
|
||||
implementation 'org.springframework.boot:spring-boot-starter-aop'
|
||||
// 스프링 부트 JDBC 스타터 - JDBC 및 커넥션 풀(HikariCP) 포함
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
//implementation 'org.springframework.boot:spring-boot-starter-data-jpa' /* 실제 사용 X, intellij plugin 사용을 위해 설정 */
|
||||
|
||||
// ===== 전자정부 프레임워크 의존성 =====
|
||||
// 전자정부 프레임워크 공통 기능 - 공통 유틸리티 및 예외 처리 등
|
||||
implementation("org.egovframe.rte:org.egovframe.rte.fdl.cmmn:${egovFrameVersion}") {
|
||||
exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' /* 보안이슈 대응 후 쿼리로그 문제 발생, SLF4J 충돌 발생 */
|
||||
}
|
||||
// 전자정부 프레임워크 MVC - 웹 MVC 관련 기능
|
||||
implementation("org.egovframe.rte:org.egovframe.rte.ptl.mvc:${egovFrameVersion}") {
|
||||
exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' /* 보안이슈 대응 후 쿼리로그 문제 발생, SLF4J 충돌 발생 */
|
||||
}
|
||||
|
||||
|
||||
// ===== 로깅 의존성 =====
|
||||
// Logback - 로깅 구현체
|
||||
implementation 'ch.qos.logback:logback-classic'
|
||||
implementation 'ch.qos.logback:logback-core'
|
||||
// SLF4J - 로깅 퍼사드 인터페이스
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
// ===== 서블릿 & JSP 관련 의존성 =====
|
||||
// JSTL - JSP 표준 태그 라이브러리
|
||||
implementation 'javax.servlet:jstl'
|
||||
|
||||
// ===== 톰캣 설정 =====
|
||||
// 톰캣 9 명시적 설정 (Servlet 3.1 지원)
|
||||
implementation "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
|
||||
implementation "org.apache.tomcat.embed:tomcat-embed-el:${tomcatVersion}"
|
||||
implementation "org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}"
|
||||
|
||||
// ===== 타일즈 의존성 =====
|
||||
// 타일즈 - 레이아웃 템플릿 엔진 (JSP 페이지 조각 조합)
|
||||
implementation "org.apache.tiles:tiles-jsp:${tilesVersion}"
|
||||
implementation "org.apache.tiles:tiles-core:${tilesVersion}"
|
||||
implementation "org.apache.tiles:tiles-api:${tilesVersion}"
|
||||
implementation "org.apache.tiles:tiles-servlet:${tilesVersion}"
|
||||
implementation "org.apache.tiles:tiles-el:${tilesVersion}"
|
||||
|
||||
// ===== 데이터 액세스 의존성 =====
|
||||
// MyBatis 스프링 부트 스타터 - SQL 매핑 프레임워크
|
||||
implementation "org.mybatis.spring.boot:mybatis-spring-boot-starter:${mybatisVersion}"
|
||||
// MariaDB JDBC 드라이버 - MariaDB 데이터베이스 연결
|
||||
implementation 'org.mariadb.jdbc:mariadb-java-client'
|
||||
// HikariCP - 고성능 JDBC 커넥션 풀 (spring-boot-starter-jdbc에 포함됨)
|
||||
|
||||
// ===== 유틸리티 의존성 =====
|
||||
// Apache Commons Text - 텍스트 처리 유틸리티
|
||||
implementation "org.apache.commons:commons-text:${commonsTextVersion}"
|
||||
|
||||
// ===== EXCEL =====
|
||||
implementation 'org.apache.poi:poi:5.3.0'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.3.0'
|
||||
|
||||
// ===== Swagger UI =====
|
||||
implementation 'org.springdoc:springdoc-openapi-ui:1.7.0'
|
||||
implementation 'org.springdoc:springdoc-openapi-webmvc-core:1.7.0'
|
||||
|
||||
// ===== sqlPaser =====
|
||||
implementation 'com.github.jsqlparser:jsqlparser:4.5'
|
||||
|
||||
// ===== datasource-proxy =====
|
||||
// 파라미터 바인딩된 SQL 쿼리 로깅을 위한 datasource-proxy
|
||||
implementation 'net.ttddyy:datasource-proxy:1.10.1'
|
||||
|
||||
// ===== 개발 도구 의존성 =====
|
||||
// Lombok - 반복 코드 생성 도구 (Getter, Setter, Builder 등 자동 생성)
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
// 스프링 부트 개발 도구 - 자동 재시작, 라이브 리로드 등
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
|
||||
// ===== 테스트 의존성 =====
|
||||
// 스프링 부트 테스트 스타터 - JUnit, Mockito 등 테스트 도구 포함
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
|
||||
// ===== 배포 관련 의존성 =====
|
||||
// 톰캣 서블릿 API - 외부 톰캣 배포 시 사용
|
||||
providedCompile "org.apache.tomcat:tomcat-servlet-api:${tomcatVersion}"
|
||||
// 스프링 부트 톰캣 - 외부 톰캣 배포 시 내장 톰캣 제외
|
||||
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
|
||||
}
|
||||
|
||||
// ===== 리소스 필터링 설정 =====
|
||||
// application*.yml 파일에서 @변수명@ 플레이스홀더를 gradle.properties의 값으로 치환
|
||||
processResources {
|
||||
// application*.yml 파일에서만 @projectName@ 치환
|
||||
filesMatching('application*.yml') {
|
||||
filter { line ->
|
||||
line.replaceAll('@projectName@', project.property('projectName').toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 테스트 설정 =====
|
||||
// JUnit 플랫폼을 사용하여 테스트 실행 (JUnit 5 지원)
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// ===== WAR 파일 설정 =====
|
||||
// 생성될 WAR 파일의 이름 지정 (gradle.properties의 프로젝트명 전역변수 사용)
|
||||
war {
|
||||
archiveFileName = "${projectName}.war"
|
||||
}
|
||||
|
||||
// ===== bootWar 파일 설정 =====
|
||||
// 생성될 bootWar 파일의 이름 지정 (gradle.properties의 프로젝트명 전역변수 사용)
|
||||
bootWar {
|
||||
archiveFileName = "${projectName}-BOOT.war"
|
||||
}
|
||||
|
||||
// war 포함 압축 푼 소스 포함,
|
||||
// ./build/exploded/{프로젝트명}/
|
||||
tasks.register('exploded', Copy) {
|
||||
dependsOn 'war'
|
||||
from zipTree(tasks.war.archiveFile)
|
||||
into layout.buildDirectory.dir("exploded/${project.name}")
|
||||
doFirst {
|
||||
layout.buildDirectory.dir("exploded/${project.name}").get().asFile.deleteDir()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
# ???? ?? ?? ??
|
||||
# ?????? ????? ???? gradle? application*.yml?? ?? ??
|
||||
|
||||
# ????? ???? (gradle ??? Spring Boot ???? ?? ??)
|
||||
projectName=VIPS
|
||||
|
||||
# Gradle ?? ??
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
@ -0,0 +1,864 @@
|
||||
package egovframework.config;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import net.sf.jsqlparser.expression.CaseExpression;
|
||||
import net.sf.jsqlparser.expression.CastExpression;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.WhenClause;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
|
||||
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.statement.Statement;
|
||||
import net.sf.jsqlparser.statement.insert.Insert;
|
||||
import net.sf.jsqlparser.statement.select.*;
|
||||
import net.sf.jsqlparser.statement.update.Update;
|
||||
import net.sf.jsqlparser.statement.update.UpdateSet;
|
||||
import net.ttddyy.dsproxy.ExecutionInfo;
|
||||
import net.ttddyy.dsproxy.QueryInfo;
|
||||
import net.ttddyy.dsproxy.listener.QueryExecutionListener;
|
||||
import net.ttddyy.dsproxy.listener.logging.DefaultQueryLogEntryCreator;
|
||||
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel;
|
||||
import net.ttddyy.dsproxy.listener.logging.SLF4JQueryLoggingListener;
|
||||
import net.ttddyy.dsproxy.proxy.ParameterSetOperation;
|
||||
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DataSource Proxy 설정 클래스
|
||||
*
|
||||
* datasource-proxy 라이브러리를 사용하여 파라미터 바인딩이 된 실제 SQL 쿼리를 로그로 출력하도록 설정
|
||||
* MyBatis의 include, foreach 등 복잡한 쿼리에도 적용됨
|
||||
*
|
||||
* Environment를 사용하여 수동으로 DataSource를 구성하고 프록시 적용
|
||||
* Multi DB 환경 지원
|
||||
*
|
||||
* @author XIT Framework
|
||||
*/
|
||||
@Configuration
|
||||
public class DataSourceProxyConfig {
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
/**
|
||||
* Primary 데이터소스 빈 생성
|
||||
* Environment에서 설정값을 읽어서 HikariDataSource를 수동으로 구성
|
||||
*/
|
||||
@Bean
|
||||
public DataSource actualDataSource() {
|
||||
return createHikariDataSource("spring.datasource");
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary 데이터소스 빈 생성 (필요한 경우)
|
||||
* Multi DB 환경에서 두 번째 DB를 사용할 경우 활성화
|
||||
*
|
||||
* 예시:
|
||||
* @Bean
|
||||
* public DataSource actualSecondaryDataSource() {
|
||||
* return createHikariDataSource("spring.datasource.secondary");
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* HikariDataSource 생성 헬퍼 메서드
|
||||
*
|
||||
* @param prefix application.yml의 설정 prefix
|
||||
* @return HikariDataSource
|
||||
*/
|
||||
private HikariDataSource createHikariDataSource(String prefix) {
|
||||
HikariDataSource dataSource = new HikariDataSource();
|
||||
|
||||
// application.yml에서 설정값 읽기
|
||||
dataSource.setJdbcUrl(environment.getProperty(prefix + ".url"));
|
||||
dataSource.setUsername(environment.getProperty(prefix + ".username"));
|
||||
dataSource.setPassword(environment.getProperty(prefix + ".password"));
|
||||
dataSource.setDriverClassName(environment.getProperty(prefix + ".driver-class-name"));
|
||||
|
||||
// HikariCP 설정
|
||||
dataSource.setMaximumPoolSize(environment.getProperty(prefix + ".hikari.maximum-pool-size", Integer.class, 10));
|
||||
dataSource.setMinimumIdle(environment.getProperty(prefix + ".hikari.minimum-idle", Integer.class, 5));
|
||||
dataSource.setConnectionTimeout(environment.getProperty(prefix + ".hikari.connection-timeout", Long.class, 30000L));
|
||||
dataSource.setIdleTimeout(environment.getProperty(prefix + ".hikari.idle-timeout", Long.class, 600000L));
|
||||
dataSource.setMaxLifetime(environment.getProperty(prefix + ".hikari.max-lifetime", Long.class, 1800000L));
|
||||
dataSource.setValidationTimeout(environment.getProperty(prefix + ".hikari.validation-timeout", Long.class, 60000L));
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary 프록시 데이터소스 빈 생성
|
||||
* actualDataSource를 래핑하여 SQL 쿼리 로깅 기능을 추가
|
||||
*
|
||||
* @param actualDataSource 실제 데이터소스
|
||||
* @return 프록시가 적용된 데이터소스
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource dataSource(@Qualifier("actualDataSource") DataSource actualDataSource) {
|
||||
return createProxyDataSource(actualDataSource, "PRIMARY-DB");
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary 프록시 데이터소스 빈 생성 (필요한 경우)
|
||||
* Multi DB 환경에서 두 번째 DB를 사용할 경우 활성화
|
||||
*
|
||||
* 예시:
|
||||
* @Bean
|
||||
* public DataSource secondaryDataSource(@Qualifier("actualSecondaryDataSource") DataSource actualSecondaryDataSource) {
|
||||
* return createProxyDataSource(actualSecondaryDataSource, "SECONDARY-DB");
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* 프록시 데이터소스 생성 헬퍼 메서드
|
||||
*
|
||||
* @param actualDataSource 실제 데이터소스
|
||||
* @param dataSourceName 데이터소스 이름 (로그 식별용)
|
||||
* @return 프록시가 적용된 데이터소스
|
||||
*/
|
||||
private DataSource createProxyDataSource(DataSource actualDataSource, String dataSourceName) {
|
||||
// SLF4J 쿼리 로깅 리스너 생성
|
||||
SLF4JQueryLoggingListener loggingListener = new SLF4JQueryLoggingListener();
|
||||
|
||||
// 로그 레벨 설정 (DEBUG 레벨로 출력)
|
||||
loggingListener.setLogLevel(SLF4JLogLevel.DEBUG);
|
||||
|
||||
// 로거 이름 설정 (쿼리 로그 식별을 위함)
|
||||
loggingListener.setLogger("go.kr.project.sql");
|
||||
|
||||
// 쿼리 로그 엔트리 생성자 설정
|
||||
DefaultQueryLogEntryCreator logEntryCreator = new DefaultQueryLogEntryCreator();
|
||||
logEntryCreator.setMultiline(true); // 멀티라인으로 보기 좋게 출력
|
||||
loggingListener.setQueryLogEntryCreator(logEntryCreator);
|
||||
|
||||
// 커스텀 파라미터 바인딩 리스너 생성
|
||||
CustomParameterBindingListener customListener = new CustomParameterBindingListener();
|
||||
|
||||
// 프록시 데이터소스 빌더를 사용하여 프록시 데이터소스 생성
|
||||
return ProxyDataSourceBuilder
|
||||
.create(actualDataSource)
|
||||
.name(dataSourceName) // 데이터소스 이름 설정
|
||||
.listener(loggingListener) // 기본 로깅 리스너 추가
|
||||
.listener(customListener) // 커스텀 파라미터 바인딩 리스너 추가
|
||||
.asJson() // JSON 형태로 파라미터 바인딩된 쿼리 출력
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파라미터가 바인딩된 실제 SQL 쿼리를 출력하는 커스텀 리스너
|
||||
*/
|
||||
private static class CustomParameterBindingListener implements QueryExecutionListener {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger("go.kr.project.sql.binding");
|
||||
|
||||
@Override
|
||||
public void beforeQuery(ExecutionInfo execInfo, List<QueryInfo> queryInfoList) {
|
||||
// 쿼리 실행 전 처리 (필요시 구현)
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterQuery(ExecutionInfo execInfo, List<QueryInfo> queryInfoList) {
|
||||
try {
|
||||
// 쿼리 실행 후 파라미터가 바인딩된 SQL 출력
|
||||
for (QueryInfo queryInfo : queryInfoList) {
|
||||
String query = queryInfo.getQuery();
|
||||
List<List<ParameterSetOperation>> parametersList = queryInfo.getParametersList();
|
||||
|
||||
// Mapper 경로 및 메서드명 추출
|
||||
String mapperInfo = extractMapperInfo();
|
||||
logger.info(" ========================== Mapper: {} ========================== ", mapperInfo);
|
||||
|
||||
// 쿼리 실행 시간 및 기본 정보
|
||||
long executionTime = execInfo.getElapsedTime();
|
||||
logger.debug("실행 시간: {}ms", executionTime);
|
||||
|
||||
// 파라미터 값 추출
|
||||
List<Object> parameterValues = extractParameterValues(parametersList);
|
||||
|
||||
// 파라미터 개수 정보
|
||||
int questionMarkCount = countQuestionMarks(query);
|
||||
logger.debug("파라미터 정보: ? 플레이스홀더 {}개, 바인딩 값 {}개", questionMarkCount, parameterValues.size());
|
||||
|
||||
// 파라미터 값들 전체 출력 (인덱스 포함)
|
||||
if (!parameterValues.isEmpty()) {
|
||||
logger.debug("파라미터 값 목록 (총 {}개):", parameterValues.size());
|
||||
for (int i = 0; i < parameterValues.size(); i++) {
|
||||
Object param = parameterValues.get(i);
|
||||
String paramValue;
|
||||
|
||||
if (param == null) {
|
||||
paramValue = "NULL";
|
||||
} else if (param instanceof String) {
|
||||
paramValue = "'" + param.toString() + "'";
|
||||
} else {
|
||||
paramValue = param.toString();
|
||||
}
|
||||
|
||||
// 파라미터 인덱스와 함께 출력
|
||||
logger.debug(" (parameter {}) = {}", i + 1, paramValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 파라미터 바인딩된 SQL 생성
|
||||
String boundQuery = bindParameters(query, parameterValues);
|
||||
|
||||
// SQL 포맷팅 적용 (jsqlparser 사용)
|
||||
String formattedQuery = formatSql(boundQuery);
|
||||
|
||||
logger.info("\n{}\n", formattedQuery);
|
||||
}
|
||||
} finally {
|
||||
// ThreadLocal 정리 (메모리 누수 방지)
|
||||
// 쿼리 로깅이 완료된 후 반드시 정리
|
||||
SqlLoggingInterceptor.clearCurrentMapperInfo();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 쿼리에서 ? 플레이스홀더 개수를 카운트하는 메서드
|
||||
* 주석 안의 ? 는 제외하고 실제 파라미터 플레이스홀더만 카운트
|
||||
*
|
||||
* @param query SQL 쿼리 문자열
|
||||
* @return ? 플레이스홀더 개수
|
||||
*/
|
||||
private int countQuestionMarks(String query) {
|
||||
int count = 0;
|
||||
boolean inBlockComment = false; // /* */ 블록 주석 내부인지 여부
|
||||
boolean inLineComment = false; // -- 라인 주석 내부인지 여부
|
||||
boolean inStringLiteral = false; // ' ' 문자열 리터럴 내부인지 여부
|
||||
|
||||
for (int i = 0; i < query.length(); i++) {
|
||||
char currentChar = query.charAt(i);
|
||||
char nextChar = (i + 1 < query.length()) ? query.charAt(i + 1) : '\0';
|
||||
|
||||
// 문자열 리터럴 처리 (홑따옴표로 둘러싸인 문자열)
|
||||
if (currentChar == '\'' && !inBlockComment && !inLineComment) {
|
||||
inStringLiteral = !inStringLiteral;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 블록 주석 시작 /* 검사
|
||||
if (!inStringLiteral && !inLineComment && currentChar == '/' && nextChar == '*') {
|
||||
inBlockComment = true;
|
||||
i++; // 다음 문자도 건너뛰기
|
||||
continue;
|
||||
}
|
||||
|
||||
// 블록 주석 끝 */ 검사
|
||||
if (!inStringLiteral && inBlockComment && currentChar == '*' && nextChar == '/') {
|
||||
inBlockComment = false;
|
||||
i++; // 다음 문자도 건너뛰기
|
||||
continue;
|
||||
}
|
||||
|
||||
// 라인 주석 시작 -- 검사
|
||||
if (!inStringLiteral && !inBlockComment && currentChar == '-' && nextChar == '-') {
|
||||
inLineComment = true;
|
||||
i++; // 다음 문자도 건너뛰기
|
||||
continue;
|
||||
}
|
||||
|
||||
// 라인 주석 끝 (줄바꿈) 검사
|
||||
if (inLineComment && (currentChar == '\n' || currentChar == '\r')) {
|
||||
inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ? 플레이스홀더 카운트 (주석이나 문자열 리터럴 내부가 아닌 경우에만)
|
||||
if (currentChar == '?' && !inBlockComment && !inLineComment && !inStringLiteral) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis Interceptor에서 정확한 Mapper 정보 추출
|
||||
* SqlLoggingInterceptor에서 ThreadLocal을 통해 제공하는 정보를 사용
|
||||
*
|
||||
* @return Mapper 클래스명과 메서드명 (예: go.kr.project.login.mapper.LoginMapper.selectMenusByRoleIds)
|
||||
*/
|
||||
private String extractMapperInfo() {
|
||||
try {
|
||||
// SqlLoggingInterceptor에서 제공하는 정확한 Mapper 정보 사용
|
||||
String mapperInfo = SqlLoggingInterceptor.getCurrentMapperInfo();
|
||||
return mapperInfo != null ? mapperInfo : "Unknown Mapper";
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.warn("Mapper 정보 추출 실패: {}", e.getMessage());
|
||||
return "Unknown Mapper";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ParameterSetOperation 리스트에서 실제 파라미터 값들을 추출
|
||||
* 모든 객체 타입(Integer, String, Date 등)의 null 값을 올바르게 처리하도록 완전 재구현
|
||||
*/
|
||||
private List<Object> extractParameterValues(List<List<ParameterSetOperation>> parametersList) {
|
||||
java.util.Map<Integer, Object> parameterMap = new java.util.TreeMap<>();
|
||||
|
||||
if (parametersList != null && !parametersList.isEmpty()) {
|
||||
// 첫 번째 파라미터 세트를 사용 (일반적으로 PreparedStatement는 하나의 파라미터 세트를 가짐)
|
||||
List<ParameterSetOperation> operations = parametersList.get(0);
|
||||
|
||||
if (operations != null) {
|
||||
for (ParameterSetOperation operation : operations) {
|
||||
Object[] args = operation.getArgs();
|
||||
|
||||
if (args != null && args.length >= 1) {
|
||||
Integer paramIndex = (Integer) args[0];
|
||||
Object paramValue = null;
|
||||
|
||||
// 리플렉션을 사용하여 실제 메서드명 추출
|
||||
String methodName = extractMethodName(operation);
|
||||
//logger.debug("[METHOD_INFO] 파라미터 {}번 - 메서드명: {}, args 길이: {}",
|
||||
// paramIndex, methodName, args.length);
|
||||
|
||||
// 메서드명을 통한 정확한 null 처리
|
||||
if ("setNull".equals(methodName)) {
|
||||
// setNull 메서드 호출 - 확실하게 null 값으로 설정
|
||||
paramValue = null;
|
||||
//logger.debug("[NULL_SET] setNull 메서드 호출됨 - 파라미터 {}번을 null로 설정", paramIndex);
|
||||
} else {
|
||||
// 다른 모든 set 메서드들 (setString, setInt, setObject 등)
|
||||
if (args.length >= 2) {
|
||||
paramValue = args[1];
|
||||
|
||||
// args[1]이 null인 경우에도 null로 처리
|
||||
if (paramValue == null) {
|
||||
//logger.debug("[NULL_VALUE] 파라미터 {}번 값이 null임", paramIndex);
|
||||
} else {
|
||||
//logger.debug("[VALUE_SET] 파라미터 {}번 - 메서드: {}, 값: {} (타입: {})",
|
||||
// paramIndex, methodName, paramValue, paramValue.getClass().getSimpleName());
|
||||
}
|
||||
} else {
|
||||
logger.warn("[WARN] 파라미터 {}번 - 메서드: {}, args 길이 부족: {}",
|
||||
paramIndex, methodName, args.length);
|
||||
}
|
||||
}
|
||||
|
||||
parameterMap.put(paramIndex, paramValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TreeMap을 사용했으므로 자동으로 인덱스 순서대로 정렬됨
|
||||
return new java.util.ArrayList<>(parameterMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* ParameterSetOperation에서 실제 호출된 메서드명을 추출
|
||||
* 리플렉션을 사용하여 정확한 메서드명을 획득
|
||||
*/
|
||||
private String extractMethodName(ParameterSetOperation operation) {
|
||||
try {
|
||||
// ParameterSetOperation의 getMethod() 메서드 호출을 시도
|
||||
java.lang.reflect.Method getMethodMethod = operation.getClass().getMethod("getMethod");
|
||||
Object method = getMethodMethod.invoke(operation);
|
||||
|
||||
if (method instanceof java.lang.reflect.Method) {
|
||||
return ((java.lang.reflect.Method) method).getName();
|
||||
}
|
||||
|
||||
// getMethod가 없거나 실패한 경우 toString()에서 메서드명 추출 시도
|
||||
String operationStr = operation.toString();
|
||||
if (operationStr != null) {
|
||||
// "MethodName(args...)" 형태에서 메서드명 추출
|
||||
int openParen = operationStr.indexOf('(');
|
||||
if (openParen > 0) {
|
||||
String methodPart = operationStr.substring(0, openParen);
|
||||
int lastDot = methodPart.lastIndexOf('.');
|
||||
return lastDot >= 0 ? methodPart.substring(lastDot + 1) : methodPart;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.debug("[METHOD_EXTRACT_ERROR] 메서드명 추출 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL을 보기 좋게 포맷팅합니다.
|
||||
* 주석(/ * * /)을 원래 위치에 보존하면서 포맷팅합니다.
|
||||
*
|
||||
* @param sql 포맷팅할 SQL 문자열
|
||||
* @return 포맷팅된 SQL 문자열
|
||||
*/
|
||||
private String formatSql(String sql) {
|
||||
// 주석이 포함된 경우 원본 SQL을 그대로 반환 (주석 위치 보존)
|
||||
if (sql.contains("/*")) {
|
||||
return sql;
|
||||
}
|
||||
|
||||
// 주석이 없는 경우에만 jsqlparser로 포맷팅
|
||||
try {
|
||||
Statement statement = CCJSqlParserUtil.parse(sql);
|
||||
|
||||
if (statement instanceof Select) {
|
||||
return formatSelectStatement((Select) statement);
|
||||
} else if (statement instanceof Insert) {
|
||||
return formatInsertStatement((Insert) statement);
|
||||
} else if (statement instanceof Update) {
|
||||
return formatUpdateStatement((Update) statement);
|
||||
} else {
|
||||
// SELECT, INSERT, UPDATE 외 다른 구문(DELETE 등)은 기본 포맷팅 적용
|
||||
return applyBasicFormatting(statement.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// SQL 파싱 실패 시 원본 SQL 반환
|
||||
logger.info("SQL 파싱 실패, 원본 SQL 반환: {}", e.getMessage());
|
||||
return sql;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE 구문을 AST 기반으로 포맷팅합니다.
|
||||
*/
|
||||
private String formatUpdateStatement(Update update) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append("UPDATE ").append(update.getTable());
|
||||
|
||||
// SET clause
|
||||
if (update.getUpdateSets() != null && !update.getUpdateSets().isEmpty()) {
|
||||
sb.append("\nSET");
|
||||
List<UpdateSet> updateSets = update.getUpdateSets();
|
||||
for (int i = 0; i < updateSets.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
UpdateSet set = updateSets.get(i);
|
||||
sb.append("\n ");
|
||||
|
||||
if (set.getColumns().size() > 1) sb.append("(");
|
||||
for (int j = 0; j < set.getColumns().size(); j++) {
|
||||
if (j > 0) sb.append(", ");
|
||||
sb.append(set.getColumns().get(j));
|
||||
}
|
||||
if (set.getColumns().size() > 1) sb.append(")");
|
||||
|
||||
sb.append(" = ");
|
||||
|
||||
if (set.getExpressions().size() > 1) sb.append("(");
|
||||
for (int j = 0; j < set.getExpressions().size(); j++) {
|
||||
if (j > 0) sb.append(", ");
|
||||
sb.append(formatComplexExpression(set.getExpressions().get(j), 2));
|
||||
}
|
||||
if (set.getExpressions().size() > 1) sb.append(")");
|
||||
}
|
||||
}
|
||||
|
||||
// FROM clause (for some dialects)
|
||||
if (update.getFromItem() != null) {
|
||||
sb.append("\nFROM ").append(update.getFromItem());
|
||||
}
|
||||
|
||||
// JOIN clause (for some dialects)
|
||||
if (update.getJoins() != null) {
|
||||
for (Join join : update.getJoins()) {
|
||||
sb.append("\n").append(formatJoin(join));
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE clause
|
||||
if (update.getWhere() != null) {
|
||||
sb.append("\nWHERE");
|
||||
appendExpression(update.getWhere(), sb, 1, false);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 구문을 AST 기반으로 포맷팅합니다.
|
||||
* 컬럼 목록과 값 목록을 줄바꿈하여 가독성을 높입니다.
|
||||
* INSERT ... SELECT 구문도 지원합니다.
|
||||
*/
|
||||
private String formatInsertStatement(Insert insert) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("INSERT INTO ").append(insert.getTable());
|
||||
|
||||
// 컬럼 목록 포맷팅
|
||||
if (insert.getColumns() != null && !insert.getColumns().isEmpty()) {
|
||||
sb.append("(");
|
||||
for (int i = 0; i < insert.getColumns().size(); i++) {
|
||||
sb.append("\n ").append(insert.getColumns().get(i).getColumnName());
|
||||
if (i < insert.getColumns().size() - 1) {
|
||||
sb.append(",");
|
||||
}
|
||||
}
|
||||
sb.append("\n)");
|
||||
}
|
||||
|
||||
// 값 목록 포맷팅
|
||||
if (insert.getItemsList() != null) {
|
||||
if (insert.getItemsList() instanceof SubSelect) {
|
||||
// INSERT ... SELECT 구문인 경우
|
||||
sb.append("\n");
|
||||
SubSelect subSelect = (SubSelect) insert.getItemsList();
|
||||
formatSelectBody(subSelect.getSelectBody(), sb, 0);
|
||||
} else {
|
||||
sb.append("\nVALUES\n(");
|
||||
if (insert.getItemsList() instanceof ExpressionList) {
|
||||
List<Expression> values = ((ExpressionList) insert.getItemsList()).getExpressions();
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
sb.append("\n ").append(formatComplexExpression(values.get(i), 2));
|
||||
if (i < values.size() - 1) {
|
||||
sb.append(",");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.append("\n ").append(insert.getItemsList().toString());
|
||||
}
|
||||
sb.append("\n)");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 구문을 AST 기반으로 포맷팅합니다.
|
||||
*
|
||||
* @param select JSqlParser의 Select 객체
|
||||
* @return 포맷팅된 SELECT 구문
|
||||
*/
|
||||
private String formatSelectStatement(Select select) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
SelectBody selectBody = select.getSelectBody();
|
||||
|
||||
// 재귀적으로 SelectBody를 처리 (UNION 등 복합 쿼리 지원)
|
||||
formatSelectBody(selectBody, sb, 0);
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectBody를 재귀적으로 포맷팅하여 UNION 등의 복합 쿼리를 처리합니다.
|
||||
*
|
||||
* @param selectBody SelectBody 객체
|
||||
* @param sb 결과를 담을 StringBuilder
|
||||
* @param indentLevel 들여쓰기 수준
|
||||
*/
|
||||
private void formatSelectBody(SelectBody selectBody, StringBuilder sb, int indentLevel) {
|
||||
String indent = getIndent(indentLevel);
|
||||
|
||||
if (selectBody instanceof PlainSelect) {
|
||||
PlainSelect plainSelect = (PlainSelect) selectBody;
|
||||
|
||||
// SELECT 절
|
||||
sb.append(indent).append("SELECT");
|
||||
if (plainSelect.getDistinct() != null) {
|
||||
sb.append(" ").append(plainSelect.getDistinct());
|
||||
}
|
||||
appendSelectItems(plainSelect.getSelectItems(), sb, indentLevel + 1);
|
||||
|
||||
// FROM 절
|
||||
if (plainSelect.getFromItem() != null) {
|
||||
sb.append("\n").append(indent).append("FROM ").append(plainSelect.getFromItem());
|
||||
}
|
||||
|
||||
// JOIN 절
|
||||
if (plainSelect.getJoins() != null) {
|
||||
for (Join join : plainSelect.getJoins()) {
|
||||
sb.append("\n").append(indent).append(formatJoin(join));
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE 절
|
||||
if (plainSelect.getWhere() != null) {
|
||||
sb.append("\n").append(indent).append("WHERE");
|
||||
appendExpression(plainSelect.getWhere(), sb, indentLevel + 1, false);
|
||||
}
|
||||
|
||||
// GROUP BY 절
|
||||
if (plainSelect.getGroupBy() != null) {
|
||||
sb.append("\n").append(indent).append("GROUP BY ").append(plainSelect.getGroupBy().toString());
|
||||
}
|
||||
|
||||
// HAVING 절
|
||||
if (plainSelect.getHaving() != null) {
|
||||
sb.append("\n").append(indent).append("HAVING");
|
||||
appendExpression(plainSelect.getHaving(), sb, indentLevel + 1, false);
|
||||
}
|
||||
|
||||
// ORDER BY 절
|
||||
if (plainSelect.getOrderByElements() != null && !plainSelect.getOrderByElements().isEmpty()) {
|
||||
sb.append("\n").append(indent).append("ORDER BY ");
|
||||
List<OrderByElement> orderByElements = plainSelect.getOrderByElements();
|
||||
for (int i = 0; i < orderByElements.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
OrderByElement element = orderByElements.get(i);
|
||||
sb.append(element.getExpression().toString());
|
||||
if (element.isAsc()) {
|
||||
sb.append(" ASC");
|
||||
} else if (!element.isAsc() && element.toString().contains("DESC")) {
|
||||
sb.append(" DESC");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LIMIT 절
|
||||
if (plainSelect.getLimit() != null) {
|
||||
sb.append("\n").append(indent).append(plainSelect.getLimit());
|
||||
}
|
||||
|
||||
} else if (selectBody instanceof SetOperationList) {
|
||||
SetOperationList setOpList = (SetOperationList) selectBody;
|
||||
for (int i = 0; i < setOpList.getSelects().size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append("\n").append(indent).append(setOpList.getOperations().get(i - 1)).append("\n");
|
||||
}
|
||||
formatSelectBody(setOpList.getSelects().get(i), sb, indentLevel);
|
||||
}
|
||||
} else {
|
||||
// 기타 SelectBody 타입은 기본 toString() 사용
|
||||
sb.append(indent).append(selectBody.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 항목들을 포맷팅하여 추가합니다.
|
||||
*/
|
||||
private void appendSelectItems(List<SelectItem> selectItems, StringBuilder sb, int indentLevel) {
|
||||
String indent = getIndent(indentLevel);
|
||||
for (int i = 0; i < selectItems.size(); i++) {
|
||||
sb.append("\n").append(indent);
|
||||
if (i > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
|
||||
SelectItem item = selectItems.get(i);
|
||||
if (item instanceof SelectExpressionItem) {
|
||||
SelectExpressionItem exprItem = (SelectExpressionItem) item;
|
||||
// CASE 표현식 등을 포함한 복잡한 표현식 포맷팅
|
||||
sb.append(formatComplexExpression(exprItem.getExpression(), indentLevel));
|
||||
if (exprItem.getAlias() != null) {
|
||||
sb.append(" AS ").append(exprItem.getAlias().getName());
|
||||
}
|
||||
} else {
|
||||
sb.append(item.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 표현식(WHERE, HAVING)을 포맷팅하여 추가합니다.
|
||||
* AND, OR를 기준으로 줄바꿈 및 들여쓰기를 적용합니다.
|
||||
*/
|
||||
private void appendExpression(Expression expression, StringBuilder sb, int indentLevel, boolean isNested) {
|
||||
String indent = getIndent(indentLevel);
|
||||
if (expression instanceof AndExpression) {
|
||||
AndExpression and = (AndExpression) expression;
|
||||
appendExpression(and.getLeftExpression(), sb, indentLevel, isNested);
|
||||
sb.append("\n").append(indent).append("AND ");
|
||||
appendExpression(and.getRightExpression(), sb, indentLevel, true);
|
||||
} else if (expression instanceof OrExpression) {
|
||||
OrExpression or = (OrExpression) expression;
|
||||
appendExpression(or.getLeftExpression(), sb, indentLevel, isNested);
|
||||
sb.append("\n").append(indent).append("OR ");
|
||||
appendExpression(or.getRightExpression(), sb, indentLevel, true);
|
||||
} else {
|
||||
if (!isNested) {
|
||||
sb.append("\n").append(indent);
|
||||
}
|
||||
sb.append(formatComplexExpression(expression, indentLevel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE와 같은 복잡한 표현식을 포맷팅합니다.
|
||||
*/
|
||||
private String formatComplexExpression(Expression expression, int indentLevel) {
|
||||
if (expression instanceof CaseExpression) {
|
||||
return formatCaseExpression((CaseExpression) expression, indentLevel);
|
||||
}
|
||||
if (expression instanceof CastExpression) {
|
||||
return formatCastExpression((CastExpression) expression, indentLevel);
|
||||
}
|
||||
// 다른 복잡한 표현식들도 여기에 추가 가능
|
||||
return expression.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE 표현식을 포맷팅합니다.
|
||||
*/
|
||||
private String formatCaseExpression(CaseExpression caseExpr, int indentLevel) {
|
||||
String indent = getIndent(indentLevel);
|
||||
String innerIndent = getIndent(indentLevel + 1);
|
||||
StringBuilder sb = new StringBuilder("CASE");
|
||||
|
||||
if (caseExpr.getSwitchExpression() != null) {
|
||||
sb.append(" ").append(caseExpr.getSwitchExpression().toString());
|
||||
}
|
||||
|
||||
for (WhenClause when : caseExpr.getWhenClauses()) {
|
||||
sb.append("\n").append(innerIndent).append("WHEN ").append(when.getWhenExpression().toString());
|
||||
sb.append(" THEN ").append(when.getThenExpression().toString());
|
||||
}
|
||||
|
||||
if (caseExpr.getElseExpression() != null) {
|
||||
sb.append("\n").append(innerIndent).append("ELSE ").append(caseExpr.getElseExpression().toString());
|
||||
}
|
||||
|
||||
sb.append("\n").append(indent).append("END");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* CAST 표현식을 포맷팅합니다.
|
||||
*/
|
||||
private String formatCastExpression(CastExpression castExpr, int indentLevel) {
|
||||
return "CAST(" + formatComplexExpression(castExpr.getLeftExpression(), indentLevel) + " AS " + castExpr.getType().toString() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* JOIN 구문을 포맷팅합니다.
|
||||
*/
|
||||
private String formatJoin(Join join) {
|
||||
String joinType = "";
|
||||
if (join.isSimple()) joinType += " ";
|
||||
if (join.isCross()) joinType += "CROSS ";
|
||||
if (join.isFull()) joinType += "FULL ";
|
||||
if (join.isInner()) joinType += "INNER ";
|
||||
if (join.isLeft()) joinType += "LEFT ";
|
||||
if (join.isNatural()) joinType += "NATURAL ";
|
||||
if (join.isOuter()) joinType += "OUTER ";
|
||||
if (join.isRight()) joinType += "RIGHT ";
|
||||
if (join.isSemi()) joinType += "SEMI ";
|
||||
|
||||
return joinType + "JOIN " + join.getRightItem() + (join.getOnExpression() != null ? " ON " + join.getOnExpression() : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT, UPDATE, DELETE 등의 기본 포맷팅을 적용합니다.
|
||||
*/
|
||||
private String applyBasicFormatting(String sql) {
|
||||
return sql.replaceAll("(?i)\\b(SET|VALUES|WHERE)\\b", "\\n$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 들여쓰기 문자열을 생성합니다.
|
||||
*/
|
||||
private String getIndent(int level) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < level; i++) {
|
||||
sb.append(" "); // 4 spaces for each level
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 쿼리의 ? 플레이스홀더를 실제 파라미터 값으로 치환
|
||||
* 주석 안에 있는 ? 는 치환하지 않음
|
||||
*/
|
||||
private String bindParameters(String query, List<Object> parameters) {
|
||||
if (parameters == null || parameters.isEmpty()) {
|
||||
return query;
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
int paramIndex = 0;
|
||||
int queryIndex = 0;
|
||||
boolean inBlockComment = false; // /* */ 블록 주석 내부인지 여부
|
||||
boolean inLineComment = false; // -- 라인 주석 내부인지 여부
|
||||
boolean inStringLiteral = false; // ' ' 문자열 리터럴 내부인지 여부
|
||||
|
||||
while (queryIndex < query.length()) {
|
||||
char currentChar = query.charAt(queryIndex);
|
||||
char nextChar = (queryIndex + 1 < query.length()) ? query.charAt(queryIndex + 1) : '\0';
|
||||
|
||||
// 문자열 리터럴 처리 (홑따옴표로 둘러싸인 문자열)
|
||||
if (currentChar == '\'' && !inBlockComment && !inLineComment) {
|
||||
inStringLiteral = !inStringLiteral;
|
||||
result.append(currentChar);
|
||||
queryIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 블록 주석 시작 /* 검사
|
||||
if (!inStringLiteral && !inLineComment && currentChar == '/' && nextChar == '*') {
|
||||
inBlockComment = true;
|
||||
result.append(currentChar);
|
||||
result.append(nextChar);
|
||||
queryIndex += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 블록 주석 끝 */ 검사
|
||||
if (!inStringLiteral && inBlockComment && currentChar == '*' && nextChar == '/') {
|
||||
inBlockComment = false;
|
||||
result.append(currentChar);
|
||||
result.append(nextChar);
|
||||
queryIndex += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 라인 주석 시작 -- 검사
|
||||
if (!inStringLiteral && !inBlockComment && currentChar == '-' && nextChar == '-') {
|
||||
inLineComment = true;
|
||||
result.append(currentChar);
|
||||
result.append(nextChar);
|
||||
queryIndex += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 라인 주석 끝 (줄바꿈) 검사
|
||||
if (inLineComment && (currentChar == '\n' || currentChar == '\r')) {
|
||||
inLineComment = false;
|
||||
result.append(currentChar);
|
||||
queryIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ? 파라미터 플레이스홀더 처리 (주석이나 문자열 리터럴 내부가 아닌 경우에만)
|
||||
if (currentChar == '?' && !inBlockComment && !inLineComment && !inStringLiteral) {
|
||||
if (paramIndex < parameters.size()) {
|
||||
// ? 플레이스홀더를 실제 파라미터 값으로 치환
|
||||
Object param = parameters.get(paramIndex);
|
||||
String paramValue;
|
||||
|
||||
if (param == null) {
|
||||
paramValue = "NULL";
|
||||
} else if (param instanceof String) {
|
||||
paramValue = "'" + param.toString().replace("'", "''") + "'";
|
||||
} else if (param instanceof java.util.Date || param instanceof java.time.LocalDateTime || param instanceof java.time.LocalDate) {
|
||||
paramValue = "'" + param.toString() + "'";
|
||||
} else {
|
||||
paramValue = param.toString();
|
||||
}
|
||||
|
||||
result.append(paramValue);
|
||||
paramIndex++;
|
||||
} else {
|
||||
// 파라미터가 부족한 경우 ? 그대로 유지
|
||||
result.append('?');
|
||||
}
|
||||
} else {
|
||||
// 그 외의 모든 문자는 그대로 복사
|
||||
result.append(currentChar);
|
||||
}
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package egovframework.config;
|
||||
|
||||
import org.egovframe.rte.fdl.cmmn.trace.LeaveaTrace;
|
||||
import org.egovframe.rte.fdl.cmmn.trace.handler.DefaultTraceHandler;
|
||||
import org.egovframe.rte.fdl.cmmn.trace.handler.TraceHandler;
|
||||
import org.egovframe.rte.fdl.cmmn.trace.manager.DefaultTraceHandleManager;
|
||||
import org.egovframe.rte.fdl.cmmn.trace.manager.TraceHandlerService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.MessageSourceAccessor;
|
||||
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
@Configuration
|
||||
public class EgovConfigCommon {
|
||||
|
||||
@Bean
|
||||
public AntPathMatcher antPathMatcher() {
|
||||
return new AntPathMatcher();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DefaultTraceHandler defaultTraceHandler() {
|
||||
return new DefaultTraceHandler();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReloadableResourceBundleMessageSource messageSource() {
|
||||
ReloadableResourceBundleMessageSource reloadableResourceBundleMessageSource = new ReloadableResourceBundleMessageSource();
|
||||
reloadableResourceBundleMessageSource.setBasenames(
|
||||
"classpath:/egovframework/message/message-common",
|
||||
"classpath:/org/egovframe/rte/fdl/idgnr/messages/idgnr",
|
||||
"classpath:/org/egovframe/rte/fdl/property/messages/properties");
|
||||
reloadableResourceBundleMessageSource.setDefaultEncoding("UTF-8");
|
||||
reloadableResourceBundleMessageSource.setCacheSeconds(60);
|
||||
return reloadableResourceBundleMessageSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageSourceAccessor messageSourceAccessor() {
|
||||
return new MessageSourceAccessor(this.messageSource());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DefaultTraceHandleManager traceHandlerService() {
|
||||
DefaultTraceHandleManager defaultTraceHandleManager = new DefaultTraceHandleManager();
|
||||
defaultTraceHandleManager.setReqExpMatcher(antPathMatcher());
|
||||
defaultTraceHandleManager.setPatterns(new String[]{"*"});
|
||||
defaultTraceHandleManager.setHandlers(new TraceHandler[]{defaultTraceHandler()});
|
||||
return defaultTraceHandleManager;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LeaveaTrace leaveaTrace() {
|
||||
LeaveaTrace leaveaTrace = new LeaveaTrace();
|
||||
leaveaTrace.setTraceHandlerServices(new TraceHandlerService[]{traceHandlerService()});
|
||||
return leaveaTrace;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package egovframework.config;
|
||||
|
||||
import egovframework.configProperties.InterceptorProperties;
|
||||
import egovframework.interceptor.AuthInterceptor;
|
||||
import go.kr.project.login.service.LoginService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 인터셉터 설정 클래스
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EgovConfigInterceptor implements WebMvcConfigurer {
|
||||
|
||||
private final LoginService loginService;
|
||||
private final InterceptorProperties interceptorProperties;
|
||||
|
||||
|
||||
/**
|
||||
* 인터셉터 등록
|
||||
* @param registry 인터셉터 레지스트리
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor())
|
||||
.addPathPatterns("/**") // 모든 경로에 적용
|
||||
.excludePathPatterns(interceptorProperties.getInterceptorExclude()); // 접근 제어 예외 URL 패턴 제외
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 인터셉터 빈 등록
|
||||
* @return AuthInterceptor 객체
|
||||
*/
|
||||
@Bean
|
||||
public AuthInterceptor authInterceptor() {
|
||||
return new AuthInterceptor(loginService);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package egovframework.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
|
||||
import org.springframework.aop.support.DefaultPointcutAdvisor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.interceptor.*;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* @ClassName : EgovConfigAppTransaction.java
|
||||
* @Description : Transaction 설정
|
||||
*
|
||||
* @author : 윤주호
|
||||
* @since : 2021. 7. 20
|
||||
* @version : 1.0
|
||||
*
|
||||
* <pre>
|
||||
* << 개정이력(Modification Information) >>
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ------------- ------------ ---------------------
|
||||
* 2021. 7. 20 윤주호 최초 생성
|
||||
* </pre>
|
||||
*
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class EgovConfigTransaction {
|
||||
|
||||
@Autowired
|
||||
DataSource dataSource;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("Datasource type: {}", dataSource.getClass().getName());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataSourceTransactionManager txManager() {
|
||||
DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager();
|
||||
dataSourceTransactionManager.setDataSource(dataSource);
|
||||
return dataSourceTransactionManager;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TransactionAdvice 설정
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@Bean
|
||||
public TransactionInterceptor txAdvice(DataSourceTransactionManager txManager) {
|
||||
TransactionInterceptor txAdvice = new TransactionInterceptor();
|
||||
txAdvice.setTransactionManager(txManager);
|
||||
txAdvice.setTransactionAttributeSource(getNameMatchTransactionAttributeSource());
|
||||
return txAdvice;
|
||||
}
|
||||
|
||||
private NameMatchTransactionAttributeSource getNameMatchTransactionAttributeSource() {
|
||||
NameMatchTransactionAttributeSource txAttributeSource = new NameMatchTransactionAttributeSource();
|
||||
txAttributeSource.setNameMap(getRuleBasedTxAttributeMap());
|
||||
return txAttributeSource;
|
||||
}
|
||||
|
||||
private HashMap<String, TransactionAttribute> getRuleBasedTxAttributeMap() {
|
||||
HashMap<String, TransactionAttribute> txMethods = new HashMap<String, TransactionAttribute>();
|
||||
|
||||
RuleBasedTransactionAttribute txAttribute = new RuleBasedTransactionAttribute();
|
||||
txAttribute.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
|
||||
txAttribute.setRollbackRules(Collections.singletonList(new RollbackRuleAttribute(Exception.class)));
|
||||
txMethods.put("*", txAttribute);
|
||||
|
||||
return txMethods;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TransactionAdvisor 설정
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@Bean
|
||||
public Advisor txAdvisor(DataSourceTransactionManager txManager) {
|
||||
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
|
||||
pointcut.setExpression(
|
||||
"execution(* go.kr.project..impl.*Impl.*(..)) or execution(* egovframework.com..*Impl.*(..))");
|
||||
return new DefaultPointcutAdvisor(pointcut, txAdvice(txManager));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
package egovframework.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.view.InternalResourceViewResolver;
|
||||
import org.springframework.web.servlet.view.UrlBasedViewResolver;
|
||||
import org.springframework.web.servlet.view.tiles3.TilesConfigurer;
|
||||
import org.springframework.web.servlet.view.tiles3.TilesView;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.List;
|
||||
|
||||
@Setter
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EgovConfigWeb implements WebMvcConfigurer, ApplicationContextAware {
|
||||
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/plugins/**")
|
||||
.addResourceLocations("/resources/plugins/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
registry.addResourceHandler("/css/**")
|
||||
.addResourceLocations("/resources/css/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
registry.addResourceHandler("/img/**")
|
||||
.addResourceLocations("/resources/img/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
registry.addResourceHandler("/js/**")
|
||||
.addResourceLocations("/resources/js/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
registry.addResourceHandler("/xit/**")
|
||||
.addResourceLocations("/resources/xit/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
registry.addResourceHandler("/font/**")
|
||||
.addResourceLocations("/resources/font/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 캐시 방지 Interceptor 등록
|
||||
* 중요로직: 모든 요청에 대해 캐시 방지 헤더를 추가하여 브라우저 뒤로가기 시에도 항상 최신 데이터를 표시한다.
|
||||
* js, jsp, css 즉각 반영안됨, 주석처리
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new HandlerInterceptor() {
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
// 중요로직: HTTP 캐시 방지 헤더 설정
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); // HTTP 1.1
|
||||
response.setHeader("Pragma", "no-cache"); // HTTP 1.0
|
||||
response.setDateHeader("Expires", 0); // Proxies
|
||||
return true;
|
||||
}
|
||||
}).addPathPatterns("/**"); // 모든 경로에 적용
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception handling is now done using @ControllerAdvice in EgovExceptionAdvice class.
|
||||
* The SimpleMappingExceptionResolver approach has been replaced with a more modern approach.
|
||||
*
|
||||
* @see egovframework.exception.EgovExceptionAdvice
|
||||
*/
|
||||
@Override
|
||||
public void configureHandlerExceptionResolvers(@Nullable List< HandlerExceptionResolver> resolvers) {
|
||||
// Exception handling is now done using @ControllerAdvice
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UrlBasedViewResolver viewResolver() {
|
||||
UrlBasedViewResolver tilesViewResolver = new UrlBasedViewResolver();
|
||||
tilesViewResolver.setViewClass(TilesView.class);
|
||||
tilesViewResolver.setOrder(1);
|
||||
return tilesViewResolver;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TilesConfigurer tilesConfigurer() {
|
||||
TilesConfigurer tilesConfigurer = new TilesConfigurer();
|
||||
tilesConfigurer.setDefinitions(
|
||||
"/WEB-INF/tiles/tiles.xml"
|
||||
);
|
||||
tilesConfigurer.setCheckRefresh(true);
|
||||
return tilesConfigurer;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public InternalResourceViewResolver internalResourceViewResolver() {
|
||||
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
|
||||
resolver.setPrefix("/WEB-INF/views/");
|
||||
resolver.setSuffix(".jsp");
|
||||
resolver.setOrder(2);
|
||||
return resolver;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package egovframework.config;
|
||||
|
||||
import org.springframework.boot.web.server.ErrorPage;
|
||||
import org.springframework.boot.web.server.ErrorPageRegistrar;
|
||||
import org.springframework.boot.web.server.ErrorPageRegistry;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 에러 페이지 설정을 위한 설정 클래스
|
||||
* 404, 500 등의 HTTP 에러 코드에 대한 커스텀 에러 페이지를 매핑합니다.
|
||||
*/
|
||||
@Configuration
|
||||
public class EgovErrorConfig {
|
||||
|
||||
/**
|
||||
* ErrorPageRegistrar 빈을 생성하여 에러 페이지를 등록합니다.
|
||||
*
|
||||
* @return ErrorPageRegistrar 구현체
|
||||
*/
|
||||
@Bean
|
||||
public ErrorPageRegistrar errorPageRegistrar() {
|
||||
return new ErrorPageRegistrar() {
|
||||
@Override
|
||||
public void registerErrorPages(ErrorPageRegistry registry) {
|
||||
// 404 에러 페이지 설정
|
||||
ErrorPage error404Page = new ErrorPage(HttpStatus.NOT_FOUND, "/error/404");
|
||||
|
||||
// 403 에러 페이지 설정 (권한 없음)
|
||||
ErrorPage error403Page = new ErrorPage(HttpStatus.FORBIDDEN, "/error/404");
|
||||
|
||||
// AccessDeniedException 예외 처리를 위한 에러 페이지 설정
|
||||
ErrorPage accessDeniedPage = new ErrorPage(egovframework.exception.AccessDeniedException.class, "/error/404");
|
||||
|
||||
// 클라이언트 에러(4xx) 페이지 설정
|
||||
// 400 에러 페이지 설정 (잘못된 요청)
|
||||
ErrorPage error400Page = new ErrorPage(HttpStatus.BAD_REQUEST, "/error/404");
|
||||
|
||||
// 401 에러 페이지 설정 (인증 실패)
|
||||
ErrorPage error401Page = new ErrorPage(HttpStatus.UNAUTHORIZED, "/error/404");
|
||||
|
||||
// 405 에러 페이지 설정 (허용되지 않는 메소드)
|
||||
ErrorPage error405Page = new ErrorPage(HttpStatus.METHOD_NOT_ALLOWED, "/error/404");
|
||||
|
||||
// 406 에러 페이지 설정 (허용되지 않는 형식)
|
||||
ErrorPage error406Page = new ErrorPage(HttpStatus.NOT_ACCEPTABLE, "/error/404");
|
||||
|
||||
// 408 에러 페이지 설정 (요청 시간 초과)
|
||||
ErrorPage error408Page = new ErrorPage(HttpStatus.REQUEST_TIMEOUT, "/error/404");
|
||||
|
||||
// 409 에러 페이지 설정 (충돌)
|
||||
ErrorPage error409Page = new ErrorPage(HttpStatus.CONFLICT, "/error/404");
|
||||
|
||||
// 410 에러 페이지 설정 (리소스 사라짐)
|
||||
ErrorPage error410Page = new ErrorPage(HttpStatus.GONE, "/error/404");
|
||||
|
||||
// 413 에러 페이지 설정 (요청 엔티티가 너무 큼)
|
||||
ErrorPage error413Page = new ErrorPage(HttpStatus.PAYLOAD_TOO_LARGE, "/error/404");
|
||||
|
||||
// 414 에러 페이지 설정 (URI가 너무 김)
|
||||
ErrorPage error414Page = new ErrorPage(HttpStatus.URI_TOO_LONG, "/error/404");
|
||||
|
||||
// 415 에러 페이지 설정 (지원되지 않는 미디어 타입)
|
||||
ErrorPage error415Page = new ErrorPage(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "/error/404");
|
||||
|
||||
// 429 에러 페이지 설정 (너무 많은 요청)
|
||||
ErrorPage error429Page = new ErrorPage(HttpStatus.TOO_MANY_REQUESTS, "/error/404");
|
||||
|
||||
// 500 에러 페이지 설정
|
||||
ErrorPage error500Page = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500");
|
||||
|
||||
// 기타 서버 에러 페이지 설정
|
||||
ErrorPage error501Page = new ErrorPage(HttpStatus.NOT_IMPLEMENTED, "/error/500");
|
||||
ErrorPage error502Page = new ErrorPage(HttpStatus.BAD_GATEWAY, "/error/500");
|
||||
ErrorPage error503Page = new ErrorPage(HttpStatus.SERVICE_UNAVAILABLE, "/error/500");
|
||||
ErrorPage error504Page = new ErrorPage(HttpStatus.GATEWAY_TIMEOUT, "/error/500");
|
||||
ErrorPage error505Page = new ErrorPage(HttpStatus.HTTP_VERSION_NOT_SUPPORTED, "/error/500");
|
||||
|
||||
// 예외 처리를 위한 에러 페이지 설정
|
||||
ErrorPage runtimeExceptionPage = new ErrorPage(RuntimeException.class, "/error/500");
|
||||
|
||||
// 추가 예외 처리를 위한 에러 페이지 설정
|
||||
ErrorPage illegalArgumentExceptionPage = new ErrorPage(IllegalArgumentException.class, "/error/500");
|
||||
ErrorPage nullPointerExceptionPage = new ErrorPage(NullPointerException.class, "/error/500");
|
||||
ErrorPage illegalStateExceptionPage = new ErrorPage(IllegalStateException.class, "/error/500");
|
||||
ErrorPage ioExceptionPage = new ErrorPage(java.io.IOException.class, "/error/500");
|
||||
ErrorPage sqlExceptionPage = new ErrorPage(java.sql.SQLException.class, "/error/500");
|
||||
ErrorPage dataAccessExceptionPage = new ErrorPage(org.springframework.dao.DataAccessException.class, "/error/500");
|
||||
ErrorPage arithmeticExceptionPage = new ErrorPage(ArithmeticException.class, "/error/500");
|
||||
ErrorPage classNotFoundExceptionPage = new ErrorPage(ClassNotFoundException.class, "/error/500");
|
||||
ErrorPage noSuchMethodExceptionPage = new ErrorPage(NoSuchMethodException.class, "/error/500");
|
||||
ErrorPage indexOutOfBoundsExceptionPage = new ErrorPage(IndexOutOfBoundsException.class, "/error/500");
|
||||
ErrorPage concurrentModificationExceptionPage = new ErrorPage(java.util.ConcurrentModificationException.class, "/error/500");
|
||||
|
||||
// 에러 페이지 등록
|
||||
registry.addErrorPages(
|
||||
// 4xx 에러
|
||||
error400Page, error401Page, error403Page, error404Page, error405Page,
|
||||
error406Page, error408Page, error409Page, error410Page, error413Page,
|
||||
error414Page, error415Page, error429Page,
|
||||
|
||||
// 5xx 에러
|
||||
error500Page, error501Page, error502Page, error503Page, error504Page, error505Page,
|
||||
|
||||
// 예외 처리
|
||||
runtimeExceptionPage, illegalArgumentExceptionPage, nullPointerExceptionPage,
|
||||
illegalStateExceptionPage, ioExceptionPage, sqlExceptionPage,
|
||||
dataAccessExceptionPage, arithmeticExceptionPage, classNotFoundExceptionPage,
|
||||
noSuchMethodExceptionPage, indexOutOfBoundsExceptionPage, concurrentModificationExceptionPage,
|
||||
accessDeniedPage
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package egovframework.config;
|
||||
|
||||
import com.fasterxml.jackson.core.SerializableString;
|
||||
import com.fasterxml.jackson.core.io.CharacterEscapes;
|
||||
import com.fasterxml.jackson.core.io.SerializedString;
|
||||
import org.apache.commons.text.StringEscapeUtils;
|
||||
|
||||
public class HtmlCharacterEscapes extends CharacterEscapes {
|
||||
|
||||
private static final long serialVersionUID = -6353236148390563705L;
|
||||
|
||||
private final int[] asciiEscapes;
|
||||
|
||||
public HtmlCharacterEscapes() {
|
||||
this.asciiEscapes = CharacterEscapes.standardAsciiEscapesForJSON();
|
||||
this.asciiEscapes['<'] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes['>'] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes['\"'] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes['('] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes[')'] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes['#'] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
this.asciiEscapes['\''] = CharacterEscapes.ESCAPE_CUSTOM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getEscapeCodesForAscii() {
|
||||
return asciiEscapes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SerializableString getEscapeSequence(int ch) {
|
||||
return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package egovframework.config;
|
||||
|
||||
import org.apache.ibatis.executor.Executor;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.plugin.*;
|
||||
import org.apache.ibatis.session.ResultHandler;
|
||||
import org.apache.ibatis.session.RowBounds;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* MyBatis Interceptor를 이용한 SQL 로깅 및 Mapper 정보 추출
|
||||
*
|
||||
* MappedStatement에서 정확한 namespace(Mapper 클래스)와 id(메서드명)를 추출하여
|
||||
* ThreadLocal을 통해 DataSourceProxy와 공유합니다.
|
||||
*
|
||||
* @author XIT Framework
|
||||
*/
|
||||
@Intercepts({
|
||||
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
|
||||
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
|
||||
})
|
||||
public class SqlLoggingInterceptor implements Interceptor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SqlLoggingInterceptor.class);
|
||||
|
||||
/**
|
||||
* ThreadLocal을 이용해 현재 실행 중인 Mapper 정보를 저장
|
||||
* DataSourceProxy에서 이 정보를 참조하여 정확한 Mapper 정보를 로그에 출력
|
||||
*/
|
||||
private static final ThreadLocal<String> CURRENT_MAPPER_INFO = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 현재 스레드의 Mapper 정보 반환
|
||||
*
|
||||
* @return Mapper 클래스명.메서드명 (예: go.kr.project.login.mapper.LoginMapper.selectMenusByRoleIds)
|
||||
*/
|
||||
public static String getCurrentMapperInfo() {
|
||||
String mapperInfo = CURRENT_MAPPER_INFO.get();
|
||||
return mapperInfo != null ? mapperInfo : "Unknown Mapper";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 스레드의 Mapper 정보 설정
|
||||
*
|
||||
* @param mapperInfo Mapper 정보
|
||||
*/
|
||||
public static void setCurrentMapperInfo(String mapperInfo) {
|
||||
CURRENT_MAPPER_INFO.set(mapperInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 스레드의 Mapper 정보 제거
|
||||
*/
|
||||
public static void clearCurrentMapperInfo() {
|
||||
CURRENT_MAPPER_INFO.remove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object intercept(Invocation invocation) throws Throwable {
|
||||
// MappedStatement에서 Mapper 정보 추출
|
||||
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
|
||||
String statementId = mappedStatement.getId();
|
||||
|
||||
// statementId는 "namespace.methodId" 형태 (예: go.kr.project.login.mapper.LoginMapper.selectMenusByRoleIds)
|
||||
String mapperInfo = statementId;
|
||||
|
||||
// ThreadLocal에 Mapper 정보 저장
|
||||
setCurrentMapperInfo(mapperInfo);
|
||||
|
||||
logger.debug("MyBatis Interceptor - Mapper 정보 설정: {}", mapperInfo);
|
||||
|
||||
try {
|
||||
// 실제 쿼리 실행
|
||||
return invocation.proceed();
|
||||
|
||||
} catch (Exception e) {
|
||||
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
|
||||
Object parameter = invocation.getArgs()[1];
|
||||
|
||||
logger.error("MyBatis Interceptor 실행 중 오류 발생");
|
||||
logger.error("Query ID: {}", ms.getId());
|
||||
logger.error("Parameters: {}", parameter);
|
||||
logger.error("SQL: {}", ms.getBoundSql(parameter).getSql());
|
||||
logger.error("Error: {}", e.getMessage());
|
||||
// 중요로직: 예외 전체 스택트레이스를 가독성 있게 출력하기 위해 logger.error("message", exception) 형태를 사용
|
||||
logger.error("Error StackTrace: ", e);
|
||||
throw e;
|
||||
}
|
||||
// 주의: ThreadLocal 정리는 DataSourceProxy의 afterQuery에서 수행
|
||||
// finally 블록에서 정리하면 DataSourceProxy가 Mapper 정보를 사용하기 전에 제거될 수 있음
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object plugin(Object target) {
|
||||
// 모든 Executor에 대해 인터셉터 적용
|
||||
return Plugin.wrap(target, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProperties(Properties properties) {
|
||||
// 설정 프로퍼티가 있다면 여기서 처리
|
||||
// 현재는 특별한 설정이 필요없음
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package egovframework.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import org.springdoc.core.GroupedOpenApi;
|
||||
import org.springdoc.core.SpringDocUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
/**
|
||||
* Swagger UI 설정 클래스
|
||||
* OpenAPI 3.0 스펙을 기반으로 API 문서화를 위한 설정을 제공합니다.
|
||||
* 세션 기반 인증을 사용하므로 별도의 보안 스키마를 설정하지 않습니다.
|
||||
* @Controller 어노테이션이 있는 클래스도 스캔하도록 설정합니다.
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
static {
|
||||
// @Controller 어노테이션이 있는 클래스도 스캔하도록 설정
|
||||
SpringDocUtils.getConfig().addAnnotationsToIgnore(RequestMapping.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 API를 문서화하는 GroupedOpenApi 빈을 정의합니다.
|
||||
*
|
||||
* @return GroupedOpenApi 설정 객체
|
||||
*/
|
||||
@Bean
|
||||
public GroupedOpenApi allApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("All")
|
||||
.packagesToScan("go.kr.project")
|
||||
.pathsToMatch("/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 관련 API를 문서화하는 GroupedOpenApi 빈을 정의합니다.
|
||||
*
|
||||
* @return GroupedOpenApi 설정 객체
|
||||
*/
|
||||
@Bean
|
||||
public GroupedOpenApi loginApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("Login")
|
||||
.packagesToScan("go.kr.project.login")
|
||||
.pathsToMatch("/login/**")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI 설정을 정의합니다.
|
||||
* API 문서의 기본 정보(제목, 설명, 버전 등)를 설정합니다.
|
||||
*
|
||||
* @return OpenAPI 설정 객체
|
||||
*/
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
Info info = new Info()
|
||||
.title("XIT Framework API")
|
||||
.description("XIT Framework API 문서 - 세션 기반 인증 사용")
|
||||
.version("v1.0.0")
|
||||
.license(new License().name("Apache 2.0").url("http://springdoc.org"));
|
||||
|
||||
return new OpenAPI()
|
||||
.components(new Components())
|
||||
.info(info);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package egovframework.configProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.config
|
||||
* fileName : FileUploadProperties
|
||||
* author : 시스템 관리자
|
||||
* date : 25. 5. 23.
|
||||
* description : 파일 업로드 관련 설정 속성
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 23. 시스템 관리자 최초 생성
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "file.upload")
|
||||
public class FileUploadProperties {
|
||||
|
||||
/** 파일 저장 기본 경로 */
|
||||
private String path;
|
||||
|
||||
/** 최대 파일 크기 (단일 파일) - 기본값 10MB */
|
||||
private long maxSize;
|
||||
|
||||
/** 최대 총 파일 크기 - 기본값 50MB */
|
||||
private long maxTotalSize;
|
||||
|
||||
/** 허용된 파일 확장자 */
|
||||
private String allowedExtensions;
|
||||
|
||||
/** 최대 파일 개수 - 기본값 10개 */
|
||||
private int maxFiles;
|
||||
|
||||
/** 실제 파일 삭제 여부 - 기본값 true */
|
||||
private boolean realFileDelete;
|
||||
|
||||
/** 하위 디렉토리 설정 */
|
||||
private Map<String, String> subDirs;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package egovframework.configProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.config
|
||||
* fileName : InterceptorProperties
|
||||
* author : 박성영
|
||||
* date : 25. 5. 19.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 19. 박성영 최초 생성
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "interceptor")
|
||||
public class InterceptorProperties {
|
||||
|
||||
private List<String> interceptorExclude;
|
||||
private List<String> refererExclude;
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package egovframework.configProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.config
|
||||
* fileName : LoginProperties
|
||||
* author : 박성영
|
||||
* date : 25. 5. 19.
|
||||
* description : 로그인 관련 설정 속성
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 19. 박성영 최초 생성
|
||||
* 25. 5. 22. 시스템 관리자 동시 접속 가능 여부 속성 추가
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "login")
|
||||
public class LoginProperties {
|
||||
|
||||
private String url; // 로그인 페이지 URL
|
||||
private Lock lock; // 로그인 잠금 설정
|
||||
private boolean allowMultipleLogin = true; // 동시 접속 가능 여부 (기본값: true)
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public static class Lock {
|
||||
private int count; // 비밀번호 잠김 최종 카운트
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.constant
|
||||
* fileName : BatchConstants
|
||||
* author : 개발자
|
||||
* date : 2025-06-10
|
||||
* description : 배치 작업 관련 상수를 정의하는 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-06-10 개발자 최초 생성
|
||||
*/
|
||||
public class BatchConstants {
|
||||
|
||||
/**
|
||||
* 배치 작업 정보 상태 코드 (TB_BATCH_JOB_INFO.STATUS)
|
||||
*/
|
||||
public static final String JOB_INFO_STATUS_ACTIVE = "ACTIVE";
|
||||
public static final String JOB_INFO_STATUS_PAUSED = "PAUSED";
|
||||
public static final String JOB_INFO_STATUS_DELETED = "DELETED";
|
||||
|
||||
/**
|
||||
* 배치 작업 실행 상태 코드 (TB_BATCH_JOB_EXECUTION.STATUS)
|
||||
* 실행상태에서 부분완료는 제거됨 - 시작,완료,실패,거부만 사용
|
||||
*/
|
||||
public static final String JOB_EXECUTION_STATUS_STARTED = "STARTED";
|
||||
public static final String JOB_EXECUTION_STATUS_COMPLETED = "COMPLETED";
|
||||
public static final String JOB_EXECUTION_STATUS_FAILED = "FAILED";
|
||||
public static final String JOB_EXECUTION_STATUS_VETOED = "VETOED";
|
||||
|
||||
/**
|
||||
* 배치 작업 실행 종료 코드 (TB_BATCH_JOB_EXECUTION.EXIT_CODE)
|
||||
*/
|
||||
public static final String JOB_EXECUTION_EXIT_COMPLETED = "COMPLETED";
|
||||
public static final String JOB_EXECUTION_EXIT_FAILED = "FAILED";
|
||||
public static final String JOB_EXECUTION_EXIT_UNKNOWN = "UNKNOWN";
|
||||
public static final String JOB_EXECUTION_EXIT_PARTIALLY_COMPLETED = "PARTIALLY_COMPLETED";
|
||||
|
||||
|
||||
/**
|
||||
* 로그 레벨
|
||||
*/
|
||||
public static final String LOG_LEVEL_INFO = "INFO";
|
||||
public static final String LOG_LEVEL_WARN = "WARN";
|
||||
public static final String LOG_LEVEL_ERROR = "ERROR";
|
||||
|
||||
/**
|
||||
* 날짜 및 시간 형식
|
||||
*/
|
||||
public static final String DATE_FORMAT_DEFAULT = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
/**
|
||||
* 시간 값 (밀리초)
|
||||
*/
|
||||
public static final long TIME_ITEM_PROCESS_DELAY = 100; // 0.1초
|
||||
public static final long TIME_JOB_DELAY_TEST = 3000; // 3초 (테스트용)
|
||||
public static final long TIME_JOB_DELAY_1_MIN = 60000; // 1분
|
||||
public static final long TIME_JOB_DELAY_3_MIN = 180000; // 3분
|
||||
public static final long TIME_JOB_DELAY_5_MIN = 300000; // 5분
|
||||
|
||||
/**
|
||||
* JobDataMap 키
|
||||
*/
|
||||
public static final String JOB_DATA_EXECUTION_ID = "executionId";
|
||||
|
||||
/**
|
||||
* 작업 이름 및 그룹
|
||||
*/
|
||||
public static final String JOB_NAME_SAMPLE = "sampleJob";
|
||||
public static final String JOB_GROUP_SAMPLE = "sampleGroup";
|
||||
public static final String JOB_TRIGGER_SUFFIX = "Trigger";
|
||||
|
||||
/**
|
||||
* Cron 표현식
|
||||
*/
|
||||
public static final String CRON_EVERY_MINUTE = "0 * * * * ?";
|
||||
public static final String CRON_EVERY_ONE_SECOND = "1 0 * * * ?";
|
||||
|
||||
/**
|
||||
* 작업 설명
|
||||
*/
|
||||
public static final String JOB_DESC_SAMPLE = "샘플 배치 작업 (매 분마다 실행)";
|
||||
|
||||
/**
|
||||
* 배치 로그 메시지 템플릿
|
||||
*/
|
||||
// 기본 배치 작업 로그 메시지
|
||||
public static final String LOG_MSG_BATCH_JOB_START = "===== 배치 작업 시작: %s.%s - %s =====";
|
||||
public static final String LOG_MSG_BATCH_JOB_COMPLETE = "===== 배치 작업 완료: %s.%s - %s =====";
|
||||
public static final String LOG_MSG_BATCH_JOB_PARTIAL_COMPLETE = "===== 배치 작업 부분 완료: %s.%s - 총 %d개 파일 중 처리: %d개, 성공: %d개, 실패: %d개 (%s) =====";
|
||||
public static final String LOG_MSG_BATCH_JOB_FAILED = "===== 배치 작업 실패: %s.%s - 총 %d개 파일 중 처리: %d개, 성공: %d개, 실패: %d개 (%s) =====";
|
||||
public static final String LOG_MSG_BATCH_JOB_ERROR = "===== 배치 작업 실행 중 오류 발생: %s.%s - %s =====";
|
||||
|
||||
/**
|
||||
* 배치 작업 초기화 관련 메시지
|
||||
*/
|
||||
public static final String LOG_MSG_BATCH_INIT_SERVER_BOOT_DETECTED = "서버 부팅 완료 감지 - 배치 작업 초기화를 5초 후에 시작합니다.";
|
||||
public static final String LOG_MSG_BATCH_INIT_START = "배치 작업 초기화 시작";
|
||||
public static final String LOG_MSG_BATCH_INIT_COMPLETE = "배치 작업 초기화 완료";
|
||||
public static final String LOG_MSG_BATCH_INIT_INTERRUPT_ERROR = "배치 작업 초기화 대기 중 인터럽트 발생: {}";
|
||||
public static final String LOG_MSG_BATCH_INIT_ERROR = "배치 작업 초기화 중 오류 발생: %s";
|
||||
public static final String LOG_MSG_BATCH_INIT_NO_JOBS = "등록된 배치 작업이 없습니다.";
|
||||
public static final String LOG_MSG_BATCH_INIT_JOBS_COUNT = "총 {}개의 배치 작업 정보를 조회했습니다.";
|
||||
public static final String LOG_MSG_BATCH_INIT_SKIP_DELETED = "삭제된 작업은 스케줄러에 등록하지 않습니다: {}.{}";
|
||||
public static final String LOG_MSG_BATCH_INIT_SCHEDULE_SUCCESS = "배치 작업을 스케줄러에 등록했습니다: {}.{}";
|
||||
public static final String LOG_MSG_BATCH_INIT_SCHEDULE_FAILED = "배치 작업 스케줄링에 실패했습니다: {}.{}";
|
||||
public static final String LOG_MSG_BATCH_INIT_CLASS_NOT_FOUND = "배치 작업 클래스를 찾을 수 없습니다: {}";
|
||||
public static final String LOG_MSG_BATCH_INIT_SCHEDULE_ERROR = "배치 작업 스케줄링 중 오류 발생: {}.{} - {}";
|
||||
public static final String LOG_MSG_BATCH_INIT_DB_ERROR = "배치 작업 초기화 중 오류 발생: {}";
|
||||
public static final String LOG_MSG_BATCH_INIT_REGISTER_COMPLETE = "배치 작업 정보 등록 완료: %s";
|
||||
public static final String LOG_MSG_BATCH_INIT_REGISTER_ERROR = "샘플 배치 작업 등록 중 오류 발생: %s";
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.constant
|
||||
* fileName : CrdnPrcsSttsConstants
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-08-26
|
||||
* description : 단속 처리 상태 코드 상수를 정의하는 클래스
|
||||
* 중요로직: 처리 단계별 상태 코드, 순서는 처리 순서대로 정의
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-08-26 시스템 관리자 최초 생성
|
||||
*/
|
||||
public class CrdnPrcsSttsConstants {
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 10: 단속
|
||||
* 최초 단속 등록시 기본값으로 설정되는 상태
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_10_CRDN = "10";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 20: 처분사전
|
||||
* 처분 사전 통지 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_20_DSPS_BFHD = "20";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 30: 시정명령
|
||||
* 시정 명령 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_30_CRC_CMD = "30";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 40: 시정촉구
|
||||
* 시정 촉구 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_40_CRC_URG = "40";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 50: 부과예고
|
||||
* 부과 예고 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_50_LEVY_PRVNTC = "50";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 60: 부과
|
||||
* 부과 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_60_LEVY = "60";
|
||||
|
||||
/**
|
||||
* 단속 처리 상태 코드 - 70: 납부촉구
|
||||
* 납부 촉구 단계
|
||||
*/
|
||||
public static final String CRDN_PRCS_STTS_CD_70_PAY_URG = "70";
|
||||
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package egovframework.constant;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.constant
|
||||
* fileName : FileContentTypeConstants
|
||||
* author : 개발자
|
||||
* date : 2025-05-17
|
||||
* description : 파일 확장자에 따른 MIME 타입을 정의하는 상수 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-17 개발자 최초 생성
|
||||
*/
|
||||
public class FileContentTypeConstants {
|
||||
|
||||
/**
|
||||
* 기본 MIME 타입 (알 수 없는 파일 형식)
|
||||
*/
|
||||
public static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
/**
|
||||
* 파일 확장자와 MIME 타입 매핑
|
||||
*/
|
||||
private static final Map<String, String> CONTENT_TYPE_MAP;
|
||||
|
||||
static {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
|
||||
// 이미지 파일
|
||||
map.put("jpg", "image/jpeg");
|
||||
map.put("jpeg", "image/jpeg");
|
||||
map.put("png", "image/png");
|
||||
map.put("gif", "image/gif");
|
||||
|
||||
// 문서 파일
|
||||
map.put("pdf", "application/pdf");
|
||||
map.put("doc", "application/msword");
|
||||
map.put("docx", "application/msword");
|
||||
map.put("xls", "application/vnd.ms-excel");
|
||||
map.put("xlsx", "application/vnd.ms-excel");
|
||||
map.put("ppt", "application/vnd.ms-powerpoint");
|
||||
map.put("pptx", "application/vnd.ms-powerpoint");
|
||||
map.put("txt", "text/plain");
|
||||
|
||||
CONTENT_TYPE_MAP = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 확장자에 따른 MIME 타입을 반환합니다.
|
||||
*
|
||||
* @param fileExt 파일 확장자 (소문자)
|
||||
* @return MIME 타입
|
||||
*/
|
||||
public static String getContentType(String fileExt) {
|
||||
if (fileExt == null || fileExt.isEmpty()) {
|
||||
return DEFAULT_CONTENT_TYPE;
|
||||
}
|
||||
|
||||
return CONTENT_TYPE_MAP.getOrDefault(fileExt.toLowerCase(), DEFAULT_CONTENT_TYPE);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.constant
|
||||
* fileName : ImpltTaskSeConstants
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-09-08
|
||||
* description : 이행업무구분코드 상수를 정의하는 클래스
|
||||
* 중요로직: 이행업무 처리 단계별 구분 코드, 순서는 처리 순서대로 정의
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-09-08 시스템 관리자 최초 생성
|
||||
*/
|
||||
public class ImpltTaskSeConstants {
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 1: 처분사전
|
||||
* 처분 사전 통지 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_1_DSPS_BFHD = "1";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 2: 시정명령
|
||||
* 시정 명령 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_2_CRC_CMD = "2";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 3: 시정촉구
|
||||
* 시정 촉구 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_3_CRC_URG = "3";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 4: 부과예고
|
||||
* 부과 예고 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_4_LEVY_PRVNTC = "4";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 5: 부과
|
||||
* 부과 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_5_LEVY = "5";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드 - 6: 납부촉구
|
||||
* 납부 촉구 단계
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_6_PAY_URG = "6";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 처분사전
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_DSPS_BFHD = "처분사전";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 시정명령
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_CRC_CMD = "시정명령";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 시정촉구
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_CRC_URG = "시정촉구";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 부과예고
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_LEVY_PRVNTC = "부과예고";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 부과
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_LEVY = "부과";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드명 - 납부촉구
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_PAY_URG = "납부촉구";
|
||||
|
||||
/**
|
||||
* 알수없음
|
||||
*/
|
||||
public static final String IMPLT_TASK_SE_CD_NM_UNKNOWN = "알수없음";
|
||||
|
||||
/**
|
||||
* 이행업무구분코드에 해당하는 코드명을 반환합니다.
|
||||
*
|
||||
* @param impltTaskSeCd 이행업무구분코드
|
||||
* @return 코드명
|
||||
*/
|
||||
public static String getImpltTaskSeCdNm(String impltTaskSeCd) {
|
||||
switch (impltTaskSeCd) {
|
||||
case IMPLT_TASK_SE_CD_1_DSPS_BFHD: return IMPLT_TASK_SE_CD_NM_DSPS_BFHD;
|
||||
case IMPLT_TASK_SE_CD_2_CRC_CMD: return IMPLT_TASK_SE_CD_NM_CRC_CMD;
|
||||
case IMPLT_TASK_SE_CD_3_CRC_URG: return IMPLT_TASK_SE_CD_NM_CRC_URG;
|
||||
case IMPLT_TASK_SE_CD_4_LEVY_PRVNTC: return IMPLT_TASK_SE_CD_NM_LEVY_PRVNTC;
|
||||
case IMPLT_TASK_SE_CD_5_LEVY: return IMPLT_TASK_SE_CD_NM_LEVY;
|
||||
case IMPLT_TASK_SE_CD_6_PAY_URG: return IMPLT_TASK_SE_CD_NM_PAY_URG;
|
||||
default: return IMPLT_TASK_SE_CD_NM_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.constant
|
||||
* fileName : MessageConstants
|
||||
* author : 개발자
|
||||
* date : 2025-05-22
|
||||
* description : 시스템에서 사용되는 메시지 상수를 정의하는 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-22 개발자 최초 생성
|
||||
*/
|
||||
public class MessageConstants {
|
||||
|
||||
/**
|
||||
* 로그인 관련 메시지
|
||||
*/
|
||||
public static class Login {
|
||||
// 성공 메시지
|
||||
public static final String SUCCESS = "로그인에 성공하였습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String ERROR_INVALID_CREDENTIALS = "아이디 또는 비밀번호가 일치하지 않습니다.";
|
||||
public static final String ERROR_INACTIVE_ACCOUNT = "비활성화 계정입니다. 관리자에 문의하세요.";
|
||||
public static final String ERROR_PROCESS = "로그인 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 실패 관련 메시지
|
||||
*/
|
||||
public static class LoginFailure {
|
||||
public static final String ABNORMAL_PATTERN_PREFIX = "비정상적인 로그인 패턴: ";
|
||||
public static final String USER_NOT_FOUND = "사용자 계정 없음";
|
||||
public static final String ACCOUNT_LOCKED = "계정 잠김";
|
||||
public static final String PASSWORD_MISMATCH = "비밀번호 불일치";
|
||||
public static final String ACCOUNT_INACTIVE = "계정 비활성화";
|
||||
|
||||
public static final String MESSAGE_ACCOUNT_LOCKED = "사용자 계정이 잠겼습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 관련 메시지
|
||||
*/
|
||||
public static class Logout {
|
||||
// 성공 메시지
|
||||
public static final String SUCCESS = "로그아웃 되었습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String ERROR_PROCESS = "로그아웃 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 관련 메시지
|
||||
*/
|
||||
public static class Account {
|
||||
// 계정 잠금 관련 메시지
|
||||
public static final String UNLOCK_SUCCESS = "계정 잠금이 해제되었습니다.";
|
||||
public static final String UNLOCK_FAIL = "계정 잠금 해제에 실패했습니다.";
|
||||
public static final String UNLOCK_ERROR = "계정 잠금 해제 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 로그 관련 메시지
|
||||
*/
|
||||
public static class LoginLog {
|
||||
// 목록 조회 관련 메시지
|
||||
public static final String LIST_SUCCESS = "로그인 로그 목록을 조회하였습니다.";
|
||||
public static final String LIST_ERROR = "로그인 로그 목록 조회 중 오류가 발생했습니다.";
|
||||
|
||||
// 상세 조회 관련 메시지
|
||||
public static final String DETAIL_SUCCESS = "로그인 로그 상세 정보를 조회하였습니다.";
|
||||
public static final String DETAIL_ERROR = "로그인 로그 상세 조회 중 오류가 발생했습니다.";
|
||||
public static final String NOT_EXIST = "존재하지 않는 로그입니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 관련 메시지
|
||||
*/
|
||||
public static class Stats {
|
||||
// 디바이스 유형별 통계
|
||||
public static final String DEVICE_SUCCESS = "디바이스 유형별 로그인 통계를 조회하였습니다.";
|
||||
public static final String DEVICE_ERROR = "디바이스 유형별 로그인 통계 조회 중 오류가 발생했습니다.";
|
||||
|
||||
// 성공/실패 비율 통계
|
||||
public static final String SUCCESS_FAIL_SUCCESS = "로그인 성공/실패 비율 통계를 조회하였습니다.";
|
||||
public static final String SUCCESS_FAIL_ERROR = "로그인 성공/실패 비율 통계 조회 중 오류가 발생했습니다.";
|
||||
|
||||
// 시간대별 통계
|
||||
public static final String HOURLY_SUCCESS = "시간대별 로그인 시도 통계를 조회하였습니다.";
|
||||
public static final String HOURLY_ERROR = "시간대별 로그인 시도 통계 조회 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 관련 메시지
|
||||
*/
|
||||
public static class Auth {
|
||||
// 메뉴-역할 관련 메시지
|
||||
public static final String MENU_ROLE_ASSIGN_SUCCESS = "메뉴가 역할에 성공적으로 할당되었습니다.";
|
||||
public static final String MENU_ROLE_REMOVE_SUCCESS = "메뉴가 역할에서 성공적으로 제거되었습니다.";
|
||||
|
||||
// 역할-그룹 관련 메시지
|
||||
public static final String ROLE_GROUP_ASSIGN_SUCCESS = "역할이 그룹에 성공적으로 할당되었습니다.";
|
||||
public static final String ROLE_GROUP_REMOVE_SUCCESS = "역할이 그룹에서 성공적으로 제거되었습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 관련 메시지
|
||||
*/
|
||||
public static class Code {
|
||||
// 코드 상세 관련 메시지
|
||||
public static final String DETAIL_SAVE_SUCCESS = "코드 상세 정보가 성공적으로 저장되었습니다.";
|
||||
|
||||
// 그룹코드 관련 메시지
|
||||
public static final String GROUP_SAVE_SUCCESS = "그룹코드 정보가 성공적으로 저장되었습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 관련 메시지
|
||||
*/
|
||||
public static class Group {
|
||||
// 그룹 저장 관련 메시지
|
||||
public static final String SAVE_SUCCESS = "그룹이 성공적으로 저장되었습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 관련 메시지
|
||||
*/
|
||||
public static class Menu {
|
||||
// 성공 메시지
|
||||
public static final String REGISTER_SUCCESS = "메뉴가 성공적으로 등록되었습니다.";
|
||||
public static final String EDIT_SUCCESS = "메뉴 정보가 성공적으로 수정되었습니다.";
|
||||
public static final String DELETE_SUCCESS = "메뉴가 성공적으로 삭제되었습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String ROOT_REGISTER_ERROR = "ROOT 메뉴 ID로 새 메뉴를 등록할 수 없습니다.";
|
||||
public static final String UPPER_MENU_REQUIRED = "상위 메뉴를 선택해주세요.";
|
||||
public static final String REGISTER_ERROR = "메뉴 등록에 실패했습니다.";
|
||||
public static final String ROOT_EDIT_ERROR = "ROOT 메뉴는 수정할 수 없습니다.";
|
||||
public static final String EDIT_ERROR = "메뉴 정보 수정에 실패했습니다.";
|
||||
public static final String ROOT_DELETE_ERROR = "ROOT 메뉴는 삭제할 수 없습니다.";
|
||||
public static final String CHILD_EXISTS_ERROR = "하위 메뉴가 존재하여 삭제할 수 없습니다.";
|
||||
public static final String DELETE_ERROR = "메뉴 삭제에 실패했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 관련 메시지
|
||||
*/
|
||||
public static class Role {
|
||||
// 성공 메시지
|
||||
public static final String SAVE_SUCCESS = "역할이 성공적으로 저장되었습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String SAVE_ERROR = "역할 저장에 실패했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 관련 메시지
|
||||
*/
|
||||
public static class User {
|
||||
// 성공 메시지
|
||||
public static final String REGISTER_SUCCESS = "사용자가 성공적으로 등록되었습니다.";
|
||||
public static final String EDIT_SUCCESS = "사용자 정보가 성공적으로 수정되었습니다.";
|
||||
public static final String PASSWORD_RESET_SUCCESS = "비밀번호가 성공적으로 초기화되었습니다.";
|
||||
public static final String UNLOCK_SUCCESS = "사용자 잠금이 성공적으로 해제되었습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String REGISTER_ERROR = "사용자 등록에 실패했습니다.";
|
||||
public static final String EDIT_ERROR = "사용자 정보 수정에 실패했습니다.";
|
||||
public static final String NOT_FOUND = "사용자 정보를 찾을 수 없습니다.";
|
||||
public static final String PASSWORD_RESET_ERROR = "비밀번호 초기화에 실패했습니다.";
|
||||
public static final String UNLOCK_ERROR = "사용자 잠금 해제에 실패했습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 그룹 관련 메시지
|
||||
*/
|
||||
public static class UserGroup {
|
||||
// 성공 메시지
|
||||
public static final String GROUP_LIST_SUCCESS = "그룹 목록을 성공적으로 조회했습니다.";
|
||||
public static final String ROLE_LIST_SUCCESS = "역할 목록을 성공적으로 조회했습니다.";
|
||||
public static final String MENU_LIST_SUCCESS = "메뉴 목록을 성공적으로 조회했습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String GROUP_LIST_ERROR = "그룹 목록 조회 중 오류가 발생했습니다: ";
|
||||
public static final String ROLE_LIST_ERROR = "역할 목록 조회 중 오류가 발생했습니다: ";
|
||||
public static final String MENU_LIST_ERROR = "메뉴 목록 조회 중 오류가 발생했습니다: ";
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 메시지
|
||||
*/
|
||||
public static class Common {
|
||||
// 성공 메시지
|
||||
public static final String SAVE_SUCCESS = "저장되었습니다.";
|
||||
public static final String UPDATE_SUCCESS = "수정되었습니다.";
|
||||
public static final String DELETE_SUCCESS = "삭제되었습니다.";
|
||||
|
||||
// 오류 메시지
|
||||
public static final String SAVE_ERROR = "저장 중 오류가 발생했습니다.";
|
||||
public static final String UPDATE_ERROR = "수정 중 오류가 발생했습니다.";
|
||||
public static final String DELETE_ERROR = "삭제 중 오류가 발생했습니다.";
|
||||
public static final String SYSTEM_ERROR = "시스템 오류가 발생했습니다.";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.constant
|
||||
* fileName : SEQConstants
|
||||
* author : 박성영
|
||||
* date : 25. 8. 26.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 8. 26. 박성영 최초 생성
|
||||
*/
|
||||
public class SEQConstants {
|
||||
|
||||
public static final String SEQ_CRDN = "seq_crdn_no_";
|
||||
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.constant
|
||||
* fileName : SessionConstants
|
||||
* author : 개발자
|
||||
* date : 2025-05-22
|
||||
* description : 세션 관련 상수를 정의하는 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-22 개발자 최초 생성
|
||||
*/
|
||||
public class SessionConstants {
|
||||
|
||||
/**
|
||||
* 세션 키
|
||||
* 세션에서 SessionVO 객체를 가져오는 데 사용되는 키
|
||||
*/
|
||||
public static final String SESSION_KEY = "sessionVO";
|
||||
|
||||
/**
|
||||
* 방문자 역할 ID
|
||||
* 방문자 권한을 나타내는 역할 ID
|
||||
*/
|
||||
public static final String VISITOR_ROLE_ID = "ROLE_VISITOR";
|
||||
|
||||
/**
|
||||
* 세션 만료 에러 코드
|
||||
* 세션이 만료되었을 때 사용되는 에러 코드
|
||||
*/
|
||||
public static final String SESSION_EXPIRED = "SESSION_EXPIRED";
|
||||
|
||||
/**
|
||||
* 잘못된 접근 에러 코드
|
||||
* Referer 헤더가 없는 등 비정상적인 접근 시도에 대한 에러 코드
|
||||
*/
|
||||
public static final String INVALID_ACCESS = "INVALID_ACCESS";
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.constant
|
||||
* fileName : TilesConstants
|
||||
* author : 개발자
|
||||
* date : 2025-05-22
|
||||
* description : 타일즈 관련 상수를 정의하는 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-22 개발자 최초 생성
|
||||
*/
|
||||
public class TilesConstants {
|
||||
|
||||
public static final String LOGIN = ".login";
|
||||
|
||||
public static final String BASE = ".base";
|
||||
|
||||
public static final String INNER = ".inner";
|
||||
|
||||
public static final String POPUP = ".popup";
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package egovframework.constant;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.constant
|
||||
* fileName : TuiGridColorConstants
|
||||
* author : 개발자
|
||||
* date : 2025-10-21
|
||||
* description : TUI Grid의 행/셀에 적용하는 사용자 정의 색상 CSS 클래스명을 상수로 관리하는 클래스
|
||||
* 중요로직 주석: 백엔드에서 TUI Grid의 _attributes.className.row 등에 사용할 CSS 클래스명을
|
||||
* 하드코딩하지 않고 본 상수를 통해 일관되게 사용하도록 한다.
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-10-21 개발자 최초 생성 (tui-grid-custom-color* 상수 정의)
|
||||
*/
|
||||
public class TuiGridColorConstants {
|
||||
|
||||
// 공통: 그리드 행 배경색 지정용 CSS 클래스명 (xit-tui-grid.css 참고)
|
||||
public static final String ROW_COLOR_RED = "tui-grid-custom-color-red";
|
||||
public static final String ROW_COLOR_BLUE = "tui-grid-custom-color-blue";
|
||||
public static final String ROW_COLOR_GRAY = "tui-grid-custom-color-gray";
|
||||
public static final String ROW_COLOR_WHITE = "tui-grid-custom-color-white";
|
||||
|
||||
private TuiGridColorConstants() {
|
||||
// 유틸 상수 클래스 - 인스턴스화 방지
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
/**
|
||||
* 접근 권한이 없는 경우 발생하는 예외
|
||||
*/
|
||||
@Getter
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class AccessDeniedException extends RuntimeException {
|
||||
|
||||
private final String userAcnt;
|
||||
private final String requestURI;
|
||||
|
||||
public AccessDeniedException(String userAcnt, String requestURI) {
|
||||
super("접근 권한이 없습니다.");
|
||||
this.userAcnt = userAcnt;
|
||||
this.requestURI = requestURI;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import lombok.Setter;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.egovframe.rte.fdl.cmmn.aspect.ExceptionTransfer;
|
||||
|
||||
@Setter
|
||||
//@Aspect
|
||||
public class EgovAopExceptionTransfer {
|
||||
|
||||
private ExceptionTransfer exceptionTransfer;
|
||||
|
||||
//@Pointcut("execution(* go.kr.project..impl.*Impl.*(..)) or execution(* egovframework.com..*Impl.*(..))")
|
||||
private void exceptionTransferService() {}
|
||||
|
||||
//@AfterThrowing(pointcut="exceptionTransferService()", throwing="ex")
|
||||
public void doAfterThrowingExceptionTransferService(JoinPoint thisJoinPoint, Exception ex) throws Exception {
|
||||
exceptionTransfer.transfer(thisJoinPoint, ex);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.egovframe.rte.fdl.cmmn.exception.handler.ExceptionHandler;
|
||||
|
||||
@Slf4j
|
||||
public class EgovDefaultExcepHndlr implements ExceptionHandler {
|
||||
|
||||
@Override
|
||||
public void occur(Exception ex, String packageName) {
|
||||
log.debug("##### EgovServiceExceptionHandler Run...");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.egovframe.rte.fdl.cmmn.exception.handler.ExceptionHandler;
|
||||
|
||||
@Slf4j
|
||||
public class EgovDefaultOthersExcepHndlr implements ExceptionHandler {
|
||||
|
||||
@Override
|
||||
public void occur(Exception exception, String packageName) {
|
||||
log.debug("##### EgovSampleOthersExcepHndlr Run...");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import egovframework.configProperties.LoginProperties;
|
||||
import egovframework.util.ApiResponseEntity;
|
||||
import egovframework.util.HttpServletUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.TransactionException;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* @ControllerAdvice를 사용한 전역 예외 처리기
|
||||
* 이는 SimpleMappingExceptionResolver 접근 방식을 대체합니다
|
||||
*
|
||||
* AJAX 요청(*.ajax)에 대해서는 JSON 형식의 예외 응답을 반환하고,
|
||||
* 일반 요청에 대해서는 egovError.jsp 페이지로 리다이렉트합니다.
|
||||
*/
|
||||
@Slf4j
|
||||
@ControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class EgovExceptionAdvice {
|
||||
|
||||
private final LoginProperties loginProperties;
|
||||
|
||||
/**
|
||||
* DataAccessException 예외 처리
|
||||
* 데이터 액세스 관련 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(DataAccessException.class)
|
||||
public Object handleDataAccessException(DataAccessException e, HttpServletRequest request) {
|
||||
log.error("DataAccessException 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* TransactionException 예외 처리
|
||||
* 트랜잭션 관련 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(TransactionException.class)
|
||||
public Object handleTransactionException(TransactionException e, HttpServletRequest request) {
|
||||
log.error("TransactionException 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 AccessDeniedException 예외 처리
|
||||
* 접근 권한이 없는 경우 발생하는 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public Object handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) {
|
||||
log.warn("접근 권한 예외 발생 - 사용자: {}, URI: {}", e.getUserAcnt(), e.getRequestURI());
|
||||
return getModelAndView(e, request, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 만료 관련 예외 처리
|
||||
* HttpSessionRequiredException이 없으므로 일반적인 예외 처리로 대체합니다.
|
||||
* 이 메서드는 AuthInterceptor에서 세션 만료 시 AJAX 요청에 대한 처리를 보완합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* RuntimeException 예외 처리
|
||||
* 런타임 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public Object handleRuntimeException(RuntimeException e, HttpServletRequest request) {
|
||||
log.error("RuntimeException 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception 예외 처리
|
||||
* 일반 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Object handleException(Exception e, HttpServletRequest request) {
|
||||
log.error("Exception 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* IOException 예외 처리
|
||||
* IO 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(IOException.class)
|
||||
public Object handleIOException(Exception e, HttpServletRequest request) {
|
||||
log.error("IOException 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwable 예외 처리
|
||||
* 모든 예외의 상위 클래스인 Throwable을 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(Throwable.class)
|
||||
public Object handleThrowable(Throwable e, HttpServletRequest request) {
|
||||
log.error("Throwable 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageException 예외 처리
|
||||
* 메시지 관련 예외를 처리합니다.
|
||||
*/
|
||||
@ExceptionHandler(MessageException.class)
|
||||
public Object handleMessageException(MessageException e, HttpServletRequest request) {
|
||||
log.warn("MessageException 발생: ", e);
|
||||
return getModelAndView(e, request, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 객체를 생성하는 헬퍼 메소드
|
||||
* AJAX 요청인 경우 ResponseEntity<ApiResponse<Void>>를 반환하고,
|
||||
* 일반 요청인 경우 egovError 페이지로 리다이렉트하는 ModelAndView를 반환합니다.
|
||||
* AJAX 요청 판단은 URL 패턴(*.ajax) 또는 HTTP 헤더 정보를 기반으로 합니다.
|
||||
*
|
||||
* @param e 발생한 예외
|
||||
* @param request HTTP 요청 객체
|
||||
* @param status HTTP 상태 코드
|
||||
* @return AJAX 요청인 경우 ResponseEntity, 일반 요청인 경우 ModelAndView
|
||||
*/
|
||||
private Object getModelAndView(Throwable e, HttpServletRequest request, HttpStatus status) {
|
||||
// AJAX 요청인 경우 ApiResponse를 사용한 JSON 응답 반환
|
||||
if (HttpServletUtil.isAjaxRequest(request) || HttpServletUtil.isRealAjaxRequest(request)) {
|
||||
ApiResponseEntity<Void> responseBody = ApiResponseEntity.<Void>builder()
|
||||
.result(false)
|
||||
.message(e.getMessage())
|
||||
.errorCode(e.getClass().getSimpleName())
|
||||
.build();
|
||||
return new ResponseEntity<>(responseBody, status);
|
||||
}
|
||||
|
||||
// 일반 요청인 경우 egovError 페이지로 리다이렉트
|
||||
ModelAndView mav = new ModelAndView("error/egovError");
|
||||
mav.addObject("exceptionName", e.getClass().getSimpleName());
|
||||
mav.addObject("exception", e);
|
||||
return mav;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package egovframework.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 기본 예외 클래스
|
||||
*
|
||||
* 이 예외는 애플리케이션에서 발생하는 기본적인 예외를 처리하기 위해 사용됩니다.
|
||||
* BasicException 발생 시 에러코드는 "MESSAGE"로 표시되며,
|
||||
* 메시지만 사용자에게 표출됩니다.
|
||||
*/
|
||||
@Getter
|
||||
public class MessageException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 에러 코드
|
||||
*/
|
||||
private final String errorCode;
|
||||
|
||||
/**
|
||||
* 기본 생성자
|
||||
*
|
||||
* @param message 예외 메시지
|
||||
*/
|
||||
public MessageException(String message) {
|
||||
super(message);
|
||||
this.errorCode = "MESSAGE_EXCEPTION";
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 코드를 지정할 수 있는 생성자
|
||||
*
|
||||
* @param message 예외 메시지
|
||||
* @param errorCode 에러 코드
|
||||
*/
|
||||
public MessageException(String message, String errorCode) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원인 예외를 포함하는 생성자
|
||||
*
|
||||
* @param message 예외 메시지
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
public MessageException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = "MESSAGE_EXCEPTION";
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 코드와 원인 예외를 모두 지정할 수 있는 생성자
|
||||
*
|
||||
* @param message 예외 메시지
|
||||
* @param errorCode 에러 코드
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
public MessageException(String message, String errorCode, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package egovframework.filter;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.*;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.filter
|
||||
* fileName : SessionRefreshFilter
|
||||
* author : 박성영
|
||||
* date : 25. 5. 21.
|
||||
* description : 세션 갱신을 위한 필터
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 21. 박성영 최초 생성
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SessionRefreshFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
|
||||
// 배치 작업 경로는 세션 리프레시에서 제외
|
||||
String requestURI = httpRequest.getRequestURI();
|
||||
if (requestURI != null && requestURI.startsWith("/batch/")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션이 있으면 접근하여 갱신 (세션 타임아웃 연장)
|
||||
httpRequest.getSession(false);
|
||||
|
||||
// 디버깅 용도 (필요시 주석 해제)
|
||||
// if (httpRequest.getSession().getAttribute("sessionVO") != null) {
|
||||
// log.debug("세션 리프레시 - 세션 정보 존재");
|
||||
// } else {
|
||||
// log.debug("세션 리프레시 - 세션 정보 없음");
|
||||
// }
|
||||
} catch (Exception e) {
|
||||
// 세션 관련 오류 발생 시 로그만 남기고 계속 진행
|
||||
log.debug("세션 리프레시 중 오류 발생: {}", e.getMessage());
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package egovframework.filter;
|
||||
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
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 java.io.IOException;
|
||||
|
||||
/**
|
||||
* XSS 공격 방지를 위한 필터
|
||||
* 모든 요청과 응답에 대해 XSS 필터링을 적용
|
||||
*/
|
||||
public class XssFilter extends OncePerRequestFilter {
|
||||
|
||||
private final XssUtil xssUtil;
|
||||
|
||||
public XssFilter(XssUtil xssUtil) {
|
||||
this.xssUtil = xssUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@Nullable HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// XSS 방지를 위한 헤더 설정
|
||||
response.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
// 요청을 래핑하여 XSS 필터링 적용
|
||||
XssRequestWrapper xssRequestWrapper = new XssRequestWrapper(request, xssUtil);
|
||||
|
||||
// 필터 체인 실행
|
||||
filterChain.doFilter(xssRequestWrapper, response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package egovframework.filter;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* XSS 필터 설정 클래스
|
||||
*/
|
||||
@Configuration
|
||||
public class XssFilterConfig {
|
||||
|
||||
private final XssUtil xssUtil;
|
||||
|
||||
public XssFilterConfig(XssUtil xssUtil) {
|
||||
this.xssUtil = xssUtil;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS 필터 등록
|
||||
* @return FilterRegistrationBean
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
|
||||
FilterRegistrationBean<XssFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
registrationBean.setFilter(new XssFilter(xssUtil));
|
||||
registrationBean.addUrlPatterns("/*"); // 모든 URL에 적용
|
||||
registrationBean.setName("xssFilter");
|
||||
registrationBean.setOrder(1); // 필터 순서 (낮은 숫자가 먼저 실행)
|
||||
return registrationBean;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package egovframework.filter;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* XSS 필터링을 위한 HttpServletRequest 래퍼 클래스
|
||||
*/
|
||||
public class XssRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final XssUtil xssUtil;
|
||||
|
||||
public XssRequestWrapper(HttpServletRequest request, XssUtil xssUtil) {
|
||||
super(request);
|
||||
this.xssUtil = xssUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
String value = super.getParameter(name);
|
||||
return xssFilter(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
String[] values = super.getParameterValues(name);
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] filteredValues = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
filteredValues[i] = xssFilter(name, values[i]);
|
||||
}
|
||||
return filteredValues;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String[]> getParameterMap() {
|
||||
Map<String, String[]> paramMap = super.getParameterMap();
|
||||
Map<String, String[]> filteredParamMap = new HashMap<>();
|
||||
|
||||
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
|
||||
String[] values = entry.getValue();
|
||||
String[] filteredValues = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
filteredValues[i] = xssFilter(entry.getKey(), values[i]);
|
||||
}
|
||||
filteredParamMap.put(entry.getKey(), filteredValues);
|
||||
}
|
||||
return filteredParamMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS 필터링 적용
|
||||
* @param name 파라미터 이름
|
||||
* @param value 파라미터 값
|
||||
* @return 필터링된 값
|
||||
*/
|
||||
private String xssFilter(String name, String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTML 에디터 내용인 경우 cleanHtml 메서드 사용
|
||||
//if (name != null && (name.contains("content") || name.contains("html") || name.contains("editor"))) {
|
||||
if (name != null && (name.contains("html") || name.contains("editor"))) {
|
||||
return xssUtil.cleanHtml(value);
|
||||
}
|
||||
|
||||
// 파일명인 경우 sanitizeFilename 메서드 사용
|
||||
if (name != null && (name.contains("filename") || name.contains("file"))) {
|
||||
return xssUtil.sanitizeFilename(value);
|
||||
}
|
||||
|
||||
// 일반 텍스트인 경우 escape 메서드 사용
|
||||
//return xssUtil.escape(value);
|
||||
// TODO : 임시 해제!! 추후 보안을 위해 다시 적용해야함!!
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package egovframework.filter;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* XSS(Cross-Site Scripting) 방지를 위한 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class XssUtil {
|
||||
|
||||
/**
|
||||
* HTML 태그를 이스케이프 처리하여 XSS 공격 방지
|
||||
* @param value 원본 문자열
|
||||
* @return 이스케이프 처리된 문자열
|
||||
*/
|
||||
public String escape(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("/", "/")
|
||||
.replaceAll("\\(", "(")
|
||||
.replaceAll("\\)", ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* 이스케이프 처리된 문자열을 원래 형태로 복원
|
||||
* @param value 이스케이프 처리된 문자열
|
||||
* @return 원본 문자열
|
||||
*/
|
||||
public String unescape(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("/", "/")
|
||||
.replaceAll("(", "(")
|
||||
.replaceAll(")", ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 에디터에서 사용하는 태그만 허용하고 나머지는 이스케이프 처리
|
||||
* @param value 원본 HTML 문자열
|
||||
* @return 안전한 HTML 문자열
|
||||
*/
|
||||
public String cleanHtml(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 허용할 HTML 태그 패턴
|
||||
String allowedTags = "<p>|<br>|<b>|<i>|<strong>|<em>|<u>|<ul>|<ol>|<li>|<h1>|<h2>|<h3>|<h4>|<h5>|<h6>|<table>|<tr>|<td>|<th>|<tbody>|<thead>|<img>|<a>|<div>|<span>";
|
||||
|
||||
// 스크립트 태그 및 이벤트 핸들러 패턴
|
||||
String scriptPattern = "<script[^>]*>.*?</script>";
|
||||
String eventPattern = " on\\w+=\".*?\"";
|
||||
String stylePattern = " style=\".*?\"";
|
||||
String inlineFramePattern = "<iframe[^>]*>.*?</iframe>";
|
||||
String embedPattern = "<embed[^>]*>.*?</embed>";
|
||||
String objectPattern = "<object[^>]*>.*?</object>";
|
||||
|
||||
// 스크립트 및 이벤트 핸들러 제거
|
||||
value = Pattern.compile(scriptPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile(eventPattern, Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile(inlineFramePattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile(embedPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile(objectPattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
|
||||
// style 속성은 제한적으로 허용 (위험한 표현식 제거)
|
||||
value = Pattern.compile("expression\\s*\\(.*?\\)", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
value = Pattern.compile("vbscript:", Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("");
|
||||
|
||||
// href 속성에서 javascript: 제거
|
||||
value = Pattern.compile("href\\s*=\\s*['\"]\\s*javascript:.*?['\"]", Pattern.CASE_INSENSITIVE).matcher(value).replaceAll("href=\"#\"");
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명에서 XSS 공격 가능성이 있는 문자 제거
|
||||
* @param filename 원본 파일명
|
||||
* @return 안전한 파일명
|
||||
*/
|
||||
public String sanitizeFilename(String filename) {
|
||||
if (filename == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 파일명에서 위험한 문자 제거
|
||||
return filename.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,413 @@
|
||||
package egovframework.interceptor;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import egovframework.configProperties.InterceptorProperties;
|
||||
import egovframework.configProperties.LoginProperties;
|
||||
import egovframework.constant.SessionConstants;
|
||||
import egovframework.exception.AccessDeniedException;
|
||||
import egovframework.filter.XssUtil;
|
||||
import egovframework.util.ApiResponseEntity;
|
||||
import egovframework.util.HttpServletUtil;
|
||||
import go.kr.project.login.mapper.LoginMapper;
|
||||
import go.kr.project.login.model.SessionVO;
|
||||
import go.kr.project.login.model.UserSessionVO;
|
||||
import go.kr.project.login.service.LoginService;
|
||||
import go.kr.project.system.menu.model.MenuVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.interceptor
|
||||
* fileName : AuthInterceptor
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-05-13
|
||||
* description : 접근 제어를 위한 인터셉터
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-13 시스템 관리자 최초 생성
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final LoginService loginService;
|
||||
|
||||
// URL 패턴 매칭을 위한 AntPathMatcher
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
// XSS 방지 유틸리티
|
||||
private final XssUtil xssUtil = new XssUtil();
|
||||
|
||||
// JSON 변환을 위한 ObjectMapper
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// Referer 검사에서 제외할 URL 패턴 목록 설정
|
||||
@Autowired
|
||||
private InterceptorProperties interceptorProperties;
|
||||
|
||||
// 로그인 관련 설정
|
||||
@Autowired
|
||||
private LoginProperties loginProperties;
|
||||
|
||||
// 로그인 매퍼
|
||||
@Autowired
|
||||
private LoginMapper loginMapper;
|
||||
|
||||
/**
|
||||
* 컨트롤러 실행 전 접근 제어 처리
|
||||
* @param request 요청 객체
|
||||
* @param response 응답 객체
|
||||
* @param handler 핸들러 객체
|
||||
* @return 접근 허용 여부
|
||||
* @throws Exception 예외 발생 시
|
||||
*/
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Object handler) throws Exception {
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
// 접근 제어 예외 URL 패턴은 WebMvcConfigurer에서 처리됨
|
||||
// 이 메서드가 호출된다는 것은 이미 예외 패턴이 아니라는 의미
|
||||
|
||||
// Referer 헤더 검사 - URL을 브라우저 주소창에 직접 입력해서 접근하는 것을 방지
|
||||
// 첫 페이지 접근이나 로그인 페이지 등 일부 URL은 Referer 검사에서 제외
|
||||
if (!isRefererCheckExcluded(requestURI)) {
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer == null || referer.isEmpty()) {
|
||||
// Referer 헤더가 없는 경우 (직접 URL 입력 또는 북마크 등)
|
||||
log.warn("Referer 헤더 없음: {}", requestURI);
|
||||
|
||||
// AJAX 요청인 경우 JSON 응답 반환
|
||||
if (isAjaxRequest(request)) {
|
||||
handleRefererMissing(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
||||
redirectToLoginPageWithPopupCheck(request, response, "잘못된 접근입니다. 로그인 페이지로 이동합니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션 정보 조회
|
||||
SessionVO sessionVO = loginService.getSessionInfo(request);
|
||||
|
||||
// 세션이 있고 로그인 상태인 경우, 동시 접속 허용 불가일 때만 세션 정보를 DB에서 확인
|
||||
if (sessionVO != null && sessionVO.isLogin() && !loginProperties.isAllowMultipleLogin()) {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session != null) {
|
||||
String sessionId = session.getId();
|
||||
String userId = sessionVO.getUser().getUserId();
|
||||
|
||||
// DB에서 세션 정보 조회
|
||||
UserSessionVO userSessionVO = loginMapper.selectUserSessionBySessionId(sessionId);
|
||||
|
||||
if (userSessionVO == null) {
|
||||
// DB에 세션 정보가 없으면 현재 세션 무효화
|
||||
log.warn("DB에 세션 정보가 없음. 세션 무효화 (동시 접속 제한): {}", sessionId);
|
||||
session.invalidate();
|
||||
|
||||
// AJAX 요청인 경우 JSON 응답 반환
|
||||
if (isAjaxRequest(request)) {
|
||||
handleAjaxSessionExpired(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
||||
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
||||
|
||||
return false;
|
||||
} else {
|
||||
// DB에 세션 정보가 있으면 마지막 접속 시간 업데이트
|
||||
userSessionVO.setLastAccessDttm(LocalDateTime.now());
|
||||
loginMapper.updateUserSession(userSessionVO);
|
||||
log.debug("세션 정보 업데이트 (동시 접속 제한): {}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 세션이 없거나 로그인 상태가 아닌 경우
|
||||
if (sessionVO == null || !sessionVO.isLogin()) {
|
||||
// 방문자 권한 확인
|
||||
if (sessionVO != null && sessionVO.isVisitor()) {
|
||||
// 방문자 권한으로 접근 가능한지 확인
|
||||
if (hasAccess(sessionVO, requestURI)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// AJAX 요청인 경우 JSON 응답 반환
|
||||
if (isAjaxRequest(request)) {
|
||||
handleAjaxSessionExpired(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
||||
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 초기화 여부 확인
|
||||
if ("Y".equals(sessionVO.getUser().getPasswdInitYn())) {
|
||||
// 비밀번호 변경 페이지 또는 비밀번호 변경 처리 URL인 경우에만 접근 허용
|
||||
if (requestURI.equals("/mypage/password.do") ||
|
||||
requestURI.equals("/mypage/password.ajax") ||
|
||||
requestURI.equals("/mypage/check-password.ajax") ||
|
||||
requestURI.equals("/mypage/password-reset.do")) {
|
||||
return true;
|
||||
} else {
|
||||
// 비밀번호 변경 페이지로 리다이렉트
|
||||
if (isAjaxRequest(request)) {
|
||||
// AJAX 요청인 경우 JSON 응답 반환
|
||||
sendJsonResponse(response, HttpStatus.UNAUTHORIZED, false, "비밀번호를 변경해야 합니다.", "PASSWORD_RESET_REQUIRED");
|
||||
return false;
|
||||
} else {
|
||||
// 일반 요청인 경우 비밀번호 변경 페이지로 리다이렉트
|
||||
redirectToPage(request, response, "비밀번호를 변경해야 합니다.", "/mypage/password-reset.do");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 상태인 경우 접근 권한 확인
|
||||
if (hasAccess(sessionVO, requestURI)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 접근 권한이 없는 경우 예외 발생
|
||||
log.warn("접근 권한 없음: {} - {}", sessionVO.getUser().getUserAcnt(), requestURI);
|
||||
throw new AccessDeniedException(sessionVO.getUser().getUserAcnt(), requestURI);
|
||||
//response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
|
||||
//return false;
|
||||
} catch (AccessDeniedException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("접근 제어 처리 중 오류 발생", e);
|
||||
|
||||
// AJAX 요청인 경우 JSON 응답 반환
|
||||
if (isAjaxRequest(request)) {
|
||||
try {
|
||||
handleAjaxSessionExpired(response);
|
||||
} catch (IOException ex) {
|
||||
log.error("AJAX 응답 처리 중 오류 발생", ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 요청인 경우 로그인 페이지로 리다이렉트
|
||||
try {
|
||||
redirectToLoginPageWithPopupCheck(request, response, "세션이 종료되었습니다. 로그인 페이지로 이동합니다.");
|
||||
} catch (IOException ex) {
|
||||
log.error("리다이렉트 처리 중 오류 발생", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 권한이 있는지 확인
|
||||
* @param sessionVO 세션 정보
|
||||
* @param requestURI 요청 URI
|
||||
* @return 접근 권한 여부
|
||||
*/
|
||||
private boolean hasAccess(SessionVO sessionVO, String requestURI) {
|
||||
// 메뉴 트리를 1차원 리스트로 변환
|
||||
List<MenuVO> menus = flattenMenuTree(sessionVO.getMenus());
|
||||
if (menus.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 메뉴의 URL 패턴과 일치하는지 확인
|
||||
for (MenuVO menu : menus) {
|
||||
if (menu.getUrlPattern() != null && !menu.getUrlPattern().isEmpty()) {
|
||||
// XSS 방지를 위한 이스케이프 처리된 URL 패턴을 원래대로 복원하고 쉼표로 구분된 패턴을 분리
|
||||
String[] patterns = xssUtil.unescape(menu.getUrlPattern()).split(",");
|
||||
for (String pattern : patterns) {
|
||||
// 각 패턴과 요청 URI를 비교하여 일치하면 접근 허용
|
||||
if (pathMatcher.match(pattern.trim(), requestURI)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 일치하는 패턴이 없으면 접근 불가
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 트리 구조를 1차원 리스트로 변환
|
||||
* 재귀적으로 모든 하위 메뉴를 포함하는 평면화된 리스트를 생성
|
||||
*
|
||||
* @param menus 메뉴 트리 구조
|
||||
* @return 1차원으로 변환된 메뉴 리스트
|
||||
*/
|
||||
private List<MenuVO> flattenMenuTree(List<MenuVO> menus) {
|
||||
List<MenuVO> flatList = new ArrayList<>();
|
||||
if (menus != null) {
|
||||
for (MenuVO menu : menus) {
|
||||
// 현재 메뉴를 리스트에 추가
|
||||
flatList.add(menu);
|
||||
|
||||
// 하위 메뉴가 있으면 재귀적으로 처리하여 리스트에 추가
|
||||
if (menu.getChildren() != null && !menu.getChildren().isEmpty()) {
|
||||
flatList.addAll(flattenMenuTree(menu.getChildren()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return flatList;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 요청에 대한 세션 만료 응답 처리
|
||||
* 클라이언트에게 세션 만료 정보를 JSON 형식으로 반환
|
||||
*
|
||||
* @param response HTTP 응답 객체
|
||||
* @throws IOException 입출력 예외 발생 시
|
||||
*/
|
||||
private void handleAjaxSessionExpired(HttpServletResponse response) throws IOException {
|
||||
sendJsonResponse(response, HttpStatus.UNAUTHORIZED, false,
|
||||
"세션이 만료되었습니다. 로그인 페이지로 이동합니다.",
|
||||
SessionConstants.SESSION_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Referer 헤더가 없는 AJAX 요청에 대한 응답 처리
|
||||
* 직접 URL 입력 등 비정상적인 접근 시도에 대한 응답 처리
|
||||
*
|
||||
* @param response HTTP 응답 객체
|
||||
* @throws IOException 입출력 예외 발생 시
|
||||
*/
|
||||
private void handleRefererMissing(HttpServletResponse response) throws IOException {
|
||||
sendJsonResponse(response, HttpStatus.FORBIDDEN, false,
|
||||
"잘못된 접근입니다. 로그인 페이지로 이동합니다.",
|
||||
SessionConstants.INVALID_ACCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 응답을 생성하여 클라이언트에게 반환
|
||||
*
|
||||
* @param response HTTP 응답 객체
|
||||
* @param status HTTP 상태 코드
|
||||
* @param result 결과 성공 여부
|
||||
* @param message 메시지
|
||||
* @param errorCode 에러 코드
|
||||
* @throws IOException 입출력 예외 발생 시
|
||||
*/
|
||||
private void sendJsonResponse(HttpServletResponse response, HttpStatus status, boolean result,
|
||||
String message, String errorCode) throws IOException {
|
||||
// 응답 상태 코드 및 컨텐츠 타입 설정
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
// 응답 객체 생성
|
||||
ApiResponseEntity<Void> responseBody = ApiResponseEntity.<Void>builder()
|
||||
.result(result)
|
||||
.message(message)
|
||||
.errorCode(errorCode)
|
||||
.build();
|
||||
|
||||
// JSON 변환 및 응답 출력
|
||||
response.getWriter().write(objectMapper.writeValueAsString(responseBody));
|
||||
}
|
||||
|
||||
/**
|
||||
* Referer 검사에서 제외할 URL 패턴인지 확인
|
||||
* 로그인 페이지, 메인 페이지 등 직접 접근이 허용되는 URL인지 확인
|
||||
*
|
||||
* @param requestURI 요청 URI
|
||||
* @return Referer 검사 제외 여부 (true: 제외, false: 검사 필요)
|
||||
*/
|
||||
private boolean isRefererCheckExcluded(String requestURI) {
|
||||
// 설정된 제외 패턴과 요청 URI를 비교
|
||||
for (String pattern : interceptorProperties.getRefererExclude()) {
|
||||
if (pathMatcher.match(pattern, requestURI)) {
|
||||
return true; // 제외 패턴에 해당하면 Referer 검사 제외
|
||||
}
|
||||
}
|
||||
|
||||
// 제외 패턴에 해당하지 않으면 Referer 검사 필요
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX 요청인지 확인
|
||||
*
|
||||
* @param request HTTP 요청 객체
|
||||
* @return AJAX 요청 여부
|
||||
*/
|
||||
private boolean isAjaxRequest(HttpServletRequest request) {
|
||||
return HttpServletUtil.isAjaxRequest(request) || HttpServletUtil.isRealAjaxRequest(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 페이지로 리다이렉트
|
||||
*
|
||||
* @param request HTTP 요청 객체
|
||||
* @param response HTTP 응답 객체
|
||||
* @param message 알림 메시지
|
||||
* @param targetUrl 리다이렉트 대상 URL
|
||||
* @throws IOException 입출력 예외 발생 시
|
||||
*/
|
||||
private void redirectToPage(HttpServletRequest request, HttpServletResponse response,
|
||||
String message, String targetUrl) throws IOException {
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
response.getWriter().write("<script type='text/javascript'>");
|
||||
response.getWriter().write("alert('" + message + "');");
|
||||
response.getWriter().write("location.href = '" + request.getContextPath() + targetUrl + "';");
|
||||
response.getWriter().write("</script>");
|
||||
}
|
||||
|
||||
/**
|
||||
* 팝업 창 확인 후 로그인 페이지로 리다이렉트
|
||||
*
|
||||
* @param request HTTP 요청 객체
|
||||
* @param response HTTP 응답 객체
|
||||
* @param message 알림 메시지
|
||||
* @throws IOException 입출력 예외 발생 시
|
||||
*/
|
||||
private void redirectToLoginPageWithPopupCheck(HttpServletRequest request, HttpServletResponse response,
|
||||
String message) throws IOException {
|
||||
try {
|
||||
// 메시지를 request attribute로 설정
|
||||
request.setAttribute("errorMessage", message);
|
||||
// 401.jsp로 forward하여 handleSessionExpired() 함수 호출
|
||||
request.getRequestDispatcher("/WEB-INF/views/error/401.jsp").forward(request, response);
|
||||
} catch (Exception e) {
|
||||
log.error("401 페이지 forward 실패, 기존 방식으로 처리", e);
|
||||
// forward 실패 시 기존 방식으로 fallback
|
||||
response.setContentType("text/html; charset=UTF-8");
|
||||
response.getWriter().write("<script type='text/javascript'>");
|
||||
response.getWriter().write("alert('" + message + "');");
|
||||
response.getWriter().write("if(window.opener) {");
|
||||
response.getWriter().write(" var topWindow = window;");
|
||||
response.getWriter().write(" while(topWindow.opener && !topWindow.opener.closed) {");
|
||||
response.getWriter().write(" topWindow = topWindow.opener;");
|
||||
response.getWriter().write(" }");
|
||||
response.getWriter().write(" topWindow.location.href = '" + request.getContextPath() + loginProperties.getUrl() + "';");
|
||||
response.getWriter().write(" window.close();");
|
||||
response.getWriter().write("} else {");
|
||||
response.getWriter().write(" location.href = '" + request.getContextPath() + loginProperties.getUrl() + "';");
|
||||
response.getWriter().write("}");
|
||||
response.getWriter().write("</script>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package egovframework.util;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.common.model
|
||||
* fileName : ApiResponse
|
||||
* author : 박성영
|
||||
* date : 25. 5. 8.
|
||||
* description : AJAX 요청에 대한 공통 응답 객체
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 8. 박성영 최초 생성
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiResponseEntity<T> {
|
||||
|
||||
/**
|
||||
* 요청 처리 성공 여부
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 요청 처리 성공 여부
|
||||
*/
|
||||
private boolean result;
|
||||
|
||||
/**
|
||||
* 응답 메시지
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 응답 데이터 (단일 객체 또는 리스트)
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 에러 코드 (실패 시)
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package egovframework.util;
|
||||
|
||||
import go.kr.project.common.model.PagingVO;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.common.util
|
||||
* fileName : ApiResponseUtil
|
||||
* author : 박성영
|
||||
* date : 25. 5. 8.
|
||||
* description : AJAX 요청에 대한 공통 응답 객체 생성 유틸리티
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 8. 박성영 최초 생성
|
||||
*/
|
||||
public class ApiResponseUtil {
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (데이터 없음)
|
||||
* @param message 응답 메시지
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static ResponseEntity<ApiResponseEntity<Void>> success(String message) {
|
||||
ApiResponseEntity<Void> response = ApiResponseEntity.<Void>builder()
|
||||
.success(true)
|
||||
.result(true)
|
||||
.message(message)
|
||||
.build();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (단일 객체)
|
||||
* @param data 응답 데이터
|
||||
* @param message 응답 메시지
|
||||
* @param <T> 데이터 타입
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static <T> ResponseEntity<ApiResponseEntity<T>> success(T data, String message) {
|
||||
ApiResponseEntity<T> response = ApiResponseEntity.<T>builder()
|
||||
.success(true)
|
||||
.result(true)
|
||||
.message(message)
|
||||
.data(data)
|
||||
.build();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (단일 객체, 기본 메시지)
|
||||
* @param data 응답 데이터
|
||||
* @param <T> 데이터 타입
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static <T> ResponseEntity<ApiResponseEntity<T>> success(T data) {
|
||||
return success(data, "요청이 성공적으로 처리되었습니다.");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 페이징 정보를 포함한 성공 응답 생성
|
||||
* TOAST UI Grid 응답 형식에 맞게 구성
|
||||
*
|
||||
* TOAST UI Grid는 다음과 같은 응답 형식을 기대합니다:
|
||||
* {
|
||||
* data: {
|
||||
* contents: [...], // 실제 데이터 배열
|
||||
* pagination: {
|
||||
* page: number,
|
||||
* perPage: number,
|
||||
* totalCount: number
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param data 응답 데이터 리스트
|
||||
* @param pagingVO 페이징 정보 객체
|
||||
* @param message 응답 메시지
|
||||
* @param <T> 데이터 타입
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static <T> ResponseEntity<ApiResponseEntity<Map<String, Object>>> successWithGrid(T data, PagingVO pagingVO, String message) {
|
||||
// TOAST UI Grid 형식에 맞는 데이터 구조 생성
|
||||
Map<String, Object> gridData = new HashMap<>();
|
||||
|
||||
// contents에 실제 데이터 배열 설정
|
||||
gridData.put("contents", data);
|
||||
|
||||
// pagination 정보 설정
|
||||
if( "Y".equals(pagingVO.getPagingYn()) ){
|
||||
Map<String, Object> paginationMap = new HashMap<>();
|
||||
paginationMap.put("page", pagingVO.getPage());
|
||||
paginationMap.put("perPage", pagingVO.getPerPage());
|
||||
paginationMap.put("totalCount", pagingVO.getTotalCount());
|
||||
paginationMap.put("totalPages", pagingVO.getTotalPages());
|
||||
|
||||
gridData.put("pagination", paginationMap);
|
||||
}
|
||||
|
||||
// 응답 객체 생성
|
||||
ApiResponseEntity<Map<String, Object>> response = ApiResponseEntity.<Map<String, Object>>builder()
|
||||
.success(true)
|
||||
.result(true)
|
||||
.message(message)
|
||||
.data(gridData)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 정보를 포함한 성공 응답 생성 (기본 메시지)
|
||||
* @param data 응답 데이터 리스트
|
||||
* @param pagingVO 페이징 정보 객체
|
||||
* @param <T> 데이터 타입
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static <T> ResponseEntity<ApiResponseEntity<Map<String, Object>>> successWithGrid(T data, PagingVO pagingVO) {
|
||||
return successWithGrid(data, pagingVO, "요청이 성공적으로 처리되었습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성
|
||||
* @param message 에러 메시지
|
||||
* @param errorCode 에러 코드
|
||||
* @param status HTTP 상태 코드
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static ResponseEntity<ApiResponseEntity<Void>> error(String message, String errorCode, HttpStatus status) {
|
||||
ApiResponseEntity<Void> response = ApiResponseEntity.<Void>builder()
|
||||
.success(false)
|
||||
.result(false)
|
||||
.message(message)
|
||||
.errorCode(errorCode)
|
||||
.build();
|
||||
return ResponseEntity.status(status).body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성 (기본 HTTP 상태: BAD_REQUEST)
|
||||
* @param message 에러 메시지
|
||||
* @param errorCode 에러 코드
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static ResponseEntity<ApiResponseEntity<Void>> error(String message, String errorCode) {
|
||||
return error(message, errorCode, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성 (기본 에러 코드: "UNKNOWN_ERROR")
|
||||
* @param message 에러 메시지
|
||||
* @param status HTTP 상태 코드
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static ResponseEntity<ApiResponseEntity<Void>> error(String message, HttpStatus status) {
|
||||
return error(message, "UNKNOWN_ERROR", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성 (기본 HTTP 상태: BAD_REQUEST, 기본 에러 코드: "UNKNOWN_ERROR")
|
||||
* @param message 에러 메시지
|
||||
* @return ResponseEntity 객체
|
||||
*/
|
||||
public static ResponseEntity<ApiResponseEntity<Void>> error(String message) {
|
||||
return error(message, "MESSAGE", HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package egovframework.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.util
|
||||
* fileName : BatchSessionUtil
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-01-27
|
||||
* description : 배치 작업에서 사용할 수 있는 세션 관리 유틸리티 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-01-27 시스템 관리자 최초 생성
|
||||
*/
|
||||
@Slf4j
|
||||
public class BatchSessionUtil {
|
||||
|
||||
/**
|
||||
* 배치 작업용 시스템 사용자 ID
|
||||
*/
|
||||
public static final String BATCH_SYSTEM_USER_ID = "BATCH_SYSTEM";
|
||||
|
||||
/**
|
||||
* 배치 작업용 시스템 사용자 계정
|
||||
*/
|
||||
public static final String BATCH_SYSTEM_USER_ACCOUNT = "BATCH_SYSTEM";
|
||||
|
||||
/**
|
||||
* 배치 작업용 시스템 사용자 이름
|
||||
*/
|
||||
public static final String BATCH_SYSTEM_USER_NAME = "시스템 배치";
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 사용자 ID를 반환합니다.
|
||||
*
|
||||
* @return 배치 시스템 사용자 ID
|
||||
*/
|
||||
public static String getBatchUserId() {
|
||||
return BATCH_SYSTEM_USER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 사용자 계정을 반환합니다.
|
||||
*
|
||||
* @return 배치 시스템 사용자 계정
|
||||
*/
|
||||
public static String getBatchUserAccount() {
|
||||
return BATCH_SYSTEM_USER_ACCOUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 사용자 이름을 반환합니다.
|
||||
*
|
||||
* @return 배치 시스템 사용자 이름
|
||||
*/
|
||||
public static String getBatchUserName() {
|
||||
return BATCH_SYSTEM_USER_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업이 시스템 권한을 가지고 있는지 확인합니다.
|
||||
*
|
||||
* @return 항상 true (배치 작업은 시스템 권한으로 실행)
|
||||
*/
|
||||
public static boolean isBatchSystemUser() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업의 실행 컨텍스트 정보를 로깅합니다.
|
||||
*
|
||||
* @param jobName 배치 작업 이름
|
||||
* @param jobGroup 배치 작업 그룹
|
||||
*/
|
||||
public static void logBatchContext(String jobName, String jobGroup) {
|
||||
log.info("배치 작업 실행 컨텍스트 - 작업: {}.{}, 사용자: {}, 계정: {}, 시스템권한: {}",
|
||||
jobGroup, jobName, getBatchUserId(), getBatchUserAccount(), isBatchSystemUser());
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 세션 정보 접근 시 안전한 처리를 위한 래퍼 메서드입니다.
|
||||
*
|
||||
* @param sessionAccessor 세션 접근 함수
|
||||
* @param defaultValue 기본값
|
||||
* @param <T> 반환 타입
|
||||
* @return 세션 정보 또는 기본값
|
||||
*/
|
||||
public static <T> T safeSessionAccess(SessionAccessor<T> sessionAccessor, T defaultValue) {
|
||||
try {
|
||||
return sessionAccessor.access();
|
||||
} catch (Exception e) {
|
||||
log.debug("세션 접근 실패 (배치 컨텍스트), 기본값 사용: {}", e.getMessage());
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 접근을 위한 함수형 인터페이스
|
||||
*
|
||||
* @param <T> 반환 타입
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface SessionAccessor<T> {
|
||||
T access() throws Exception;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,255 @@
|
||||
package egovframework.util;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.util
|
||||
* fileName : ClientInfoUtil
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-05-22
|
||||
* description : 클라이언트 정보(디바이스, OS, 브라우저 등) 추출 유틸리티 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-22 시스템 관리자 최초 생성
|
||||
*/
|
||||
public class ClientInfoUtil {
|
||||
|
||||
/**
|
||||
* User-Agent 문자열에서 디바이스 정보를 추출합니다.
|
||||
*
|
||||
* @param userAgent 사용자 에이전트 문자열
|
||||
* @return 디바이스 정보
|
||||
*/
|
||||
public static String extractDeviceInfo(String userAgent) {
|
||||
if (userAgent == null || userAgent.isEmpty()) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
userAgent = userAgent.toLowerCase();
|
||||
StringBuilder deviceInfo = new StringBuilder();
|
||||
|
||||
// OS 정보 추출
|
||||
String osInfo = extractOsInfo(userAgent);
|
||||
|
||||
// 디바이스 유형 및 제조사 정보 추출
|
||||
String deviceType = extractDeviceType(userAgent);
|
||||
|
||||
// 브라우저 정보 추출
|
||||
String browserInfo = extractBrowserInfo(userAgent);
|
||||
|
||||
// 정보 조합
|
||||
if (!deviceType.isEmpty()) {
|
||||
deviceInfo.append(deviceType);
|
||||
}
|
||||
|
||||
if (!osInfo.isEmpty()) {
|
||||
if (deviceInfo.length() > 0) {
|
||||
deviceInfo.append(" - ");
|
||||
}
|
||||
deviceInfo.append(osInfo);
|
||||
}
|
||||
|
||||
// 모바일/태블릿이 아닌 경우에만 브라우저 정보 추가
|
||||
if (!deviceType.contains("Mobile") && !deviceType.contains("Tablet") && !browserInfo.isEmpty()) {
|
||||
if (deviceInfo.length() > 0) {
|
||||
deviceInfo.append(" - ");
|
||||
}
|
||||
deviceInfo.append(browserInfo);
|
||||
}
|
||||
|
||||
return deviceInfo.length() > 0 ? deviceInfo.toString() : "Unknown Device";
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent 문자열에서 OS 정보를 추출합니다.
|
||||
*
|
||||
* @param userAgent 사용자 에이전트 문자열
|
||||
* @return OS 정보
|
||||
*/
|
||||
public static String extractOsInfo(String userAgent) {
|
||||
// Windows 버전 확인
|
||||
if (userAgent.contains("windows")) {
|
||||
if (userAgent.contains("windows nt 10")) {
|
||||
return "Windows 10";
|
||||
} else if (userAgent.contains("windows nt 6.3")) {
|
||||
return "Windows 8.1";
|
||||
} else if (userAgent.contains("windows nt 6.2")) {
|
||||
return "Windows 8";
|
||||
} else if (userAgent.contains("windows nt 6.1")) {
|
||||
return "Windows 7";
|
||||
} else if (userAgent.contains("windows nt 6.0")) {
|
||||
return "Windows Vista";
|
||||
} else if (userAgent.contains("windows nt 5.1") || userAgent.contains("windows xp")) {
|
||||
return "Windows XP";
|
||||
} else {
|
||||
return "Windows";
|
||||
}
|
||||
}
|
||||
|
||||
// Mac OS 버전 확인
|
||||
if (userAgent.contains("macintosh") || userAgent.contains("mac os x")) {
|
||||
if (userAgent.contains("mac os x 10_15")) {
|
||||
return "macOS Catalina";
|
||||
} else if (userAgent.contains("mac os x 10_14")) {
|
||||
return "macOS Mojave";
|
||||
} else if (userAgent.contains("mac os x 10_13")) {
|
||||
return "macOS High Sierra";
|
||||
} else if (userAgent.contains("mac os x 10_12")) {
|
||||
return "macOS Sierra";
|
||||
} else {
|
||||
return "Mac OS";
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 OS 확인
|
||||
if (userAgent.contains("android")) {
|
||||
// Android 버전 추출
|
||||
int startIndex = userAgent.indexOf("android ");
|
||||
if (startIndex != -1) {
|
||||
int endIndex = userAgent.indexOf(";", startIndex);
|
||||
if (endIndex != -1) {
|
||||
return "Android " + userAgent.substring(startIndex + 8, endIndex).trim();
|
||||
}
|
||||
}
|
||||
return "Android";
|
||||
} else if (userAgent.contains("ios")) {
|
||||
return "iOS";
|
||||
} else if (userAgent.contains("iphone os") || userAgent.contains("cpu os")) {
|
||||
// iOS 버전 추출
|
||||
int startIndex = userAgent.contains("iphone os ") ? userAgent.indexOf("iphone os ") + 10 : userAgent.indexOf("cpu os ") + 7;
|
||||
if (startIndex != -1) {
|
||||
int endIndex = userAgent.indexOf(" ", startIndex);
|
||||
if (endIndex != -1) {
|
||||
String version = userAgent.substring(startIndex, endIndex).trim().replace("_", ".");
|
||||
return "iOS " + version;
|
||||
}
|
||||
}
|
||||
return "iOS";
|
||||
} else if (userAgent.contains("linux")) {
|
||||
return "Linux";
|
||||
} else if (userAgent.contains("unix")) {
|
||||
return "Unix";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent 문자열에서 디바이스 유형 및 제조사 정보를 추출합니다.
|
||||
*
|
||||
* @param userAgent 사용자 에이전트 문자열
|
||||
* @return 디바이스 유형 및 제조사 정보
|
||||
*/
|
||||
public static String extractDeviceType(String userAgent) {
|
||||
// 모바일 디바이스 확인
|
||||
if (userAgent.contains("mobile")) {
|
||||
if (userAgent.contains("iphone")) {
|
||||
return "iPhone";
|
||||
} else if (userAgent.contains("android")) {
|
||||
// 안드로이드 제조사 확인
|
||||
if (userAgent.contains("samsung")) {
|
||||
return "Samsung Mobile";
|
||||
} else if (userAgent.contains("lg")) {
|
||||
return "LG Mobile";
|
||||
} else if (userAgent.contains("huawei")) {
|
||||
return "Huawei Mobile";
|
||||
} else if (userAgent.contains("xiaomi")) {
|
||||
return "Xiaomi Mobile";
|
||||
} else if (userAgent.contains("oppo")) {
|
||||
return "OPPO Mobile";
|
||||
} else if (userAgent.contains("vivo")) {
|
||||
return "Vivo Mobile";
|
||||
} else if (userAgent.contains("oneplus")) {
|
||||
return "OnePlus Mobile";
|
||||
} else if (userAgent.contains("motorola")) {
|
||||
return "Motorola Mobile";
|
||||
} else if (userAgent.contains("nokia")) {
|
||||
return "Nokia Mobile";
|
||||
} else {
|
||||
return "Android Mobile";
|
||||
}
|
||||
} else if (userAgent.contains("windows phone")) {
|
||||
return "Windows Phone";
|
||||
} else {
|
||||
return "Mobile Device";
|
||||
}
|
||||
}
|
||||
|
||||
// 태블릿 확인
|
||||
if (userAgent.contains("ipad")) {
|
||||
return "iPad";
|
||||
} else if (userAgent.contains("android") && !userAgent.contains("mobile")) {
|
||||
// 안드로이드 태블릿 제조사 확인
|
||||
if (userAgent.contains("samsung")) {
|
||||
return "Samsung Tablet";
|
||||
} else if (userAgent.contains("huawei")) {
|
||||
return "Huawei Tablet";
|
||||
} else if (userAgent.contains("lenovo")) {
|
||||
return "Lenovo Tablet";
|
||||
} else if (userAgent.contains("asus")) {
|
||||
return "Asus Tablet";
|
||||
} else if (userAgent.contains("amazon") || userAgent.contains("kindle")) {
|
||||
return "Amazon Tablet";
|
||||
} else {
|
||||
return "Android Tablet";
|
||||
}
|
||||
} else if (userAgent.contains("tablet")) {
|
||||
return "Tablet";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent 문자열에서 브라우저 정보를 추출합니다.
|
||||
*
|
||||
* @param userAgent 사용자 에이전트 문자열
|
||||
* @return 브라우저 정보
|
||||
*/
|
||||
public static String extractBrowserInfo(String userAgent) {
|
||||
if (userAgent.contains("edge/") || userAgent.contains("edg/")) {
|
||||
return "Edge Browser";
|
||||
} else if (userAgent.contains("chrome/")) {
|
||||
return "Chrome Browser";
|
||||
} else if (userAgent.contains("safari/") && !userAgent.contains("chrome/")) {
|
||||
return "Safari Browser";
|
||||
} else if (userAgent.contains("firefox/")) {
|
||||
return "Firefox Browser";
|
||||
} else if (userAgent.contains("opera/") || userAgent.contains("opr/")) {
|
||||
return "Opera Browser";
|
||||
} else if (userAgent.contains("msie") || userAgent.contains("trident/")) {
|
||||
return "Internet Explorer";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 IP 주소를 가져옵니다.
|
||||
*
|
||||
* @param request HTTP 요청 객체
|
||||
* @return 클라이언트 IP 주소
|
||||
*/
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,431 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 컬렉션 관련 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class CollectionUtil {
|
||||
|
||||
/**
|
||||
* 컬렉션이 null이거나 비어있는지 확인
|
||||
* @param collection 검사할 컬렉션
|
||||
* @return null이거나 비어있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isEmpty(Collection<?> collection) {
|
||||
return collection == null || collection.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션이 null이 아니고 비어있지 않은지 확인
|
||||
* @param collection 검사할 컬렉션
|
||||
* @return null이 아니고 비어있지 않으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNotEmpty(Collection<?> collection) {
|
||||
return !isEmpty(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵이 null이거나 비어있는지 확인
|
||||
* @param map 검사할 맵
|
||||
* @return null이거나 비어있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isEmpty(Map<?, ?> map) {
|
||||
return map == null || map.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵이 null이 아니고 비어있지 않은지 확인
|
||||
* @param map 검사할 맵
|
||||
* @return null이 아니고 비어있지 않으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNotEmpty(Map<?, ?> map) {
|
||||
return !isEmpty(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열이 null이거나 비어있는지 확인
|
||||
* @param array 검사할 배열
|
||||
* @return null이거나 비어있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static <T> boolean isEmpty(T[] array) {
|
||||
return array == null || array.length == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열이 null이 아니고 비어있지 않은지 확인
|
||||
* @param array 검사할 배열
|
||||
* @return null이 아니고 비어있지 않으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static <T> boolean isNotEmpty(T[] array) {
|
||||
return !isEmpty(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션의 크기 반환 (null 안전)
|
||||
* @param collection 검사할 컬렉션
|
||||
* @return 컬렉션의 크기, null이면 0 반환
|
||||
*/
|
||||
public static int size(Collection<?> collection) {
|
||||
return collection == null ? 0 : collection.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵의 크기 반환 (null 안전)
|
||||
* @param map 검사할 맵
|
||||
* @return 맵의 크기, null이면 0 반환
|
||||
*/
|
||||
public static int size(Map<?, ?> map) {
|
||||
return map == null ? 0 : map.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열의 크기 반환 (null 안전)
|
||||
* @param array 검사할 배열
|
||||
* @return 배열의 크기, null이면 0 반환
|
||||
*/
|
||||
public static <T> int size(T[] array) {
|
||||
return array == null ? 0 : array.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션이 null이면 빈 컬렉션 반환, 그렇지 않으면 원래 컬렉션 반환
|
||||
* @param collection 처리할 컬렉션
|
||||
* @return null이면 빈 컬렉션, 그렇지 않으면 원래 컬렉션
|
||||
*/
|
||||
public static <T> Collection<T> emptyIfNull(Collection<T> collection) {
|
||||
return collection == null ? Collections.emptyList() : collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트가 null이면 빈 리스트 반환, 그렇지 않으면 원래 리스트 반환
|
||||
* @param list 처리할 리스트
|
||||
* @return null이면 빈 리스트, 그렇지 않으면 원래 리스트
|
||||
*/
|
||||
public static <T> List<T> emptyIfNull(List<T> list) {
|
||||
return list == null ? Collections.emptyList() : list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵이 null이면 빈 맵 반환, 그렇지 않으면 원래 맵 반환
|
||||
* @param map 처리할 맵
|
||||
* @return null이면 빈 맵, 그렇지 않으면 원래 맵
|
||||
*/
|
||||
public static <K, V> Map<K, V> emptyIfNull(Map<K, V> map) {
|
||||
return map == null ? Collections.emptyMap() : map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열을 리스트로 변환 (null 안전)
|
||||
* @param array 변환할 배열
|
||||
* @return 변환된 리스트, null이면 빈 리스트 반환
|
||||
*/
|
||||
public static <T> List<T> toList(T[] array) {
|
||||
if (array == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Arrays.asList(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 리스트로 변환 (null 안전)
|
||||
* @param collection 변환할 컬렉션
|
||||
* @return 변환된 리스트, null이면 빈 리스트 반환
|
||||
*/
|
||||
public static <T> List<T> toList(Collection<T> collection) {
|
||||
if (collection == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return new ArrayList<>(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 Set으로 변환 (null 안전)
|
||||
* @param collection 변환할 컬렉션
|
||||
* @return 변환된 Set, null이면 빈 Set 반환
|
||||
*/
|
||||
public static <T> Set<T> toSet(Collection<T> collection) {
|
||||
if (collection == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return new HashSet<>(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열을 Set으로 변환 (null 안전)
|
||||
* @param array 변환할 배열
|
||||
* @return 변환된 Set, null이면 빈 Set 반환
|
||||
*/
|
||||
public static <T> Set<T> toSet(T[] array) {
|
||||
if (array == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<T> set = new HashSet<>();
|
||||
Collections.addAll(set, array);
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션에서 조건에 맞는 요소만 필터링
|
||||
* @param collection 필터링할 컬렉션
|
||||
* @param predicate 필터링 조건
|
||||
* @return 필터링된 리스트
|
||||
*/
|
||||
public static <T> List<T> filter(Collection<T> collection, Predicate<T> predicate) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return collection.stream()
|
||||
.filter(predicate)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션의 요소를 변환
|
||||
* @param collection 변환할 컬렉션
|
||||
* @param mapper 변환 함수
|
||||
* @return 변환된 리스트
|
||||
*/
|
||||
public static <T, R> List<R> map(Collection<T> collection, Function<T, R> mapper) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return collection.stream()
|
||||
.map(mapper)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 정렬
|
||||
* @param collection 정렬할 컬렉션
|
||||
* @param comparator 정렬 기준
|
||||
* @return 정렬된 리스트
|
||||
*/
|
||||
public static <T> List<T> sort(Collection<T> collection, Comparator<? super T> comparator) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<T> list = new ArrayList<>(collection);
|
||||
list.sort(comparator);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 자연 순서로 정렬 (Comparable 구현 필요)
|
||||
* @param collection 정렬할 컬렉션
|
||||
* @return 정렬된 리스트
|
||||
*/
|
||||
public static <T extends Comparable<? super T>> List<T> sort(Collection<T> collection) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<T> list = new ArrayList<>(collection);
|
||||
Collections.sort(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션에서 중복 제거
|
||||
* @param collection 중복 제거할 컬렉션
|
||||
* @return 중복이 제거된 리스트
|
||||
*/
|
||||
public static <T> List<T> distinct(Collection<T> collection) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return collection.stream()
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 특정 키를 기준으로 그룹화
|
||||
* @param collection 그룹화할 컬렉션
|
||||
* @param keyMapper 그룹화 키 추출 함수
|
||||
* @return 그룹화된 맵
|
||||
*/
|
||||
public static <T, K> Map<K, List<T>> groupBy(Collection<T> collection, Function<T, K> keyMapper) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return collection.stream()
|
||||
.collect(Collectors.groupingBy(keyMapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 특정 키를 기준으로 맵으로 변환
|
||||
* @param collection 변환할 컬렉션
|
||||
* @param keyMapper 키 추출 함수
|
||||
* @return 변환된 맵
|
||||
*/
|
||||
public static <T, K> Map<K, T> toMap(Collection<T> collection, Function<T, K> keyMapper) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return collection.stream()
|
||||
.collect(Collectors.toMap(keyMapper, Function.identity(), (a, b) -> a));
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 특정 키와 값을 기준으로 맵으로 변환
|
||||
* @param collection 변환할 컬렉션
|
||||
* @param keyMapper 키 추출 함수
|
||||
* @param valueMapper 값 추출 함수
|
||||
* @return 변환된 맵
|
||||
*/
|
||||
public static <T, K, V> Map<K, V> toMap(Collection<T> collection, Function<T, K> keyMapper, Function<T, V> valueMapper) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return collection.stream()
|
||||
.collect(Collectors.toMap(keyMapper, valueMapper, (a, b) -> a));
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 컬렉션의 합집합 반환
|
||||
* @param collection1 첫 번째 컬렉션
|
||||
* @param collection2 두 번째 컬렉션
|
||||
* @return 합집합 리스트
|
||||
*/
|
||||
public static <T> List<T> union(Collection<T> collection1, Collection<T> collection2) {
|
||||
Set<T> set = new HashSet<>();
|
||||
if (isNotEmpty(collection1)) {
|
||||
set.addAll(collection1);
|
||||
}
|
||||
if (isNotEmpty(collection2)) {
|
||||
set.addAll(collection2);
|
||||
}
|
||||
return new ArrayList<>(set);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 컬렉션의 교집합 반환
|
||||
* @param collection1 첫 번째 컬렉션
|
||||
* @param collection2 두 번째 컬렉션
|
||||
* @return 교집합 리스트
|
||||
*/
|
||||
public static <T> List<T> intersection(Collection<T> collection1, Collection<T> collection2) {
|
||||
if (isEmpty(collection1) || isEmpty(collection2)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<T> result = new ArrayList<>();
|
||||
Set<T> set = new HashSet<>(collection2);
|
||||
|
||||
for (T item : collection1) {
|
||||
if (set.contains(item)) {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 컬렉션의 차집합 반환 (collection1 - collection2)
|
||||
* @param collection1 첫 번째 컬렉션
|
||||
* @param collection2 두 번째 컬렉션
|
||||
* @return 차집합 리스트
|
||||
*/
|
||||
public static <T> List<T> difference(Collection<T> collection1, Collection<T> collection2) {
|
||||
if (isEmpty(collection1)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (isEmpty(collection2)) {
|
||||
return new ArrayList<>(collection1);
|
||||
}
|
||||
|
||||
List<T> result = new ArrayList<>(collection1);
|
||||
result.removeAll(new HashSet<>(collection2));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션을 지정된 크기의 하위 리스트로 분할
|
||||
* @param collection 분할할 컬렉션
|
||||
* @param size 하위 리스트의 크기
|
||||
* @return 분할된 하위 리스트의 리스트
|
||||
*/
|
||||
public static <T> List<List<T>> partition(Collection<T> collection, int size) {
|
||||
if (isEmpty(collection)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (size <= 0) {
|
||||
throw new IllegalArgumentException("Size must be greater than 0");
|
||||
}
|
||||
|
||||
List<List<T>> result = new ArrayList<>();
|
||||
List<T> list = new ArrayList<>(collection);
|
||||
int total = list.size();
|
||||
|
||||
for (int i = 0; i < total; i += size) {
|
||||
result.add(list.subList(i, Math.min(total, i + size)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션에서 첫 번째 요소 반환 (null 안전)
|
||||
* @param collection 처리할 컬렉션
|
||||
* @return 첫 번째 요소, 없으면 null 반환
|
||||
*/
|
||||
public static <T> T getFirst(Collection<T> collection) {
|
||||
if (isEmpty(collection)) {
|
||||
return null;
|
||||
}
|
||||
return collection.iterator().next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트에서 마지막 요소 반환 (null 안전)
|
||||
* @param list 처리할 리스트
|
||||
* @return 마지막 요소, 없으면 null 반환
|
||||
*/
|
||||
public static <T> T getLast(List<T> list) {
|
||||
if (isEmpty(list)) {
|
||||
return null;
|
||||
}
|
||||
return list.get(list.size() - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵에서 키에 해당하는 값 반환 (null 안전)
|
||||
* @param map 처리할 맵
|
||||
* @param key 키
|
||||
* @param defaultValue 기본값
|
||||
* @return 키에 해당하는 값, 없으면 기본값 반환
|
||||
*/
|
||||
public static <K, V> V getOrDefault(Map<K, V> map, K key, V defaultValue) {
|
||||
if (isEmpty(map)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return map.getOrDefault(key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 맵을 병합
|
||||
* @param map1 첫 번째 맵
|
||||
* @param map2 두 번째 맵
|
||||
* @return 병합된 맵
|
||||
*/
|
||||
public static <K, V> Map<K, V> merge(Map<K, V> map1, Map<K, V> map2) {
|
||||
Map<K, V> result = new HashMap<>();
|
||||
|
||||
if (isNotEmpty(map1)) {
|
||||
result.putAll(map1);
|
||||
}
|
||||
|
||||
if (isNotEmpty(map2)) {
|
||||
result.putAll(map2);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,567 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.TextStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 날짜 관련 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class DateUtil {
|
||||
|
||||
/**
|
||||
* 현재 날짜의 년월 정보를 "yyyyMM" 형식으로 반환
|
||||
* @return 년월 문자열 (예: "202405")
|
||||
*/
|
||||
public String getCurrentYearMonth() {
|
||||
LocalDate now = LocalDate.now();
|
||||
return now.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜를 지정된 형식으로 반환
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd", "yyyy/MM/dd")
|
||||
* @return 형식화된 날짜 문자열
|
||||
*/
|
||||
public String getCurrentDate(String pattern) {
|
||||
LocalDate now = LocalDate.now();
|
||||
return now.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 현재 날짜에 지정된 일수를 더하거나 뺀 날짜를 지정된 형식으로 반환
|
||||
*
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd")
|
||||
* @param days 더하거나 뺄 일수 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static String getCurrentDateAddDays(String pattern, int days) {
|
||||
LocalDate now = LocalDate.now();
|
||||
return now.plusDays(days).format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜에 지정된 개월수를 더하거나 뺀 날짜를 지정된 형식으로 반환
|
||||
*
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd")
|
||||
* @param months 더하거나 뺄 개월수 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static String getCurrentDateAddMonths(String pattern, int months) {
|
||||
LocalDate now = LocalDate.now();
|
||||
return now.plusMonths(months).format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜와 시간을 지정된 형식으로 반환
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss")
|
||||
* @return 형식화된 날짜 문자열
|
||||
*/
|
||||
public static String getCurrentDateTime(String pattern) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return now.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 30분 단위로 조정하여 지정된 형식으로 반환
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss")
|
||||
* @return 30분 단위로 조정된 날짜 문자열
|
||||
*/
|
||||
public static String getCurrentDateTimeRoundedToHalfHour(String pattern) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime adjusted;
|
||||
|
||||
int minute = now.getMinute();
|
||||
|
||||
if (minute < 30) {
|
||||
// 0~29분이면 같은 시간의 30분으로 조정
|
||||
adjusted = now.withMinute(30).withSecond(0).withNano(0);
|
||||
} else {
|
||||
// 30~59분이면 다음 시간의 00분으로 조정
|
||||
adjusted = now.plusHours(1).withMinute(0).withSecond(0).withNano(0);
|
||||
}
|
||||
|
||||
return adjusted.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 30분 단위로 조정하고 1시간을 추가하여 지정된 형식으로 반환
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss")
|
||||
* @return 30분 단위로 조정되고 1시간 추가된 날짜 문자열
|
||||
*/
|
||||
public static String getCurrentDateTimeRoundedToHalfHourPlusOneHour(String pattern) {
|
||||
// 먼저 30분 단위로 조정된 시간을 구한 후
|
||||
LocalDateTime adjusted = LocalDateTime.parse(
|
||||
getCurrentDateTimeRoundedToHalfHour("yyyy-MM-dd'T'HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
|
||||
);
|
||||
|
||||
// 1시간 추가
|
||||
adjusted = adjusted.plusHours(1);
|
||||
|
||||
return adjusted.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 주어진 시간을 30분 단위로 조정하여 반환
|
||||
* @param dateTime 조정할 LocalDateTime 객체
|
||||
* @return 30분 단위로 조정된 LocalDateTime 객체
|
||||
*/
|
||||
public static LocalDateTime roundToHalfHour(LocalDateTime dateTime) {
|
||||
if (dateTime == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int minute = dateTime.getMinute();
|
||||
|
||||
if (minute < 30) {
|
||||
// 0~29분이면 같은 시간의 30분으로 조정
|
||||
return dateTime.withMinute(30).withSecond(0).withNano(0);
|
||||
} else {
|
||||
// 30~59분이면 다음 시간의 00분으로 조정
|
||||
return dateTime.plusHours(1).withMinute(0).withSecond(0).withNano(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 문자열을 LocalDate 객체로 변환
|
||||
* @param dateStr 날짜 문자열
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd")
|
||||
* @return 변환된 LocalDate 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static LocalDate parseLocalDate(String dateStr, String pattern) {
|
||||
if (dateStr == null || pattern == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(pattern));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 LocalDateTime 객체로 변환
|
||||
* @param dateTimeStr 날짜 시간 문자열
|
||||
* @param pattern 날짜 시간 형식 (예: "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss")
|
||||
* @return 변환된 LocalDateTime 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static LocalDateTime parseLocalDateTime(String dateTimeStr, String pattern) {
|
||||
if (dateTimeStr == null || pattern == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ofPattern(pattern));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* yyyyMMdd 형식의 날짜 문자열을 yyyy-MM-dd 형식으로 변환
|
||||
* @param dateStr 날짜 문자열 (yyyyMMdd 형식, 예: "20250825")
|
||||
* @return 변환된 날짜 문자열 (yyyy-MM-dd 형식, 예: "2025-08-25"), 변환 실패 시 null 반환
|
||||
*/
|
||||
public static String formatDateString(String dateStr) {
|
||||
if (dateStr == null || dateStr.length() != 8) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// yyyyMMdd 형식으로 파싱한 후 yyyy-MM-dd 형식으로 포맷
|
||||
LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDate 객체를 문자열로 변환
|
||||
* @param date LocalDate 객체
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd")
|
||||
* @return 변환된 날짜 문자열, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static String formatLocalDate(LocalDate date, String pattern) {
|
||||
if (date == null || pattern == null) {
|
||||
return null;
|
||||
}
|
||||
return date.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDateTime 객체를 문자열로 변환
|
||||
* @param dateTime LocalDateTime 객체
|
||||
* @param pattern 날짜 시간 형식 (예: "yyyy-MM-dd HH:mm:ss", "yyyyMMddHHmmss")
|
||||
* @return 변환된 날짜 시간 문자열, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static String formatLocalDateTime(Object dateTime, String pattern) {
|
||||
if (!(dateTime instanceof LocalDateTime) || pattern == null) {
|
||||
return "";
|
||||
}
|
||||
return ((LocalDateTime) dateTime).format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 객체를 LocalDate 객체로 변환
|
||||
* @param date Date 객체
|
||||
* @return 변환된 LocalDate 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static LocalDate toLocalDate(Date date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 객체를 LocalDateTime 객체로 변환
|
||||
* @param date Date 객체
|
||||
* @return 변환된 LocalDateTime 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static LocalDateTime toLocalDateTime(Date date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDate 객체를 Date 객체로 변환
|
||||
* @param localDate LocalDate 객체
|
||||
* @return 변환된 Date 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static Date toDate(LocalDate localDate) {
|
||||
if (localDate == null) {
|
||||
return null;
|
||||
}
|
||||
return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDateTime 객체를 Date 객체로 변환
|
||||
* @param localDateTime LocalDateTime 객체
|
||||
* @return 변환된 Date 객체, 변환 실패 시 null 반환
|
||||
*/
|
||||
public static Date toDate(LocalDateTime localDateTime) {
|
||||
if (localDateTime == null) {
|
||||
return null;
|
||||
}
|
||||
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜에 일수를 더하거나 뺀 날짜 반환
|
||||
* @param date 기준 날짜
|
||||
* @param days 더하거나 뺄 일수 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static LocalDate addDays(LocalDate date, int days) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.plusDays(days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜에 월수를 더하거나 뺀 날짜 반환
|
||||
* @param date 기준 날짜
|
||||
* @param months 더하거나 뺄 월수 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static LocalDate addMonths(LocalDate date, int months) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.plusMonths(months);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜에 연수를 더하거나 뺀 날짜 반환
|
||||
* @param date 기준 날짜
|
||||
* @param years 더하거나 뺄 연수 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static LocalDate addYears(LocalDate date, int years) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.plusYears(years);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜에 지정된 년/월/일을 더하거나 뺀 날짜를 지정된 형식으로 반환
|
||||
*
|
||||
* @param pattern 날짜 형식 (예: "yyyy-MM-dd", "yyyyMMdd")
|
||||
* @param type 년월일 구분 (Y: 년, M: 월, D: 일)
|
||||
* @param amount 더하거나 뺄 값 (양수: 더하기, 음수: 빼기)
|
||||
* @return 계산된 날짜
|
||||
*/
|
||||
public static String getCurrentDateAdd(String pattern, String type, int amount) {
|
||||
LocalDate now = LocalDate.now();
|
||||
LocalDate result;
|
||||
|
||||
if ("Y".equalsIgnoreCase(type) || "year".equalsIgnoreCase(type)) {
|
||||
result = now.plusYears(amount);
|
||||
} else if ("M".equalsIgnoreCase(type) || "month".equalsIgnoreCase(type)) {
|
||||
result = now.plusMonths(amount);
|
||||
} else if ("D".equalsIgnoreCase(type) || "day".equalsIgnoreCase(type)) {
|
||||
result = now.plusDays(amount);
|
||||
} else {
|
||||
result = now;
|
||||
}
|
||||
|
||||
return result.format(DateTimeFormatter.ofPattern(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜 사이의 일수 계산
|
||||
* @param startDate 시작 날짜
|
||||
* @param endDate 종료 날짜
|
||||
* @return 두 날짜 사이의 일수 (endDate - startDate)
|
||||
*/
|
||||
public static long daysBetween(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return ChronoUnit.DAYS.between(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜 사이의 월수 계산
|
||||
* @param startDate 시작 날짜
|
||||
* @param endDate 종료 날짜
|
||||
* @return 두 날짜 사이의 월수 (endDate - startDate)
|
||||
*/
|
||||
public static long monthsBetween(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return ChronoUnit.MONTHS.between(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜 사이의 연수 계산
|
||||
* @param startDate 시작 날짜
|
||||
* @param endDate 종료 날짜
|
||||
* @return 두 날짜 사이의 연수 (endDate - startDate)
|
||||
*/
|
||||
public static long yearsBetween(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return ChronoUnit.YEARS.between(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜 사이의 기간 계산
|
||||
* @param startDate 시작 날짜
|
||||
* @param endDate 종료 날짜
|
||||
* @return 두 날짜 사이의 기간 (년, 월, 일)
|
||||
*/
|
||||
public static Period periodBetween(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return Period.ZERO;
|
||||
}
|
||||
return Period.between(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 요일 이름 반환 (한글)
|
||||
* @param date 날짜
|
||||
* @return 요일 이름 (예: "월요일", "화요일")
|
||||
*/
|
||||
public static String getDayOfWeekName(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.KOREAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 요일 이름 반환 (영문)
|
||||
* @param date 날짜
|
||||
* @return 요일 이름 (예: "Monday", "Tuesday")
|
||||
*/
|
||||
public static String getDayOfWeekNameInEnglish(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 요일 이름 반환 (한글 약자)
|
||||
* @param date 날짜
|
||||
* @return 요일 이름 (예: "월", "화")
|
||||
*/
|
||||
public static String getDayOfWeekShortName(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜가 주말인지 확인
|
||||
* @param date 날짜
|
||||
* @return 주말이면 true, 평일이면 false
|
||||
*/
|
||||
public static boolean isWeekend(LocalDate date) {
|
||||
if (date == null) {
|
||||
return false;
|
||||
}
|
||||
DayOfWeek dayOfWeek = date.getDayOfWeek();
|
||||
return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜가 평일인지 확인
|
||||
* @param date 날짜
|
||||
* @return 평일이면 true, 주말이면 false
|
||||
*/
|
||||
public static boolean isWeekday(LocalDate date) {
|
||||
return !isWeekend(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜가 오늘인지 확인
|
||||
* @param date 날짜
|
||||
* @return 오늘이면 true, 아니면 false
|
||||
*/
|
||||
public static boolean isToday(LocalDate date) {
|
||||
if (date == null) {
|
||||
return false;
|
||||
}
|
||||
return date.equals(LocalDate.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 월의 첫 날 반환
|
||||
* @param date 날짜
|
||||
* @return 월의 첫 날
|
||||
*/
|
||||
public static LocalDate getFirstDayOfMonth(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.firstDayOfMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 월의 마지막 날 반환
|
||||
* @param date 날짜
|
||||
* @return 월의 마지막 날
|
||||
*/
|
||||
public static LocalDate getLastDayOfMonth(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.lastDayOfMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 연도의 첫 날 반환
|
||||
* @param date 날짜
|
||||
* @return 연도의 첫 날
|
||||
*/
|
||||
public static LocalDate getFirstDayOfYear(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.firstDayOfYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 연도의 마지막 날 반환
|
||||
* @param date 날짜
|
||||
* @return 연도의 마지막 날
|
||||
*/
|
||||
public static LocalDate getLastDayOfYear(LocalDate date) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.lastDayOfYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 다음 특정 요일 반환
|
||||
* @param date 날짜
|
||||
* @param dayOfWeek 요일 (예: DayOfWeek.MONDAY)
|
||||
* @return 다음 특정 요일
|
||||
*/
|
||||
public static LocalDate getNextDayOfWeek(LocalDate date, DayOfWeek dayOfWeek) {
|
||||
if (date == null || dayOfWeek == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.next(dayOfWeek));
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜의 이전 특정 요일 반환
|
||||
* @param date 날짜
|
||||
* @param dayOfWeek 요일 (예: DayOfWeek.MONDAY)
|
||||
* @return 이전 특정 요일
|
||||
*/
|
||||
public static LocalDate getPreviousDayOfWeek(LocalDate date, DayOfWeek dayOfWeek) {
|
||||
if (date == null || dayOfWeek == null) {
|
||||
return null;
|
||||
}
|
||||
return date.with(TemporalAdjusters.previous(dayOfWeek));
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 날짜에 시간을 결합하여 LocalDateTime 객체 생성
|
||||
* @param date 날짜
|
||||
* @param hour 시
|
||||
* @param minute 분
|
||||
* @param second 초
|
||||
* @return 생성된 LocalDateTime 객체
|
||||
*/
|
||||
public static LocalDateTime combineDateTime(LocalDate date, int hour, int minute, int second) {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
return LocalDateTime.of(date, LocalTime.of(hour, minute, second));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 종료까지 남은 시간을 "HH:mm:ss" 형식으로 반환
|
||||
* @param request HttpServletRequest 객체
|
||||
* @param pattern 날짜 형식 (예: "HH:mm:ss")
|
||||
* @return 세션 종료까지 남은 시간 문자열 (HH:mm:ss 형식)
|
||||
*/
|
||||
public static String getSessionExpiryTime(HttpServletRequest request, String pattern) {
|
||||
if (request == null) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
// 세션 최대 유효 시간(초)
|
||||
int maxInactiveInterval = session.getMaxInactiveInterval();
|
||||
|
||||
// 시, 분, 초 계산
|
||||
int hours = maxInactiveInterval / 3600;
|
||||
int minutes = (maxInactiveInterval % 3600) / 60;
|
||||
int seconds = maxInactiveInterval % 60;
|
||||
|
||||
// HH:mm:ss 형식으로 반환
|
||||
return String.format("%02d:%02d:%02d", hours, minutes, seconds);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Class Name : EgovFileScrty.java
|
||||
* Description : Base64인코딩/디코딩 방식을 이용한 데이터를 암호화/복호화하는 Business Interface class
|
||||
* Modification Information
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ------- -------- ---------------------------
|
||||
* 2009.02.04 박지욱 최초 생성
|
||||
*
|
||||
* @author 공통 서비스 개발팀 박지욱
|
||||
* @since 2009. 02. 04
|
||||
* @version 1.0
|
||||
* @see
|
||||
*
|
||||
* Copyright (C) 2009 by MOPAS All right reserved.
|
||||
*/
|
||||
package egovframework.util;
|
||||
|
||||
//import org.apache.tomcat.util.codec.binary.Base64;
|
||||
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* @Class Name : EgovFileScrty.java
|
||||
* @Description : 파일 및 텍스트 문자열 암호화 처리하는 구현 클래스
|
||||
* @Modification Information
|
||||
*
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- ------- -------------------
|
||||
* 2019.11.29 신용호 encryptPassword(String data) 삭제 : KISA 보안약점 조치 (비밀번호 해시함수 적용 시 솔트를 사용하여야 함)
|
||||
* 2022.11.16 신용호 소스코드 보안 조치
|
||||
*
|
||||
* @author 공통컴포넌트개발팀 한성곤
|
||||
* @since 2009.08.26
|
||||
* @version 1.0
|
||||
*/
|
||||
public class EgovFileScrty {
|
||||
|
||||
/**
|
||||
* 데이터를 암호화하는 기능
|
||||
*
|
||||
* @param byte[] data 암호화할 데이터
|
||||
* @return String result 암호화된 데이터
|
||||
* @exception Exception
|
||||
*/
|
||||
public static String encodeBinary(byte[] data) throws Exception {
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new String(Base64.encodeBase64(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 암호화하는 기능
|
||||
*
|
||||
* @param String data 암호화할 데이터
|
||||
* @return String result 암호화된 데이터
|
||||
* @exception Exception
|
||||
*/
|
||||
@Deprecated
|
||||
public static String encode(String data) throws Exception {
|
||||
return encodeBinary(data.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 복호화하는 기능
|
||||
*
|
||||
* @param String data 복호화할 데이터
|
||||
* @return String result 복호화된 데이터
|
||||
* @exception Exception
|
||||
*/
|
||||
public static byte[] decodeBinary(String data) throws Exception {
|
||||
return Base64.decodeBase64(data.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 복호화하는 기능
|
||||
*
|
||||
* @param String data 복호화할 데이터
|
||||
* @return String result 복호화된 데이터
|
||||
* @exception Exception
|
||||
*/
|
||||
@Deprecated
|
||||
public static String decode(String data) throws Exception {
|
||||
return new String(decodeBinary(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 암호화하는 기능(복호화가 되면 안되므로 SHA-256 인코딩 방식 적용)
|
||||
*
|
||||
* @param password 암호화될 패스워드
|
||||
* @param id salt로 사용될 사용자 ID 지정
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
public static String encryptPassword(String password, String id) throws Exception {
|
||||
|
||||
if (password == null) return "";
|
||||
if (id == null) return ""; // KISA 보안약점 조치 (2018-12-11, 신용호)
|
||||
|
||||
byte[] hashValue; // 해쉬값
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
md.reset();
|
||||
md.update(id.getBytes());
|
||||
|
||||
hashValue = md.digest(password.getBytes());
|
||||
|
||||
return new String(Base64.encodeBase64(hashValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 암호화하는 기능(복호화가 되면 안되므로 SHA-256 인코딩 방식 적용)
|
||||
* @param data 암호화할 비밀번호
|
||||
* @param salt Salt
|
||||
* @return 암호화된 비밀번호
|
||||
* @throws Exception
|
||||
*/
|
||||
public static String encryptPassword(String data, byte[] salt) throws Exception {
|
||||
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
byte[] hashValue; // 해쉬값
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
md.reset();
|
||||
md.update(salt);
|
||||
|
||||
hashValue = md.digest(data.getBytes());
|
||||
|
||||
return new String(Base64.encodeBase64(hashValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 암호화된 패스워드 검증(salt가 사용된 경우만 적용).
|
||||
*
|
||||
* @param data 원 패스워드
|
||||
* @param encoded 해쉬처리된 패스워드(Base64 인코딩)
|
||||
* @return
|
||||
* @throws Exception
|
||||
*/
|
||||
public static boolean checkPassword(String data, String encoded, byte[] salt) throws Exception {
|
||||
byte[] hashValue; // 해쉬값
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
md.reset();
|
||||
md.update(salt);
|
||||
hashValue = md.digest(data.getBytes());
|
||||
|
||||
return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes()));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,688 @@
|
||||
package egovframework.util;
|
||||
|
||||
import com.google.errorprone.annotations.CanIgnoreReturnValue;
|
||||
import egovframework.configProperties.FileUploadProperties;
|
||||
import go.kr.project.common.model.FileVO;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 파일 업로드/다운로드 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class FileUtil {
|
||||
|
||||
/** 파일 업로드 설정 */
|
||||
private final FileUploadProperties fileUploadProperties;
|
||||
|
||||
/** 날짜 유틸리티 */
|
||||
private final DateUtil dateUtil;
|
||||
|
||||
/** 파일 저장 기본 경로 */
|
||||
private String uploadPath;
|
||||
|
||||
/** 최대 파일 크기 (단일 파일) - 기본값 10MB */
|
||||
private long maxFileSize;
|
||||
|
||||
/** 최대 총 파일 크기 - 기본값 50MB */
|
||||
private long maxTotalSize;
|
||||
|
||||
/** 허용된 파일 확장자 */
|
||||
private String allowedExtensions;
|
||||
|
||||
/** 허용된 파일 확장자 목록 */
|
||||
private List<String> allowedExtensionList;
|
||||
|
||||
/** 최대 파일 개수 - 기본값 10개 */
|
||||
private int maxFiles;
|
||||
|
||||
/** 실제 파일 삭제 여부 - 기본값 true */
|
||||
private boolean realFileDelete;
|
||||
|
||||
/** 하위 디렉토리 설정 */
|
||||
private Map<String, String> subDirs;
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
* @param fileUploadProperties 파일 업로드 설정
|
||||
* @param dateUtil 날짜 유틸리티
|
||||
*/
|
||||
public FileUtil(FileUploadProperties fileUploadProperties, DateUtil dateUtil) {
|
||||
this.fileUploadProperties = fileUploadProperties;
|
||||
this.dateUtil = dateUtil;
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화 메서드
|
||||
* FileUploadProperties에서 설정 값을 가져와 초기화
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// 설정 값 초기화
|
||||
this.uploadPath = fileUploadProperties.getPath();
|
||||
this.maxFileSize = fileUploadProperties.getMaxSize();
|
||||
this.maxTotalSize = fileUploadProperties.getMaxTotalSize();
|
||||
this.allowedExtensions = fileUploadProperties.getAllowedExtensions();
|
||||
this.maxFiles = fileUploadProperties.getMaxFiles();
|
||||
this.realFileDelete = fileUploadProperties.isRealFileDelete();
|
||||
this.subDirs = fileUploadProperties.getSubDirs();
|
||||
|
||||
// 허용된 파일 확장자 목록 초기화
|
||||
allowedExtensionList = Arrays.stream(allowedExtensions.split(","))
|
||||
.map(String::trim)
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* <h3>경로 순회 문자열 제거 메소드</h3>
|
||||
* 참고: return 값의 파일 경로 구분자는 항상 "/" 가 적용된다.
|
||||
* @see #cleanPath(String, boolean)
|
||||
*/
|
||||
public static String cleanPath(String filePath) throws IllegalArgumentException {
|
||||
// Java 는 파일 경로 구분자가 "\" 든 "/" 든 똑같이 대응하도록 되어 있다.
|
||||
// 그러므로 useOsFileSeparatorOnResult=false 로 세팅한다.
|
||||
return cleanPath(filePath, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* <h3>경로 순회 문자열 제거 메소드</h3>
|
||||
* @param filePath 경로 순회 문자열을 제거하고자 하는 파일경로 문자열
|
||||
* @param useOsFileSeparatorOnResult 최종 return 문자열의 파일 경로 구분자를 OS 기반에 맞게 변경할지 여부이며 true 면 적용된다.
|
||||
* true 설정하면 window 의 경우 "C:\sdf\some.txt" 처럼 반환, false 면 "C:/sdf/some.txt" 로 반환
|
||||
* @return 경로 순회 문자열이 없는 문자열
|
||||
* @throws IllegalArgumentException 경로 순회 문자열 제거 후, 루트 경로 혹은 WEB Root 와 관련된 문자열이 발견되면 예외를 던진다.
|
||||
*/
|
||||
public static String cleanPath(String filePath, boolean useOsFileSeparatorOnResult) throws IllegalArgumentException {
|
||||
if(filePath == null || filePath.trim().isEmpty()) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
filePath = filePath.trim();
|
||||
|
||||
/*
|
||||
윈도우, 리눅스 등 어떤 운영체제를 사용하던 간에 똑같이 경로 구분자를 "/" 로 통일한다.
|
||||
이는 똑같은 로직을 태우기 위함이다.
|
||||
만약에 최종 경로 String 값의 경로구분자가 OS 기반에 맞게 하고 싶다면 [useOsFileSeparatorOnResult=true] 로 설정한다.
|
||||
*/
|
||||
String sanitizedPath = filePath.replaceAll("\\\\+", "/");
|
||||
|
||||
// "../" 경로 제거
|
||||
sanitizedPath = sanitizedPath.replaceAll("\\.\\.", "");
|
||||
|
||||
// "/./" 경로 제거
|
||||
sanitizedPath = sanitizedPath.replaceAll("/\\./", "/");
|
||||
|
||||
// "&" 제거
|
||||
sanitizedPath = sanitizedPath.replaceAll("&", "");
|
||||
|
||||
// Replace multiple consecutive slashes with a single slash
|
||||
sanitizedPath = sanitizedPath.replaceAll("/{2,}", "/");
|
||||
|
||||
// 루트 경로 사용 검사 ("/" or "C:/"). 발견되면 예외를 던진다.
|
||||
checkRootPathUsage(filePath, sanitizedPath);
|
||||
|
||||
// URL 경로 체크(= Web root 사용여부 검사). 발견되면 예외를 던진다.
|
||||
checkUrlLikePathUsage(filePath, sanitizedPath);
|
||||
|
||||
// 최종적으로 운영체제에 맞게 문자열을 반환할지 분기처리하여 return 한다.
|
||||
return useOsFileSeparatorOnResult ?
|
||||
changeFileSeparatorDependOnOs(sanitizedPath) : sanitizedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 경로 사용 여부를 체크한다.
|
||||
*/
|
||||
private static void checkRootPathUsage(String filePath, String sanitizedPath) {
|
||||
if (sanitizedPath.equals("/") || sanitizedPath.matches("[A-Za-z]:/$")) {
|
||||
throw new IllegalArgumentException("Invalid path: " + filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL Path 사용 여부를 체크한다. Web Root 접근 방어용이다.
|
||||
*/
|
||||
private static void checkUrlLikePathUsage(String filePath, String sanitizedPath) throws IllegalArgumentException {
|
||||
Path path = Paths.get(sanitizedPath);
|
||||
// Check if the path is a URL-like path (potentially a web root)
|
||||
URI uri = path.toUri();
|
||||
if (uri.getScheme() != null && uri.getHost() != null) {
|
||||
throw new IllegalArgumentException("Path resembles a URL-like path. Error Occurred Path => " + filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 경로 구분자를 운영체제에 맞게 변경한다.
|
||||
*/
|
||||
public static String changeFileSeparatorDependOnOs(String path) {
|
||||
String osName = System.getProperty("os.name").toLowerCase();
|
||||
return osName.toLowerCase().contains("win") ? path.replace("/", File.separator) : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* File 인스턴스의 경로에서 경로 순회 문자열을 제거하고 다시 File 을 생성한다.
|
||||
*/
|
||||
public static File cleanPath(File file) {
|
||||
String path = file.getPath();
|
||||
return new File(cleanPath(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 인스턴스의 경로에서 경로 순회 문자열을 제거하고 다시 File 을 생성한다.
|
||||
*/
|
||||
public static Path cleanPath(Path file) {
|
||||
String path = file.toFile().getPath();
|
||||
return Paths.get(cleanPath(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 유효성 검증
|
||||
* 경로 탐색(Path Traversal) 공격 방지
|
||||
* @param path 검증할 경로
|
||||
* @param isFileName 파일명 여부 (true: 파일명, false: 디렉토리명)
|
||||
* @throws IOException 잘못된 경로일 경우 예외 발생
|
||||
*/
|
||||
private void validatePath(String path, boolean isFileName) throws IOException {
|
||||
if (path == null) {
|
||||
throw new IOException("경로가 null입니다.");
|
||||
}
|
||||
|
||||
// 경로 정규화 시도
|
||||
try {
|
||||
Path normalizedPath = Paths.get(cleanPath(path)).normalize();
|
||||
String normalizedPathStr = normalizedPath.toString();
|
||||
|
||||
// 정규화 후에도 '..'가 남아있는지 확인
|
||||
if (normalizedPathStr.contains("..")) {
|
||||
String type = isFileName ? "파일명" : "디렉토리 경로";
|
||||
throw new IOException("잘못된 " + type + "입니다: " + path);
|
||||
}
|
||||
|
||||
// 추가 경로 검증 로직
|
||||
if (isFileName) {
|
||||
// 파일명에는 경로 구분자가 없어야 함
|
||||
if (normalizedPathStr.contains("/") || normalizedPathStr.contains("\\")) {
|
||||
throw new IOException("파일명에 경로 구분자가 포함되어 있습니다: " + path);
|
||||
}
|
||||
} else {
|
||||
// 디렉토리 경로 검증 로직 (필요에 따라 추가)
|
||||
}
|
||||
|
||||
// 허용된 문자만 포함되어 있는지 검증 (정규식 사용)
|
||||
//if (!normalizedPathStr.matches("[a-zA-Z0-9_\\-\\.가-힣]+")) {
|
||||
// String type = isFileName ? "파일명" : "디렉토리 경로";
|
||||
// throw new IOException("허용되지 않은 문자가 포함된 " + type + "입니다: " + path);
|
||||
//}
|
||||
} catch (InvalidPathException e) {
|
||||
throw new IOException("유효하지 않은 경로입니다: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 파일 확장자 유효성 검증
|
||||
* @param fileExt 검증할 파일 확장자
|
||||
* @return 허용된 확장자인지 여부
|
||||
*/
|
||||
private boolean isAllowedExtension(String fileExt) {
|
||||
return allowedExtensionList.contains(fileExt.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 하위 디렉토리 경로 조회
|
||||
* @param key 하위 디렉토리 키 (예: bbs-notice, html-editor)
|
||||
* @return 설정된 하위 디렉토리 경로
|
||||
* @throws IOException 설정된 경로가 없을 경우 예외 발생
|
||||
*/
|
||||
public String getSubDir(String key) throws IOException {
|
||||
if (!subDirs.containsKey(key)) {
|
||||
throw new IOException("설정된 하위 디렉토리가 없습니다: " + key);
|
||||
}
|
||||
return subDirs.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저별 파일명 인코딩 처리
|
||||
* @param fileName 원본 파일명
|
||||
* @param userAgent 브라우저 User-Agent
|
||||
* @return 인코딩된 파일명
|
||||
* @throws IOException 인코딩 처리 중 오류 발생 시
|
||||
*/
|
||||
private String getEncodedFilename(String fileName, String userAgent) throws IOException {
|
||||
if (userAgent.contains("MSIE") || userAgent.contains("Trident") || userAgent.contains("Chrome")) {
|
||||
// IE, Chrome
|
||||
return URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
|
||||
} else if (userAgent.contains("Firefox")) {
|
||||
// Firefox
|
||||
return "\"" + new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1) + "\"";
|
||||
} else {
|
||||
// 기타 브라우저
|
||||
return URLEncoder.encode(fileName, "UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileVO 객체 생성
|
||||
* @param originalFilename 원본 파일명
|
||||
* @param storedFilename 저장 파일명
|
||||
* @param subDir 저장 디렉토리
|
||||
* @param fileSize 파일 크기
|
||||
* @param fileExt 파일 확장자
|
||||
* @param contentType 컨텐츠 타입
|
||||
* @return 생성된 FileVO 객체
|
||||
*/
|
||||
private FileVO createFileVO(String originalFilename, String storedFilename, String subDir,
|
||||
long fileSize, String fileExt, String contentType) {
|
||||
FileVO fileVO = new FileVO();
|
||||
fileVO.setOriginalFileNm(originalFilename);
|
||||
fileVO.setStoredFileNm(storedFilename);
|
||||
fileVO.setFilePath(subDir);
|
||||
fileVO.setFileSize(fileSize);
|
||||
fileVO.setFileExt(fileExt);
|
||||
fileVO.setContentType(contentType);
|
||||
return fileVO;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 파일 업로드 처리
|
||||
* @param files 업로드할 파일 목록
|
||||
* @param subDir 저장할 하위 디렉토리 (notice, biz, qna 등)
|
||||
* @return 업로드된 파일 정보 목록
|
||||
* @throws IOException 파일 처리 중 오류 발생 시
|
||||
*/
|
||||
public List<FileVO> uploadFiles(List<MultipartFile> files, String subDir) throws IOException {
|
||||
// 파일 유효성 검증
|
||||
validateFiles(files);
|
||||
|
||||
// 디렉토리 경로 검증
|
||||
validatePath(subDir, false);
|
||||
|
||||
// 년월 정보 추출
|
||||
String yearMonth = dateUtil.getCurrentYearMonth();
|
||||
|
||||
// 년월 디렉토리를 포함한 경로 생성
|
||||
String yearMonthPath = subDir + File.separator + yearMonth;
|
||||
|
||||
// 디렉토리 경로 검증
|
||||
validatePath(yearMonthPath, false);
|
||||
|
||||
// 디렉토리 생성
|
||||
String uploadDir = uploadPath + File.separator + yearMonthPath;
|
||||
createDirectoryIfNotExists(uploadDir);
|
||||
|
||||
// 파일 업로드 처리
|
||||
return processFileUploads(files, yearMonthPath, uploadDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 목록 유효성 검증
|
||||
* @param files 검증할 파일 목록
|
||||
* @throws IOException 유효성 검증 실패 시
|
||||
*/
|
||||
private void validateFiles(List<MultipartFile> files) throws IOException {
|
||||
// 파일 개수 검증
|
||||
int validFileCount = (int) files.stream()
|
||||
.filter(file -> !file.isEmpty())
|
||||
.count();
|
||||
|
||||
if (validFileCount > maxFiles) {
|
||||
throw new IOException("파일 개수가 제한을 초과했습니다. 최대 " + maxFiles + "개까지 가능합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 처리
|
||||
* @param files 업로드할 파일 목록
|
||||
* @param subDir 저장할 하위 디렉토리
|
||||
* @param uploadDir 업로드 디렉토리 전체 경로
|
||||
* @return 업로드된 파일 정보 목록
|
||||
* @throws IOException 파일 처리 중 오류 발생 시
|
||||
*/
|
||||
private List<FileVO> processFileUploads(List<MultipartFile> files, String subDir, String uploadDir) throws IOException {
|
||||
List<FileVO> uploadedFiles = new ArrayList<>();
|
||||
long totalSize = 0;
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) continue;
|
||||
|
||||
// 파일 크기 검증
|
||||
validateFileSize(file, totalSize);
|
||||
totalSize += file.getSize();
|
||||
|
||||
// 파일 저장 및 정보 생성
|
||||
FileVO fileVO = saveFile(file, subDir, uploadDir);
|
||||
uploadedFiles.add(fileVO);
|
||||
}
|
||||
|
||||
return uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기 검증
|
||||
* @param file 검증할 파일
|
||||
* @param currentTotalSize 현재까지의 총 파일 크기
|
||||
* @throws IOException 파일 크기 제한 초과 시
|
||||
*/
|
||||
private void validateFileSize(MultipartFile file, long currentTotalSize) throws IOException {
|
||||
// 단일 파일 크기 검증
|
||||
if (file.getSize() > maxFileSize * 1024 * 1024) {
|
||||
throw new IOException("파일 크기가 제한을 초과했습니다. 최대 " + maxFileSize + "MB까지 가능합니다.");
|
||||
}
|
||||
|
||||
// 총 파일 크기 검증
|
||||
if (currentTotalSize + file.getSize() > maxTotalSize * 1024 * 1024) {
|
||||
throw new IOException("총 파일 크기가 제한을 초과했습니다. 최대 " + maxTotalSize + "MB까지 가능합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 저장 및 정보 생성
|
||||
* @param file 저장할 파일
|
||||
* @param subDir 저장할 하위 디렉토리
|
||||
* @param uploadDir 업로드 디렉토리 전체 경로
|
||||
* @return 생성된 파일 정보
|
||||
* @throws IOException 파일 저장 중 오류 발생 시
|
||||
*/
|
||||
private FileVO saveFile(MultipartFile file, String subDir, String uploadDir) throws IOException {
|
||||
// 원본 파일명 및 확장자 추출
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String fileExt = getFileExtension(originalFilename);
|
||||
|
||||
// 확장자 검증
|
||||
if (!isAllowedExtension(fileExt)) {
|
||||
throw new IOException("허용되지 않은 파일 형식입니다: " + fileExt);
|
||||
}
|
||||
|
||||
// UUID를 이용한 저장 파일명 생성
|
||||
String storedFilename = UUID.randomUUID() + "." + fileExt;
|
||||
|
||||
// 파일명 검증
|
||||
validatePath(storedFilename, true);
|
||||
|
||||
// 파일 저장 경로 생성 및 검증
|
||||
Path filePath = createAndValidateFilePath(uploadDir, storedFilename);
|
||||
|
||||
// 파일 저장
|
||||
Files.write(filePath, file.getBytes());
|
||||
|
||||
// 파일 정보 생성 및 반환
|
||||
return createFileVO(originalFilename, storedFilename, subDir,
|
||||
file.getSize(), fileExt, file.getContentType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 저장 경로 생성 및 검증
|
||||
* @param uploadDir 업로드 디렉토리 전체 경로
|
||||
* @param storedFilename 저장 파일명
|
||||
* @return 검증된 파일 경로
|
||||
* @throws IOException 경로 검증 실패 시
|
||||
*/
|
||||
private Path createAndValidateFilePath(String uploadDir, String storedFilename) throws IOException {
|
||||
Path filePath = Paths.get(uploadDir).normalize().resolve(storedFilename).normalize();
|
||||
|
||||
// 생성된 경로가 업로드 디렉토리 내에 있는지 확인
|
||||
File targetFile = filePath.toFile();
|
||||
String canonicalUploadDir = new File(uploadDir).getCanonicalPath();
|
||||
String canonicalTargetPath = targetFile.getCanonicalPath();
|
||||
|
||||
if (!canonicalTargetPath.startsWith(canonicalUploadDir)) {
|
||||
throw new IOException("잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 처리
|
||||
* @param fileVO 다운로드할 파일 정보
|
||||
* @param request HTTP 요청
|
||||
* @param response HTTP 응답
|
||||
* @throws IOException 파일 처리 중 오류 발생 시
|
||||
*/
|
||||
public void downloadFile(FileVO fileVO, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
// 파일 정보 검증 및 파일 객체 생성
|
||||
File file = validateAndGetFile(fileVO);
|
||||
|
||||
// 파일 확장자 검증
|
||||
validateFileExtension(fileVO.getOriginalFileNm());
|
||||
|
||||
// 브라우저별 인코딩된 파일명 생성
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
String encodedFilename = getEncodedFilename(fileVO.getOriginalFileNm(), userAgent);
|
||||
|
||||
// 응답 헤더 설정
|
||||
setResponseHeaders(response, fileVO, file, encodedFilename);
|
||||
|
||||
// 파일 전송
|
||||
sendFile(file, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 정보 검증 및 파일 객체 생성
|
||||
* @param fileVO 파일 정보
|
||||
* @return 검증된 파일 객체
|
||||
* @throws IOException 파일 검증 실패 시
|
||||
*/
|
||||
private File validateAndGetFile(FileVO fileVO) throws IOException {
|
||||
// 파일 경로 생성
|
||||
String filePath = uploadPath + File.separator + fileVO.getFilePath() + File.separator + fileVO.getStoredFileNm();
|
||||
File file = new File(filePath);
|
||||
|
||||
// 파일 존재 여부 확인
|
||||
if (!file.exists()) {
|
||||
throw new IOException("파일을 찾을 수 없습니다: " + filePath);
|
||||
}
|
||||
|
||||
// 보안 검사: 경로 검증 (경로 탐색 공격 방지)
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
if (!canonicalPath.startsWith(new File(uploadPath).getCanonicalPath())) {
|
||||
throw new IOException("잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 확장자 검증
|
||||
* @param filename 파일명
|
||||
* @throws IOException 허용되지 않은 확장자일 경우
|
||||
*/
|
||||
private void validateFileExtension(String filename) throws IOException {
|
||||
String fileExt = getFileExtension(filename);
|
||||
if (!isAllowedExtension(fileExt)) {
|
||||
throw new IOException("허용되지 않은 파일 형식입니다: " + fileExt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 헤더 설정
|
||||
* @param response HTTP 응답
|
||||
* @param fileVO 파일 정보
|
||||
* @param file 파일 객체
|
||||
* @param encodedFilename 인코딩된 파일명
|
||||
*/
|
||||
private void setResponseHeaders(HttpServletResponse response, FileVO fileVO, File file, String encodedFilename) {
|
||||
// 컨텐츠 타입 및 길이 설정
|
||||
response.setContentType(fileVO.getContentType());
|
||||
response.setContentLength((int) file.length());
|
||||
|
||||
// 다운로드 관련 헤더 설정
|
||||
response.setHeader("Content-Disposition", "attachment; filename=" + encodedFilename);
|
||||
response.setHeader("Content-Transfer-Encoding", "binary");
|
||||
|
||||
// 캐시 관련 헤더 설정
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Expires", "0");
|
||||
|
||||
// 보안 관련 헤더 설정
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 전송
|
||||
* @param file 전송할 파일
|
||||
* @param response HTTP 응답
|
||||
* @throws IOException 파일 전송 중 오류 발생 시
|
||||
*/
|
||||
private void sendFile(File file, HttpServletResponse response) throws IOException {
|
||||
try (FileInputStream fis = new FileInputStream(file);
|
||||
OutputStream os = response.getOutputStream()) {
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
os.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제 처리
|
||||
* @param fileVO 삭제할 파일 정보
|
||||
* @return 삭제 성공 여부
|
||||
*/
|
||||
public boolean deleteFile(FileVO fileVO) {
|
||||
// real-file-delete 설정이 false인 경우 실제 파일 삭제하지 않고 true 반환
|
||||
if (!realFileDelete) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 파일 경로 검증 및 파일 객체 생성
|
||||
File file = getValidatedFileForDelete(fileVO);
|
||||
|
||||
// 파일 존재 여부 확인 및 삭제
|
||||
return file.exists() && file.delete();
|
||||
} catch (IOException e) {
|
||||
// 로그 기록 등의 처리를 추가할 수 있음
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제를 위한 파일 객체 생성 및 검증
|
||||
* @param fileVO 파일 정보
|
||||
* @return 검증된 파일 객체
|
||||
* @throws IOException 파일 경로 검증 실패 시
|
||||
*/
|
||||
private File getValidatedFileForDelete(FileVO fileVO) throws IOException {
|
||||
// 파일 경로 및 파일명 검증
|
||||
String subDir = fileVO.getFilePath();
|
||||
String storedFilename = fileVO.getStoredFileNm();
|
||||
|
||||
// 경로 검증
|
||||
validatePath(subDir, false);
|
||||
validatePath(storedFilename, true);
|
||||
|
||||
// 파일 경로 생성
|
||||
String filePath = uploadPath + File.separator + subDir + File.separator + storedFilename;
|
||||
File file = new File(filePath);
|
||||
|
||||
// 보안 검사: 경로 검증 (경로 탐색 공격 방지)
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
if (!canonicalPath.startsWith(new File(uploadPath).getCanonicalPath())) {
|
||||
throw new IOException("잘못된 파일 경로입니다.");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리 생성
|
||||
* @param directory 생성할 디렉토리 경로
|
||||
*/
|
||||
@CanIgnoreReturnValue
|
||||
private void createDirectoryIfNotExists(String directory) {
|
||||
File dir = new File(directory);
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 확장자 추출
|
||||
* @param filename 파일명
|
||||
* @return 파일 확장자 (소문자로 변환됨)
|
||||
*/
|
||||
private String getFileExtension(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
int lastDotIndex = filename.lastIndexOf(".");
|
||||
if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 새로운 경로로 복사합니다.
|
||||
* 원본 파일과 동일한 디렉토리 구조를 유지하며 복사합니다.
|
||||
*
|
||||
* @param sourcePath 원본 파일 경로
|
||||
* @param targetPath 대상 파일 경로
|
||||
* @return 복사 성공 여부
|
||||
* @throws IOException 파일 복사 실패 시 예외 발생
|
||||
*/
|
||||
public boolean copyFile(String sourcePath, String uploadDir, String targetPath) throws IOException {
|
||||
if (sourcePath == null || targetPath == null || uploadDir == null) {
|
||||
throw new IOException("원본 또는 대상 파일 경로가 null입니다.");
|
||||
}
|
||||
|
||||
File sourceFile = new File(uploadPath + File.separator + sourcePath);
|
||||
File targetFile = new File(uploadPath + File.separator + targetPath);
|
||||
|
||||
// 원본 파일 존재 여부 확인
|
||||
if (!sourceFile.exists()) {
|
||||
throw new IOException("원본 파일이 존재하지 않습니다: " + uploadPath + File.separator + sourcePath);
|
||||
}
|
||||
|
||||
// 원본 파일이 일반 파일인지 확인
|
||||
if (!sourceFile.isFile()) {
|
||||
throw new IOException("원본이 일반 파일이 아닙니다: " + uploadPath + File.separator + sourcePath);
|
||||
}
|
||||
|
||||
createDirectoryIfNotExists( uploadPath + File.separator + uploadDir);
|
||||
|
||||
// 대상 디렉토리 생성
|
||||
File targetDir = targetFile.getParentFile();
|
||||
if (targetDir != null && !targetDir.exists()) {
|
||||
if (!targetDir.mkdirs()) {
|
||||
throw new IOException("대상 디렉토리 생성 실패: " + targetDir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 복사
|
||||
try {
|
||||
Files.copy(sourceFile.toPath(), targetFile.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
throw new IOException("파일 복사 실패: " + sourcePath + " -> " + targetPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package egovframework.util;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.util
|
||||
* fileName : HttpServletUtil
|
||||
* author : 박성영
|
||||
* date : 25. 5. 8.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 8. 박성영 최초 생성
|
||||
*/
|
||||
public class HttpServletUtil {
|
||||
|
||||
/**
|
||||
* AJAX 요청인지 확인하는 메소드
|
||||
* *.ajax 패턴의 URL인 경우 AJAX 요청으로 간주합니다.
|
||||
*/
|
||||
public static boolean isAjaxRequest(HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
return requestURI != null && requestURI.endsWith(".ajax");
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 AJAX 요청인지 확인하는 메소드
|
||||
* HTTP 헤더 정보를 기반으로 AJAX 요청인지 판단합니다.
|
||||
*
|
||||
* 다음 조건 중 하나라도 만족하면 AJAX 요청으로 간주합니다:
|
||||
* 1. 'X-Requested-With: XMLHttpRequest' 헤더가 있는 경우
|
||||
* 2. 'Accept' 헤더가 'application/json'을 포함하는 경우
|
||||
* 3. 'Content-Type' 헤더가 'application/json'인 경우
|
||||
*/
|
||||
public static boolean isRealAjaxRequest(HttpServletRequest request) {
|
||||
// 'X-Requested-With: XMLHttpRequest' 헤더 확인 (가장 일반적인 AJAX 요청 지표)
|
||||
String requestedWith = request.getHeader("X-Requested-With");
|
||||
if ("XMLHttpRequest".equals(requestedWith)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 'Accept' 헤더가 'application/json'을 포함하는지 확인
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept != null && accept.contains("application/json")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 'Content-Type' 헤더가 'application/json'인지 확인
|
||||
String contentType = request.getHeader("Content-Type");
|
||||
if (contentType != null && contentType.contains("application/json")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
package egovframework.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.batch.util
|
||||
* fileName : ImageValidationUtil
|
||||
* author : 개발자
|
||||
* date : 2025-01-27
|
||||
* description : 이미지 파일 검증 유틸리티
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-01-27 개발자 최초 생성
|
||||
*/
|
||||
@Slf4j
|
||||
public class ImageValidationUtil {
|
||||
|
||||
/**
|
||||
* 지원되는 이미지 파일 확장자 목록
|
||||
*/
|
||||
private static final List<String> SUPPORTED_IMAGE_EXTENSIONS = Arrays.asList(
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp"
|
||||
);
|
||||
|
||||
/**
|
||||
* 이미지 파일 MIME 타입 목록
|
||||
*/
|
||||
private static final List<String> IMAGE_MIME_TYPES = Arrays.asList(
|
||||
"image/jpeg", "image/jpg", "image/png", "image/gif",
|
||||
"image/bmp", "image/tiff", "image/webp"
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일이 이미지 파일인지 확장자로 확인합니다.
|
||||
*
|
||||
* @param fileName 파일명
|
||||
* @return 이미지 파일 여부
|
||||
*/
|
||||
public static boolean isImageByExtension(String fileName) {
|
||||
if (fileName == null || fileName.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
return SUPPORTED_IMAGE_EXTENSIONS.contains(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 이미지 파일인지 MIME 타입으로 확인합니다.
|
||||
*
|
||||
* @param filePath 파일 경로
|
||||
* @return 이미지 파일 여부
|
||||
*/
|
||||
public static boolean isImageByMimeType(Path filePath) {
|
||||
try {
|
||||
String mimeType = Files.probeContentType(filePath);
|
||||
if (mimeType == null) {
|
||||
return false;
|
||||
}
|
||||
return IMAGE_MIME_TYPES.contains(mimeType.toLowerCase());
|
||||
} catch (IOException e) {
|
||||
log.warn("MIME 타입 확인 중 오류 발생: {}", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일이 손상되었는지 확인합니다.
|
||||
*
|
||||
* @param filePath 이미지 파일 경로
|
||||
* @return 손상 여부 (true: 손상됨, false: 정상)
|
||||
*/
|
||||
public static boolean isImageCorrupted(Path filePath) {
|
||||
try {
|
||||
File imageFile = filePath.toFile();
|
||||
|
||||
// 파일이 존재하지 않거나 크기가 0인 경우
|
||||
if (!imageFile.exists() || imageFile.length() == 0) {
|
||||
log.warn("이미지 파일이 존재하지 않거나 크기가 0입니다: {}", filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ImageIO를 사용하여 이미지 읽기 시도
|
||||
BufferedImage image = ImageIO.read(imageFile);
|
||||
|
||||
// 이미지를 읽을 수 없는 경우 손상된 것으로 판단
|
||||
if (image == null) {
|
||||
log.warn("이미지 파일을 읽을 수 없습니다: {}", filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 이미지 크기가 유효하지 않은 경우
|
||||
if (image.getWidth() <= 0 || image.getHeight() <= 0) {
|
||||
log.warn("이미지 크기가 유효하지 않습니다: {} ({}x{})",
|
||||
filePath, image.getWidth(), image.getHeight());
|
||||
return true;
|
||||
}
|
||||
|
||||
log.debug("이미지 파일 검증 성공: {} ({}x{})",
|
||||
filePath, image.getWidth(), image.getHeight());
|
||||
return false;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("이미지 파일 검증 중 오류 발생: {}", filePath, e);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("이미지 파일 검증 중 예상치 못한 오류 발생: {}", filePath, e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 이미지 파일인지 종합적으로 확인합니다.
|
||||
* (확장자 + MIME 타입 검사)
|
||||
*
|
||||
* @param filePath 파일 경로
|
||||
* @return 이미지 파일 여부
|
||||
*/
|
||||
public static boolean isImageFile(Path filePath) {
|
||||
String fileName = filePath.getFileName().toString();
|
||||
|
||||
// 1차: 확장자 검사
|
||||
boolean isImageByExt = isImageByExtension(fileName);
|
||||
|
||||
// 2차: MIME 타입 검사 (확장자가 이미지인 경우에만)
|
||||
boolean isImageByMime = isImageByExt && isImageByMimeType(filePath);
|
||||
|
||||
log.debug("이미지 파일 검사 결과: {} - 확장자: {}, MIME: {}",
|
||||
fileName, isImageByExt, isImageByMime);
|
||||
|
||||
return isImageByMime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일의 상세 정보를 반환합니다.
|
||||
*
|
||||
* @param filePath 이미지 파일 경로
|
||||
* @return 이미지 정보 문자열
|
||||
*/
|
||||
public static String getImageInfo(Path filePath) {
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(filePath.toFile());
|
||||
if (image == null) {
|
||||
return "이미지 정보를 읽을 수 없음";
|
||||
}
|
||||
|
||||
return String.format("크기: %dx%d, 타입: %d",
|
||||
image.getWidth(), image.getHeight(), image.getType());
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("이미지 정보 조회 중 오류 발생: {}", filePath, e);
|
||||
return "이미지 정보 조회 실패: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,352 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 숫자 관련 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class NumberUtil {
|
||||
|
||||
/**
|
||||
* 문자열을 정수(int)로 변환
|
||||
* @param str 변환할 문자열
|
||||
* @param defaultValue 변환 실패 시 반환할 기본값
|
||||
* @return 변환된 정수 또는 기본값
|
||||
*/
|
||||
public static int toInt(String str, int defaultValue) {
|
||||
if (StringUtil.isEmpty(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(str.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 정수(long)로 변환
|
||||
* @param str 변환할 문자열
|
||||
* @param defaultValue 변환 실패 시 반환할 기본값
|
||||
* @return 변환된 정수 또는 기본값
|
||||
*/
|
||||
public static long toLong(String str, long defaultValue) {
|
||||
if (StringUtil.isEmpty(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(str.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 실수(double)로 변환
|
||||
* @param str 변환할 문자열
|
||||
* @param defaultValue 변환 실패 시 반환할 기본값
|
||||
* @return 변환된 실수 또는 기본값
|
||||
*/
|
||||
public static double toDouble(String str, double defaultValue) {
|
||||
if (StringUtil.isEmpty(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(str.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 BigDecimal로 변환
|
||||
* @param str 변환할 문자열
|
||||
* @param defaultValue 변환 실패 시 반환할 기본값
|
||||
* @return 변환된 BigDecimal 또는 기본값
|
||||
*/
|
||||
public static BigDecimal toBigDecimal(String str, BigDecimal defaultValue) {
|
||||
if (StringUtil.isEmpty(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(str.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 천 단위 구분 기호가 포함된 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 천 단위 구분 기호가 포함된 문자열
|
||||
*/
|
||||
public static String formatWithComma(long number) {
|
||||
return NumberFormat.getNumberInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 천 단위 구분 기호가 포함된 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 천 단위 구분 기호가 포함된 문자열
|
||||
*/
|
||||
public static String formatWithComma(double number) {
|
||||
return NumberFormat.getNumberInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 천 단위 구분 기호가 포함된 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 천 단위 구분 기호가 포함된 문자열
|
||||
*/
|
||||
public static String formatWithComma(BigDecimal number) {
|
||||
if (number == null) {
|
||||
return "";
|
||||
}
|
||||
return NumberFormat.getNumberInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 원화 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 원화 형식의 문자열 (예: "₩1,000")
|
||||
*/
|
||||
public static String formatKRW(long number) {
|
||||
return NumberFormat.getCurrencyInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 원화 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 원화 형식의 문자열 (예: "₩1,000.50")
|
||||
*/
|
||||
public static String formatKRW(double number) {
|
||||
return NumberFormat.getCurrencyInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 원화 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 원화 형식의 문자열 (예: "₩1,000.50")
|
||||
*/
|
||||
public static String formatKRW(BigDecimal number) {
|
||||
if (number == null) {
|
||||
return "";
|
||||
}
|
||||
return NumberFormat.getCurrencyInstance(Locale.KOREA).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 달러 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 달러 형식의 문자열 (예: "$1,000")
|
||||
*/
|
||||
public static String formatUSD(long number) {
|
||||
return NumberFormat.getCurrencyInstance(Locale.US).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 달러 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 달러 형식의 문자열 (예: "$1,000.50")
|
||||
*/
|
||||
public static String formatUSD(double number) {
|
||||
return NumberFormat.getCurrencyInstance(Locale.US).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 달러 형식의 문자열로 변환
|
||||
* @param number 변환할 숫자
|
||||
* @return 달러 형식의 문자열 (예: "$1,000.50")
|
||||
*/
|
||||
public static String formatUSD(BigDecimal number) {
|
||||
if (number == null) {
|
||||
return "";
|
||||
}
|
||||
return NumberFormat.getCurrencyInstance(Locale.US).format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 지정된 소수점 자리수로 반올림
|
||||
* @param number 반올림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 반올림된 숫자
|
||||
*/
|
||||
public static double round(double number, int scale) {
|
||||
return BigDecimal.valueOf(number)
|
||||
.setScale(scale, RoundingMode.HALF_UP)
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal을 지정된 소수점 자리수로 반올림
|
||||
* @param number 반올림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 반올림된 숫자
|
||||
*/
|
||||
public static BigDecimal round(BigDecimal number, int scale) {
|
||||
if (number == null) {
|
||||
return null;
|
||||
}
|
||||
return number.setScale(scale, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 지정된 소수점 자리수로 내림
|
||||
* @param number 내림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 내림된 숫자
|
||||
*/
|
||||
public static double floor(double number, int scale) {
|
||||
return BigDecimal.valueOf(number)
|
||||
.setScale(scale, RoundingMode.FLOOR)
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal을 지정된 소수점 자리수로 내림
|
||||
* @param number 내림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 내림된 숫자
|
||||
*/
|
||||
public static BigDecimal floor(BigDecimal number, int scale) {
|
||||
if (number == null) {
|
||||
return null;
|
||||
}
|
||||
return number.setScale(scale, RoundingMode.FLOOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 지정된 소수점 자리수로 올림
|
||||
* @param number 올림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 올림된 숫자
|
||||
*/
|
||||
public static double ceil(double number, int scale) {
|
||||
return BigDecimal.valueOf(number)
|
||||
.setScale(scale, RoundingMode.CEILING)
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal을 지정된 소수점 자리수로 올림
|
||||
* @param number 올림할 숫자
|
||||
* @param scale 소수점 자리수
|
||||
* @return 올림된 숫자
|
||||
*/
|
||||
public static BigDecimal ceil(BigDecimal number, int scale) {
|
||||
if (number == null) {
|
||||
return null;
|
||||
}
|
||||
return number.setScale(scale, RoundingMode.CEILING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 지정된 패턴으로 포맷팅
|
||||
* @param number 포맷팅할 숫자
|
||||
* @param pattern 패턴 (예: "#,###.##", "0.00")
|
||||
* @return 포맷팅된 문자열
|
||||
*/
|
||||
public static String format(double number, String pattern) {
|
||||
DecimalFormat df = new DecimalFormat(pattern);
|
||||
return df.format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 지정된 패턴으로 포맷팅
|
||||
* @param number 포맷팅할 숫자
|
||||
* @param pattern 패턴 (예: "#,###.##", "0.00")
|
||||
* @return 포맷팅된 문자열
|
||||
*/
|
||||
public static String format(BigDecimal number, String pattern) {
|
||||
if (number == null) {
|
||||
return "";
|
||||
}
|
||||
DecimalFormat df = new DecimalFormat(pattern);
|
||||
return df.format(number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 숫자 중 최소값 반환
|
||||
* @param a 첫 번째 숫자
|
||||
* @param b 두 번째 숫자
|
||||
* @return 최소값
|
||||
*/
|
||||
public static int min(int a, int b) {
|
||||
return Math.min(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 숫자 중 최대값 반환
|
||||
* @param a 첫 번째 숫자
|
||||
* @param b 두 번째 숫자
|
||||
* @return 최대값
|
||||
*/
|
||||
public static int max(int a, int b) {
|
||||
return Math.max(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 지정된 범위 내에 있는지 확인
|
||||
* @param number 확인할 숫자
|
||||
* @param min 최소값
|
||||
* @param max 최대값
|
||||
* @return 범위 내에 있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isBetween(int number, int min, int max) {
|
||||
return number >= min && number <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 지정된 범위 내에 있는지 확인
|
||||
* @param number 확인할 숫자
|
||||
* @param min 최소값
|
||||
* @param max 최대값
|
||||
* @return 범위 내에 있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isBetween(double number, double min, double max) {
|
||||
return number >= min && number <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 양수인지 확인
|
||||
* @param number 확인할 숫자
|
||||
* @return 양수이면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isPositive(int number) {
|
||||
return number > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 음수인지 확인
|
||||
* @param number 확인할 숫자
|
||||
* @return 음수이면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNegative(int number) {
|
||||
return number < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 0인지 확인
|
||||
* @param number 확인할 숫자
|
||||
* @return 0이면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isZero(int number) {
|
||||
return number == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자가 0인지 확인 (부동소수점 오차 고려)
|
||||
* @param number 확인할 숫자
|
||||
* @return 0이면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isZero(double number) {
|
||||
return Math.abs(number) < 0.000001;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package egovframework.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
/**
|
||||
* Utility class for URL pattern matching
|
||||
*/
|
||||
@Slf4j
|
||||
public class PathMatcherUtil {
|
||||
|
||||
private static final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
/**
|
||||
* Check if URL pattern matches the request URI
|
||||
*
|
||||
* @param pattern URL pattern
|
||||
* @param uri Request URI
|
||||
* @return true if matches, false otherwise
|
||||
*/
|
||||
/**
|
||||
* URL 패턴이 URI와 일치하는지 확인
|
||||
* 콤마로 구분된 여러 패턴이 전달된 경우 각 패턴을 개별적으로 처리
|
||||
*
|
||||
* @param pattern URL 패턴 (콤마로 구분된 여러 패턴일 수 있음)
|
||||
* @param uri 요청 URI
|
||||
* @return 일치 여부
|
||||
*/
|
||||
public static boolean match(String pattern, String uri) {
|
||||
if (pattern == null || uri == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 패턴 전처리 - 앞뒤 공백 제거
|
||||
String trimmedPattern = pattern.trim();
|
||||
|
||||
// 패턴에 콤마가 포함된 경우 (여러 패턴이 하나의 문자열로 전달된 경우)
|
||||
if (trimmedPattern.contains(",")) {
|
||||
//log.debug("콤마로 구분된 여러 패턴이 발견되었습니다: [{}]", trimmedPattern);
|
||||
|
||||
// 콤마로 패턴을 분리하여 각각 처리
|
||||
String[] patterns = trimmedPattern.split(",");
|
||||
for (String singlePattern : patterns) {
|
||||
String trimmedSinglePattern = singlePattern.trim();
|
||||
if (!trimmedSinglePattern.isEmpty()) {
|
||||
// 개별 패턴으로 재귀 호출
|
||||
if (match(trimmedSinglePattern, uri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 단일 패턴 처리
|
||||
try {
|
||||
// AntPathMatcher는 패턴에 따라 다르게 동작함
|
||||
// 1. 정확한 경로 매칭 (/system/user/list.do)
|
||||
// 2. 경로 변수 매칭 (/system/user/{id}.do)
|
||||
// 3. 와일드카드 매칭 (/system/user/*.do, /system/user/**/list.do)
|
||||
boolean isMatch = pathMatcher.match(trimmedPattern, uri);
|
||||
//log.debug("URL 패턴 매칭 시도: 패턴=[{}], URI=[{}], 결과=[{}]", trimmedPattern, uri, isMatch);
|
||||
|
||||
// 매칭되지 않았을 경우 추가 디버깅 정보
|
||||
if (!isMatch && (trimmedPattern.contains("*") || trimmedPattern.contains("?"))) {
|
||||
//log.debug("와일드카드 패턴 매칭 실패: 패턴=[{}], URI=[{}]", trimmedPattern, uri);
|
||||
}
|
||||
|
||||
return isMatch;
|
||||
} catch (Exception e) {
|
||||
log.error("URL 패턴 매칭 중 오류 발생: 패턴=[{}], URI=[{}], 오류=[{}]", trimmedPattern, uri, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.util
|
||||
* fileName : PathUtil
|
||||
* author : 개발자
|
||||
* date : 2023-06-10
|
||||
* description : 경로 관련 유틸리티 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2023-06-10 개발자 최초 생성
|
||||
*/
|
||||
public class PathUtil {
|
||||
|
||||
/**
|
||||
* 현재 애플리케이션의 contextPath를 반환합니다.
|
||||
*
|
||||
* @return contextPath 문자열 (예: "/app")
|
||||
*/
|
||||
public static String getContextPath() {
|
||||
try {
|
||||
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||
return request.getContextPath();
|
||||
} catch (Exception e) {
|
||||
// RequestContextHolder가 사용 불가능한 경우 (예: 배치 작업 등)
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 URL에 contextPath를 추가합니다.
|
||||
* 이미 contextPath가 포함된 경우 또는 외부 URL인 경우 원래 URL을 반환합니다.
|
||||
*
|
||||
* @param url contextPath를 추가할 URL
|
||||
* @return contextPath가 추가된 URL
|
||||
*/
|
||||
public static String addContextPath(String url) {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 외부 URL인 경우 (http:// 또는 https://로 시작하는 경우)
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
String contextPath = getContextPath();
|
||||
|
||||
// URL이 이미 contextPath로 시작하는 경우
|
||||
if (!contextPath.isEmpty() && url.startsWith(contextPath)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// URL이 /로 시작하지 않는 경우 /를 추가
|
||||
if (!url.startsWith("/")) {
|
||||
url = "/" + url;
|
||||
}
|
||||
|
||||
return contextPath + url;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,235 @@
|
||||
package egovframework.util;
|
||||
|
||||
import egovframework.constant.SessionConstants;
|
||||
import go.kr.project.login.model.LoginUserVO;
|
||||
import go.kr.project.login.model.SessionVO;
|
||||
import go.kr.project.system.group.model.GroupVO;
|
||||
import go.kr.project.system.menu.model.MenuVO;
|
||||
import go.kr.project.system.role.model.RoleVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.util
|
||||
* fileName : SessionUtil
|
||||
* author : 시스템 관리자
|
||||
* date : 2025-05-15
|
||||
* description : 세션 정보를 쉽게 가져올 수 있는 유틸리티 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-15 시스템 관리자 최초 생성
|
||||
*/
|
||||
@Slf4j
|
||||
public class SessionUtil {
|
||||
|
||||
// 세션 키는 SessionConstants 클래스에서 관리
|
||||
|
||||
/**
|
||||
* 현재 요청의 HttpServletRequest 객체를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return HttpServletRequest 객체, 요청 컨텍스트가 없는 경우 null
|
||||
*/
|
||||
public static HttpServletRequest getRequest() {
|
||||
try {
|
||||
ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
|
||||
return attr.getRequest();
|
||||
} catch (IllegalStateException e) {
|
||||
// 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우
|
||||
log.debug("HTTP 요청 컨텍스트가 없습니다. (배치 작업 또는 비웹 컨텍스트)");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 요청의 HttpSession 객체를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return HttpSession 객체, 요청 컨텍스트가 없는 경우 null
|
||||
*/
|
||||
public static HttpSession getSession() {
|
||||
HttpServletRequest request = getRequest();
|
||||
return request != null ? request.getSession() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세션에서 SessionVO 객체를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return SessionVO 객체, 세션이 없는 경우 null
|
||||
*/
|
||||
public static SessionVO getSessionVO() {
|
||||
HttpSession session = getSession();
|
||||
if (session != null) {
|
||||
return (SessionVO) session.getAttribute(SessionConstants.SESSION_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자 정보를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자 정보, 로그인하지 않은 경우 null
|
||||
*/
|
||||
public static LoginUserVO getLoginUser() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
if (sessionVO != null && sessionVO.isLogin()) {
|
||||
return sessionVO.getUser();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 ID를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 ID, 로그인하지 않은 경우 null
|
||||
*/
|
||||
public static String getUserId() {
|
||||
LoginUserVO user = getLoginUser();
|
||||
if (user != null) {
|
||||
return user.getUserId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 이름을 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 이름, 로그인하지 않은 경우 null
|
||||
*/
|
||||
public static String getUserName() {
|
||||
LoginUserVO user = getLoginUser();
|
||||
if (user != null) {
|
||||
return user.getUserNm();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 계정을 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 계정, 로그인하지 않은 경우 null
|
||||
*/
|
||||
public static String getUserAccount() {
|
||||
LoginUserVO user = getLoginUser();
|
||||
if (user != null) {
|
||||
return user.getUserAcnt();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 그룹 정보를 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 그룹 정보, 그룹 정보가 없는 경우 null
|
||||
*/
|
||||
public static GroupVO getUserGroup() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
if (sessionVO != null && sessionVO.isLogin()) {
|
||||
return sessionVO.getGroup();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 역할 정보 목록을 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 역할 정보 목록, 역할 정보가 없는 경우 null
|
||||
*/
|
||||
public static List<RoleVO> getUserRoles() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
if (sessionVO != null) {
|
||||
return sessionVO.getRoles();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 메뉴 정보 목록을 가져옵니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @return 로그인한 사용자의 메뉴 정보 목록, 메뉴 정보가 없는 경우 null
|
||||
*/
|
||||
public static List<MenuVO> getUserMenus() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
if (sessionVO != null) {
|
||||
return sessionVO.getMenus();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인 여부를 확인합니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 false를 반환합니다.
|
||||
*
|
||||
* @return 로그인 여부
|
||||
*/
|
||||
public static boolean isLogin() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
return sessionVO != null && sessionVO.isLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 방문자 여부를 확인합니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 false를 반환합니다.
|
||||
*
|
||||
* @return 방문자 여부
|
||||
*/
|
||||
public static boolean isVisitor() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
return sessionVO != null && sessionVO.isVisitor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시스템 관리자 여부를 확인합니다.
|
||||
* 배치 작업 등에서 HTTP 요청 컨텍스트가 없는 경우 false를 반환합니다.
|
||||
*
|
||||
* @return 시스템 관리자 여부
|
||||
*/
|
||||
public static boolean isSystem() {
|
||||
SessionVO sessionVO = getSessionVO();
|
||||
return sessionVO != null && sessionVO.isSystem();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 시스템 관리자 여부를 확인합니다.
|
||||
* 배치 작업은 시스템 권한으로 실행되는 것으로 간주합니다.
|
||||
*
|
||||
* @return 배치 작업의 시스템 권한 여부 (항상 true)
|
||||
*/
|
||||
public static boolean isBatchSystem() {
|
||||
// 배치 작업은 시스템 권한으로 실행
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 배치 사용자 ID를 반환합니다.
|
||||
* 배치 작업은 시스템 배치 사용자로 간주합니다.
|
||||
*
|
||||
* @return 배치 사용자 ID
|
||||
*/
|
||||
public static String getBatchUserId() {
|
||||
return "BATCH_SYSTEM";
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업에서 사용할 수 있는 배치 사용자 계정을 반환합니다.
|
||||
*
|
||||
* @return 배치 사용자 계정
|
||||
*/
|
||||
public static String getBatchUserAccount() {
|
||||
return "BATCH_SYSTEM";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 문자열 관련 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class StringUtil {
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 빈 문자열인지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @return null이거나 빈 문자열이면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isEmpty(String str) {
|
||||
return str == null || str.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이 아니고 빈 문자열이 아닌지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @return null이 아니고 빈 문자열이 아니면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNotEmpty(String str) {
|
||||
return !isEmpty(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 빈 문자열이거나 공백 문자로만 이루어져 있는지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @return null이거나 빈 문자열이거나 공백 문자로만 이루어져 있으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isBlank(String str) {
|
||||
if (isEmpty(str)) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < str.length(); i++) {
|
||||
if (!Character.isWhitespace(str.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이 아니고 빈 문자열이 아니고 공백 문자로만 이루어져 있지 않은지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @return null이 아니고 빈 문자열이 아니고 공백 문자로만 이루어져 있지 않으면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNotBlank(String str) {
|
||||
return !isBlank(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열의 앞뒤 공백을 제거
|
||||
* @param str 처리할 문자열
|
||||
* @return 앞뒤 공백이 제거된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String trim(String str) {
|
||||
return str == null ? null : str.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이면 빈 문자열 반환, 그렇지 않으면 원래 문자열 반환
|
||||
* @param str 처리할 문자열
|
||||
* @return null이면 빈 문자열, 그렇지 않으면 원래 문자열
|
||||
*/
|
||||
public static String nullToEmpty(String str) {
|
||||
return str == null ? "" : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 빈 문자열이면 기본값 반환, 그렇지 않으면 원래 문자열 반환
|
||||
* @param str 처리할 문자열
|
||||
* @param defaultValue 기본값
|
||||
* @return null이거나 빈 문자열이면 기본값, 그렇지 않으면 원래 문자열
|
||||
*/
|
||||
public static String defaultIfEmpty(String str, String defaultValue) {
|
||||
return isEmpty(str) ? defaultValue : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 빈 문자열이거나 공백 문자로만 이루어져 있으면 기본값 반환, 그렇지 않으면 원래 문자열 반환
|
||||
* @param str 처리할 문자열
|
||||
* @param defaultValue 기본값
|
||||
* @return null이거나 빈 문자열이거나 공백 문자로만 이루어져 있으면 기본값, 그렇지 않으면 원래 문자열
|
||||
*/
|
||||
public static String defaultIfBlank(String str, String defaultValue) {
|
||||
return isBlank(str) ? defaultValue : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열의 좌측에서 지정된 길이만큼 문자열 추출
|
||||
* @param str 처리할 문자열
|
||||
* @param len 추출할 길이
|
||||
* @return 추출된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String left(String str, int len) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
if (len < 0) {
|
||||
return "";
|
||||
}
|
||||
if (str.length() <= len) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열의 우측에서 지정된 길이만큼 문자열 추출
|
||||
* @param str 처리할 문자열
|
||||
* @param len 추출할 길이
|
||||
* @return 추출된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String right(String str, int len) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
if (len < 0) {
|
||||
return "";
|
||||
}
|
||||
if (str.length() <= len) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(str.length() - len);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 특정 문자열을 다른 문자열로 모두 치환
|
||||
* @param str 처리할 문자열
|
||||
* @param searchStr 찾을 문자열
|
||||
* @param replaceStr 치환할 문자열
|
||||
* @return 치환된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String replace(String str, String searchStr, String replaceStr) {
|
||||
if (isEmpty(str) || isEmpty(searchStr) || replaceStr == null) {
|
||||
return str;
|
||||
}
|
||||
return str.replace(searchStr, replaceStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 내의 HTML 특수 문자를 이스케이프 처리
|
||||
* @param str 처리할 문자열
|
||||
* @return 이스케이프 처리된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String escapeHtml(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
return str.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 내의 줄바꿈 문자를 HTML <br> 태그로 변환
|
||||
* @param str 처리할 문자열
|
||||
* @return 변환된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String nl2br(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
return str.replace("\r\n", "<br>")
|
||||
.replace("\n", "<br>")
|
||||
.replace("\r", "<br>");
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 지정된 접두사로 시작하는지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @param prefix 접두사
|
||||
* @return 지정된 접두사로 시작하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean startsWith(String str, String prefix) {
|
||||
return str != null && prefix != null && str.startsWith(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 지정된 접미사로 끝나는지 확인
|
||||
* @param str 검사할 문자열
|
||||
* @param suffix 접미사
|
||||
* @return 지정된 접미사로 끝나면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean endsWith(String str, String suffix) {
|
||||
return str != null && suffix != null && str.endsWith(suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 지정된 길이로 자르고 생략 부호를 추가
|
||||
* @param str 처리할 문자열
|
||||
* @param maxLength 최대 길이
|
||||
* @param suffix 생략 부호 (예: "...")
|
||||
* @return 처리된 문자열, null이면 null 반환
|
||||
*/
|
||||
public static String abbreviate(String str, int maxLength, String suffix) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
if (str.length() <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
if (suffix == null) {
|
||||
suffix = "";
|
||||
}
|
||||
int suffixLength = suffix.length();
|
||||
if (maxLength <= suffixLength) {
|
||||
return suffix;
|
||||
}
|
||||
return str.substring(0, maxLength - suffixLength) + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열의 UTF-8 바이트 길이를 계산한다.
|
||||
* 한글 문자는 3바이트, 영문/숫자는 1바이트로 계산한다.
|
||||
*
|
||||
* @param str 바이트 길이를 계산할 문자열
|
||||
* @return 계산된 바이트 길이, null이면 0 반환
|
||||
*/
|
||||
public static int calculateUtf8ByteLength(String str) {
|
||||
if (str == null || str.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int byteLength = 0;
|
||||
for (int i = 0; i < str.length(); i++) {
|
||||
char c = str.charAt(i);
|
||||
|
||||
// 한글 유니코드 범위 확인
|
||||
if ((c >= 0xAC00 && c <= 0xD7A3) || (c >= 0x3131 && c <= 0x318E)) {
|
||||
byteLength += 3; // 한글은 3바이트
|
||||
} else {
|
||||
byteLength += 1; // 영문/숫자는 1바이트
|
||||
}
|
||||
}
|
||||
|
||||
return byteLength;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,340 @@
|
||||
package egovframework.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 데이터 유효성 검증 관련 공통 유틸리티 클래스
|
||||
*/
|
||||
@Component
|
||||
public class ValidationUtil {
|
||||
|
||||
/** 이메일 주소 정규식 패턴 */
|
||||
private static final Pattern EMAIL_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$");
|
||||
|
||||
/** 한국 휴대폰 번호 정규식 패턴 (010-XXXX-XXXX 또는 010XXXXXXXX 형식) */
|
||||
private static final Pattern MOBILE_PHONE_PATTERN =
|
||||
Pattern.compile("^01(?:0|1|[6-9])(?:-?\\d{3,4})?(?:-?\\d{4})$");
|
||||
|
||||
/** 한국 일반 전화번호 정규식 패턴 (지역번호-국번-번호 형식) */
|
||||
private static final Pattern PHONE_PATTERN =
|
||||
Pattern.compile("^(?:(?:\\d{2,3})|(?:\\d{2,3}-))(?:\\d{3,4}-\\d{4})$");
|
||||
|
||||
/** 한국 우편번호 정규식 패턴 (5자리) */
|
||||
private static final Pattern ZIPCODE_PATTERN =
|
||||
Pattern.compile("^\\d{5}$");
|
||||
|
||||
/** 한국 주민등록번호 정규식 패턴 (XXXXXX-XXXXXXX 형식) */
|
||||
private static final Pattern RESIDENT_REGISTRATION_NUMBER_PATTERN =
|
||||
Pattern.compile("^\\d{6}-?[1-4]\\d{6}$");
|
||||
|
||||
/** 한국 사업자등록번호 정규식 패턴 (XXX-XX-XXXXX 형식) */
|
||||
private static final Pattern BUSINESS_REGISTRATION_NUMBER_PATTERN =
|
||||
Pattern.compile("^\\d{3}-?\\d{2}-?\\d{5}$");
|
||||
|
||||
/** IP 주소 정규식 패턴 (IPv4) */
|
||||
private static final Pattern IPV4_PATTERN =
|
||||
Pattern.compile("^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||
|
||||
/** URL 정규식 패턴 */
|
||||
private static final Pattern URL_PATTERN =
|
||||
Pattern.compile("^(https?|ftp)://[^\\s/$.?#].[^\\s]*$");
|
||||
|
||||
/** 날짜 정규식 패턴 (YYYY-MM-DD 형식) */
|
||||
private static final Pattern DATE_PATTERN =
|
||||
Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$");
|
||||
|
||||
/** 시간 정규식 패턴 (HH:MM:SS 형식) */
|
||||
private static final Pattern TIME_PATTERN =
|
||||
Pattern.compile("^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$");
|
||||
|
||||
/** 한글 정규식 패턴 */
|
||||
private static final Pattern KOREAN_PATTERN =
|
||||
Pattern.compile("^[가-힣]+$");
|
||||
|
||||
/** 영문자 정규식 패턴 */
|
||||
private static final Pattern ENGLISH_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z]+$");
|
||||
|
||||
/** 영문자 및 숫자 정규식 패턴 */
|
||||
private static final Pattern ALPHANUMERIC_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z0-9]+$");
|
||||
|
||||
/** 숫자 정규식 패턴 */
|
||||
private static final Pattern NUMERIC_PATTERN =
|
||||
Pattern.compile("^[0-9]+$");
|
||||
|
||||
/**
|
||||
* 이메일 주소 유효성 검증
|
||||
* @param email 검증할 이메일 주소
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidEmail(String email) {
|
||||
if (StringUtil.isEmpty(email)) {
|
||||
return false;
|
||||
}
|
||||
return EMAIL_PATTERN.matcher(email).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 휴대폰 번호 유효성 검증
|
||||
* @param mobilePhone 검증할 휴대폰 번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidMobilePhone(String mobilePhone) {
|
||||
if (StringUtil.isEmpty(mobilePhone)) {
|
||||
return false;
|
||||
}
|
||||
return MOBILE_PHONE_PATTERN.matcher(mobilePhone).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 일반 전화번호 유효성 검증
|
||||
* @param phone 검증할 전화번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidPhone(String phone) {
|
||||
if (StringUtil.isEmpty(phone)) {
|
||||
return false;
|
||||
}
|
||||
return PHONE_PATTERN.matcher(phone).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 우편번호 유효성 검증
|
||||
* @param zipcode 검증할 우편번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidZipcode(String zipcode) {
|
||||
if (StringUtil.isEmpty(zipcode)) {
|
||||
return false;
|
||||
}
|
||||
return ZIPCODE_PATTERN.matcher(zipcode).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 주민등록번호 유효성 검증 (형식만 검증, 실제 유효성은 검증하지 않음)
|
||||
* @param rrn 검증할 주민등록번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidResidentRegistrationNumber(String rrn) {
|
||||
if (StringUtil.isEmpty(rrn)) {
|
||||
return false;
|
||||
}
|
||||
return RESIDENT_REGISTRATION_NUMBER_PATTERN.matcher(rrn).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 사업자등록번호 유효성 검증 (형식만 검증, 실제 유효성은 검증하지 않음)
|
||||
* @param brn 검증할 사업자등록번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidBusinessRegistrationNumber(String brn) {
|
||||
if (StringUtil.isEmpty(brn)) {
|
||||
return false;
|
||||
}
|
||||
return BUSINESS_REGISTRATION_NUMBER_PATTERN.matcher(brn).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소(IPv4) 유효성 검증
|
||||
* @param ipAddress 검증할 IP 주소
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidIpAddress(String ipAddress) {
|
||||
if (StringUtil.isEmpty(ipAddress)) {
|
||||
return false;
|
||||
}
|
||||
return IPV4_PATTERN.matcher(ipAddress).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 유효성 검증
|
||||
* @param url 검증할 URL
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidUrl(String url) {
|
||||
if (StringUtil.isEmpty(url)) {
|
||||
return false;
|
||||
}
|
||||
return URL_PATTERN.matcher(url).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 유효성 검증 (YYYY-MM-DD 형식)
|
||||
* @param date 검증할 날짜
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidDate(String date) {
|
||||
if (StringUtil.isEmpty(date)) {
|
||||
return false;
|
||||
}
|
||||
return DATE_PATTERN.matcher(date).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 유효성 검증 (HH:MM:SS 또는 HH:MM 형식)
|
||||
* @param time 검증할 시간
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidTime(String time) {
|
||||
if (StringUtil.isEmpty(time)) {
|
||||
return false;
|
||||
}
|
||||
return TIME_PATTERN.matcher(time).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 유효성 검증
|
||||
* @param text 검증할 문자열
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isKorean(String text) {
|
||||
if (StringUtil.isEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
return KOREAN_PATTERN.matcher(text).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 영문자 유효성 검증
|
||||
* @param text 검증할 문자열
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isEnglish(String text) {
|
||||
if (StringUtil.isEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
return ENGLISH_PATTERN.matcher(text).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 영문자 및 숫자 유효성 검증
|
||||
* @param text 검증할 문자열
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isAlphanumeric(String text) {
|
||||
if (StringUtil.isEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
return ALPHANUMERIC_PATTERN.matcher(text).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 유효성 검증
|
||||
* @param text 검증할 문자열
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isNumeric(String text) {
|
||||
if (StringUtil.isEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
return NUMERIC_PATTERN.matcher(text).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 복잡성 검증 (영문 대소문자, 숫자, 특수문자 포함 8자 이상)
|
||||
* @param password 검증할 비밀번호
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidPassword(String password) {
|
||||
if (StringUtil.isEmpty(password) || password.length() < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasUpperCase = false;
|
||||
boolean hasLowerCase = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecialChar = false;
|
||||
|
||||
for (char c : password.toCharArray()) {
|
||||
if (Character.isUpperCase(c)) {
|
||||
hasUpperCase = true;
|
||||
} else if (Character.isLowerCase(c)) {
|
||||
hasLowerCase = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else if (!Character.isLetterOrDigit(c)) {
|
||||
hasSpecialChar = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 길이 검증
|
||||
* @param text 검증할 문자열
|
||||
* @param minLength 최소 길이
|
||||
* @param maxLength 최대 길이
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidLength(String text, int minLength, int maxLength) {
|
||||
if (text == null) {
|
||||
return minLength <= 0;
|
||||
}
|
||||
int length = text.length();
|
||||
return length >= minLength && length <= maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주민등록번호 유효성 검증 (체크섬 포함)
|
||||
* @param rrn 검증할 주민등록번호 (XXXXXX-XXXXXXX 형식)
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidResidentRegistrationNumberWithChecksum(String rrn) {
|
||||
if (!isValidResidentRegistrationNumber(rrn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 하이픈 제거
|
||||
rrn = rrn.replace("-", "");
|
||||
|
||||
// 가중치 배열
|
||||
int[] weights = {2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5};
|
||||
|
||||
// 합계 계산
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 12; i++) {
|
||||
sum += (rrn.charAt(i) - '0') * weights[i];
|
||||
}
|
||||
|
||||
// 체크섬 계산
|
||||
int checksum = (11 - (sum % 11)) % 10;
|
||||
|
||||
// 마지막 자리와 체크섬 비교
|
||||
return checksum == (rrn.charAt(12) - '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업자등록번호 유효성 검증 (체크섬 포함)
|
||||
* @param brn 검증할 사업자등록번호 (XXX-XX-XXXXX 형식)
|
||||
* @return 유효하면 true, 그렇지 않으면 false
|
||||
*/
|
||||
public static boolean isValidBusinessRegistrationNumberWithChecksum(String brn) {
|
||||
if (!isValidBusinessRegistrationNumber(brn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 하이픈 제거
|
||||
brn = brn.replace("-", "");
|
||||
|
||||
// 가중치 배열
|
||||
int[] weights = {1, 3, 7, 1, 3, 7, 1, 3, 5};
|
||||
|
||||
// 합계 계산
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
sum += (brn.charAt(i) - '0') * weights[i];
|
||||
}
|
||||
|
||||
// 체크섬 계산
|
||||
sum += ((brn.charAt(8) - '0') * 5) / 10;
|
||||
int checksum = (10 - (sum % 10)) % 10;
|
||||
|
||||
// 마지막 자리와 체크섬 비교
|
||||
return checksum == (brn.charAt(9) - '0');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,588 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import org.apache.poi.poifs.crypt.EncryptionInfo;
|
||||
import org.apache.poi.poifs.crypt.EncryptionMode;
|
||||
import org.apache.poi.poifs.crypt.Encryptor;
|
||||
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.List;
|
||||
|
||||
import static egovframework.util.excel.SuperClassReflectionUtils.getAllFieldsWithExcelColumn;
|
||||
|
||||
/**
|
||||
* SXSSF(Streaming) 방식의 엑셀 파일 생성을 위한 추상 베이스 클래스
|
||||
*
|
||||
* <p>이 클래스는 대용량 엑셀 파일 생성을 위해 Apache POI의 SXSSF(Streaming) API를 활용합니다.
|
||||
* SXSSF는 메모리에 일정 개수의 행만 유지하고 나머지는 디스크에 flush하여 메모리 사용량을 최소화합니다.</p>
|
||||
*
|
||||
* <p><b>주요 기능:</b></p>
|
||||
* <ul>
|
||||
* <li>대용량 데이터 엑셀 파일 생성 (메모리 효율적)</li>
|
||||
* <li>제목 행, 헤더 행, 데이터 행 자동 렌더링</li>
|
||||
* <li>컬럼 너비 자동 조정 또는 수동 지정</li>
|
||||
* <li>암호화 지원 (AES Agile 방식)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see SxssfExcelFile
|
||||
* @see SxssfMultiSheetExcelFile
|
||||
* @author eGovFrame
|
||||
*/
|
||||
public abstract class BaseSxssfExcelFile implements ExcelFile {
|
||||
|
||||
// ==================== 상수 정의 ====================
|
||||
|
||||
/** SXSSF 워크북의 메모리 윈도우 크기 (메모리에 유지할 행의 개수) */
|
||||
protected static final int ROW_ACCESS_WINDOW_SIZE = 10000;
|
||||
|
||||
/** 엑셀 행 시작 인덱스 */
|
||||
protected static final int ROW_START_INDEX = 0;
|
||||
|
||||
/** 엑셀 컬럼 시작 인덱스 */
|
||||
protected static final int COLUMN_START_INDEX = 0;
|
||||
|
||||
/** 제목 폰트 크기 (포인트) */
|
||||
private static final short TITLE_FONT_SIZE = 22;
|
||||
|
||||
/** 제목 행 높이 (포인트) */
|
||||
private static final float TITLE_ROW_HEIGHT = 33f;
|
||||
|
||||
/** 자동 조정 시 컬럼 최소 너비 (POI 단위: 1/256 문자) */
|
||||
private static final int MIN_COLUMN_WIDTH = 3000;
|
||||
|
||||
/** 자동 조정 시 컬럼 최대 너비 (POI 단위: 1/256 문자) */
|
||||
private static final int MAX_COLUMN_WIDTH = 15000;
|
||||
|
||||
/** 자동 조정 시 너비 증가 비율 (한글 폰트 보정) */
|
||||
private static final double AUTO_SIZE_MULTIPLIER = 1.3;
|
||||
|
||||
/** POI 컬럼 너비 단위 (1 문자 = 256 POI 단위) */
|
||||
private static final int POI_WIDTH_UNIT = 256;
|
||||
|
||||
// ==================== 필드 ====================
|
||||
|
||||
/** SXSSF 워크북 인스턴스 */
|
||||
protected SXSSFWorkbook workbook;
|
||||
|
||||
/** 현재 작업 중인 시트 */
|
||||
protected Sheet sheet;
|
||||
|
||||
/** 제목 행 오프셋 (제목이 있으면 1, 없으면 0) */
|
||||
protected int titleRowOffset = 0;
|
||||
|
||||
// ==================== 생성자 ====================
|
||||
|
||||
/**
|
||||
* 기본 생성자
|
||||
* <p>ROW_ACCESS_WINDOW_SIZE 크기의 SXSSF 워크북을 생성합니다.</p>
|
||||
*/
|
||||
public BaseSxssfExcelFile() {
|
||||
this.workbook = new SXSSFWorkbook(ROW_ACCESS_WINDOW_SIZE);
|
||||
}
|
||||
|
||||
// ==================== 렌더링 메서드 ====================
|
||||
|
||||
/**
|
||||
* 엑셀 시트에 제목, 헤더, 데이터를 모두 렌더링합니다.
|
||||
*
|
||||
* <p>이 메서드는 엑셀 시트 생성의 전체 프로세스를 처리합니다:</p>
|
||||
* <ol>
|
||||
* <li>제목 행 렌더링 (제목이 있는 경우)</li>
|
||||
* <li>헤더 행 렌더링</li>
|
||||
* <li>데이터 행 렌더링</li>
|
||||
* <li>컬럼 너비 자동 조정</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param data 엑셀 시트 데이터 (데이터 리스트, 타입, 제목 포함)
|
||||
* @param metadata 엑셀 메타데이터 (헤더명, 컬럼 너비, 시트명 포함)
|
||||
*/
|
||||
protected void renderSheetContent(ExcelSheetData data, ExcelMetadata metadata) {
|
||||
// 1. 제목 행 렌더링 (있는 경우)
|
||||
if (data.getTitle() != null && !data.getTitle().trim().isEmpty()) {
|
||||
int columnCount = metadata.getDataFieldNames().size();
|
||||
renderTitleRow(metadata.getSheetName(), data.getTitle(), columnCount);
|
||||
}
|
||||
|
||||
// 2. 헤더 행 렌더링
|
||||
renderHeaders(metadata);
|
||||
|
||||
// 3. 데이터 행 렌더링
|
||||
renderDataLines(data, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 행을 렌더링합니다.
|
||||
*
|
||||
* <p>첫 번째 행에 제목을 생성하고, 모든 컬럼에 걸쳐 셀을 병합합니다.
|
||||
* 제목 스타일은 굵은 22포인트 폰트, 왼쪽 정렬, 33포인트 행 높이로 설정됩니다.</p>
|
||||
*
|
||||
* @param sheetName 시트명
|
||||
* @param title 제목 텍스트
|
||||
* @param columnCount 전체 컬럼 개수
|
||||
*/
|
||||
protected void renderTitleRow(String sheetName, String title, int columnCount) {
|
||||
// 시트가 없으면 생성
|
||||
if (sheet == null) {
|
||||
sheet = workbook.createSheet(sheetName);
|
||||
}
|
||||
|
||||
// 제목 행 생성
|
||||
Row titleRow = sheet.createRow(ROW_START_INDEX);
|
||||
titleRow.setHeightInPoints(TITLE_ROW_HEIGHT);
|
||||
|
||||
// 제목 스타일 생성
|
||||
CellStyle titleStyle = createTitleCellStyle();
|
||||
|
||||
// 첫 번째 셀에 제목 입력
|
||||
createCell(titleRow, COLUMN_START_INDEX, title, titleStyle);
|
||||
|
||||
// 모든 컬럼에 걸쳐 셀 병합
|
||||
if (columnCount > 1) {
|
||||
sheet.addMergedRegion(new CellRangeAddress(
|
||||
ROW_START_INDEX, // 시작 행
|
||||
ROW_START_INDEX, // 종료 행
|
||||
COLUMN_START_INDEX, // 시작 컬럼
|
||||
columnCount - 1 // 종료 컬럼
|
||||
));
|
||||
}
|
||||
|
||||
// 제목 행 오프셋 설정 (다음 행부터 헤더가 시작됨)
|
||||
titleRowOffset = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 행을 렌더링합니다.
|
||||
*
|
||||
* <p>각 필드의 {@link ExcelColumn} 어노테이션에 정의된 헤더명을 사용하여 헤더 행을 생성합니다.
|
||||
* 헤더는 굵은 폰트, 가운데 정렬로 표시됩니다.</p>
|
||||
*
|
||||
* <p>또한 SXSSF에서 autoSizeColumn을 사용하기 위해 각 컬럼을 추적 대상으로 등록합니다.</p>
|
||||
*
|
||||
* @param excelMetadata 엑셀 메타데이터 (헤더명, 필드명, 시트명 포함)
|
||||
*/
|
||||
protected void renderHeaders(ExcelMetadata excelMetadata) {
|
||||
// 시트가 없으면 생성 (제목이 없는 경우)
|
||||
if (sheet == null) {
|
||||
sheet = workbook.createSheet(excelMetadata.getSheetName());
|
||||
}
|
||||
|
||||
// 헤더 행 생성 (제목이 있으면 row 1, 없으면 row 0)
|
||||
Row row = sheet.createRow(ROW_START_INDEX + titleRowOffset);
|
||||
|
||||
// 헤더 스타일 생성 (굵게, 가운데 정렬)
|
||||
CellStyle headerStyle = createHeaderCellStyle();
|
||||
|
||||
// 각 필드의 헤더명 렌더링
|
||||
int columnIndex = COLUMN_START_INDEX;
|
||||
for (String fieldName : excelMetadata.getDataFieldNames()) {
|
||||
createCell(row, columnIndex++, excelMetadata.getHeaderName(fieldName), headerStyle);
|
||||
}
|
||||
|
||||
// SXSSF에서 autoSizeColumn 사용을 위한 컬럼 추적 등록
|
||||
trackColumnsForAutoSizing(excelMetadata.getDataFieldNames().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 행들을 렌더링합니다.
|
||||
*
|
||||
* <p>리플렉션을 사용하여 각 데이터 객체의 {@link ExcelColumn} 어노테이션이 붙은 필드 값을 읽어
|
||||
* 엑셀 행으로 변환합니다.</p>
|
||||
*
|
||||
* <p>데이터 렌더링 후 각 컬럼의 너비를 조정합니다:
|
||||
* <ul>
|
||||
* <li>headerWidth가 지정된 경우: 지정된 값 사용</li>
|
||||
* <li>headerWidth가 0인 경우: 자동 조정 (내용에 맞게 + 한글 보정)</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* @param data 엑셀 시트 데이터 (데이터 리스트, 타입)
|
||||
* @param metadata 엑셀 메타데이터 (헤더 너비 정보 포함)
|
||||
* @throws RuntimeException 필드 접근 실패 시
|
||||
*/
|
||||
protected void renderDataLines(ExcelSheetData data, ExcelMetadata metadata) {
|
||||
CellStyle dataStyle = createCellStyle(workbook, false);
|
||||
|
||||
// 숫자 서식 스타일 생성(정수/소수) - 기본 스타일을 복제하여 데이터 포맷만 부여
|
||||
CellStyle integerNumberStyle = workbook.createCellStyle();
|
||||
integerNumberStyle.cloneStyleFrom(dataStyle);
|
||||
short intDf = workbook.createDataFormat().getFormat("#,##0");
|
||||
integerNumberStyle.setDataFormat(intDf);
|
||||
|
||||
CellStyle decimalNumberStyle = workbook.createCellStyle();
|
||||
decimalNumberStyle.cloneStyleFrom(dataStyle);
|
||||
short decDf = workbook.createDataFormat().getFormat("#,##0.##");
|
||||
decimalNumberStyle.setDataFormat(decDf);
|
||||
|
||||
// 한글 중요 주석: 정렬 옵션 지원을 위해 기본/숫자 스타일에 대해 좌/중앙/우 정렬 변형을 미리 생성해 재사용한다.
|
||||
CellStyle baseLeft = workbook.createCellStyle();
|
||||
baseLeft.cloneStyleFrom(dataStyle);
|
||||
baseLeft.setAlignment(HorizontalAlignment.LEFT);
|
||||
CellStyle baseCenter = workbook.createCellStyle();
|
||||
baseCenter.cloneStyleFrom(dataStyle);
|
||||
baseCenter.setAlignment(HorizontalAlignment.CENTER);
|
||||
CellStyle baseRight = workbook.createCellStyle();
|
||||
baseRight.cloneStyleFrom(dataStyle);
|
||||
baseRight.setAlignment(HorizontalAlignment.RIGHT);
|
||||
|
||||
CellStyle intLeft = workbook.createCellStyle();
|
||||
intLeft.cloneStyleFrom(integerNumberStyle);
|
||||
intLeft.setAlignment(HorizontalAlignment.LEFT);
|
||||
CellStyle intCenter = workbook.createCellStyle();
|
||||
intCenter.cloneStyleFrom(integerNumberStyle);
|
||||
intCenter.setAlignment(HorizontalAlignment.CENTER);
|
||||
CellStyle intRight = workbook.createCellStyle();
|
||||
intRight.cloneStyleFrom(integerNumberStyle);
|
||||
intRight.setAlignment(HorizontalAlignment.RIGHT);
|
||||
|
||||
CellStyle decLeft = workbook.createCellStyle();
|
||||
decLeft.cloneStyleFrom(decimalNumberStyle);
|
||||
decLeft.setAlignment(HorizontalAlignment.LEFT);
|
||||
CellStyle decCenter = workbook.createCellStyle();
|
||||
decCenter.cloneStyleFrom(decimalNumberStyle);
|
||||
decCenter.setAlignment(HorizontalAlignment.CENTER);
|
||||
CellStyle decRight = workbook.createCellStyle();
|
||||
decRight.cloneStyleFrom(decimalNumberStyle);
|
||||
decRight.setAlignment(HorizontalAlignment.RIGHT);
|
||||
|
||||
// 데이터 시작 행 (제목이 있으면 row 2, 없으면 row 1)
|
||||
int rowIndex = ROW_START_INDEX + titleRowOffset + 1;
|
||||
List<Field> fields = getAllFieldsWithExcelColumn(data.getType());
|
||||
|
||||
// 각 데이터 객체를 행으로 변환
|
||||
for (Object record : data.getDataList()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
renderDataRow(row, record, fields, dataStyle, integerNumberStyle, decimalNumberStyle,
|
||||
baseLeft, baseCenter, baseRight,
|
||||
intLeft, intCenter, intRight,
|
||||
decLeft, decCenter, decRight);
|
||||
}
|
||||
|
||||
// 컬럼 너비 조정
|
||||
adjustColumnWidths(fields, metadata);
|
||||
}
|
||||
|
||||
// ==================== private 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* 제목 셀 스타일을 생성합니다.
|
||||
*
|
||||
* @return 제목용 CellStyle (굵은 22포인트 폰트, 왼쪽/중앙 정렬)
|
||||
*/
|
||||
private CellStyle createTitleCellStyle() {
|
||||
CellStyle titleStyle = workbook.createCellStyle();
|
||||
titleStyle.setAlignment(HorizontalAlignment.LEFT);
|
||||
titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
|
||||
Font titleFont = workbook.createFont();
|
||||
titleFont.setFontHeightInPoints(TITLE_FONT_SIZE);
|
||||
titleFont.setBold(true);
|
||||
titleStyle.setFont(titleFont);
|
||||
|
||||
// 중요 로직(한글): 제목 셀에도 실선(THIN) 보더를 적용하여 표의 일관성 유지
|
||||
titleStyle.setBorderTop(BorderStyle.THIN);
|
||||
titleStyle.setBorderBottom(BorderStyle.THIN);
|
||||
titleStyle.setBorderLeft(BorderStyle.THIN);
|
||||
titleStyle.setBorderRight(BorderStyle.THIN);
|
||||
|
||||
// 중요 로직(한글): 제목 배경색 지정 - 요구사항에 따라 #be8e00 색상을 적용
|
||||
// SXSSF에서는 내부적으로 XSSFCellStyle을 사용하므로 캐스팅하여 사용자 색상 설정
|
||||
if (titleStyle instanceof org.apache.poi.xssf.usermodel.XSSFCellStyle) {
|
||||
org.apache.poi.xssf.usermodel.XSSFCellStyle xssf = (org.apache.poi.xssf.usermodel.XSSFCellStyle) titleStyle;
|
||||
xssf.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(0xBE, 0x8E, 0x00), null));
|
||||
xssf.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
}
|
||||
|
||||
return titleStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 셀 스타일을 생성합니다.
|
||||
*
|
||||
* @return 헤더용 CellStyle (굵은 폰트, 가운데 정렬)
|
||||
*/
|
||||
private CellStyle createHeaderCellStyle() {
|
||||
CellStyle headerStyle = createCellStyle(workbook, true);
|
||||
headerStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
|
||||
// 중요 로직(한글): 헤더 배경색 지정 - 요구사항에 따라 #fde598 색상을 적용
|
||||
if (headerStyle instanceof org.apache.poi.xssf.usermodel.XSSFCellStyle) {
|
||||
org.apache.poi.xssf.usermodel.XSSFCellStyle xssf = (org.apache.poi.xssf.usermodel.XSSFCellStyle) headerStyle;
|
||||
xssf.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(0xFD, 0xE5, 0x98), null));
|
||||
xssf.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
}
|
||||
|
||||
return headerStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* SXSSF autoSizeColumn 사용을 위해 컬럼들을 추적 대상으로 등록합니다.
|
||||
*
|
||||
* @param columnCount 추적할 컬럼 개수
|
||||
*/
|
||||
private void trackColumnsForAutoSizing(int columnCount) {
|
||||
for (int i = 0; i < columnCount; i++) {
|
||||
((org.apache.poi.xssf.streaming.SXSSFSheet) sheet)
|
||||
.trackColumnForAutoSizing(COLUMN_START_INDEX + i);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 행의 각 셀을 렌더링합니다.
|
||||
*
|
||||
* @param row 렌더링할 행
|
||||
* @param record 데이터 객체
|
||||
* @param fields ExcelColumn 어노테이션이 있는 필드 리스트
|
||||
* @param baseStyle 기본 데이터 셀 스타일(보더 등 공통)
|
||||
* @param integerNumberStyle 정수형 숫자 서식 스타일(#,##0)
|
||||
* @param decimalNumberStyle 소수형 숫자 서식 스타일(#,##0.##)
|
||||
* @throws RuntimeException 필드 접근 실패 시
|
||||
*/
|
||||
private void renderDataRow(
|
||||
Row row,
|
||||
Object record,
|
||||
List<Field> fields,
|
||||
CellStyle baseStyle, CellStyle integerNumberStyle, CellStyle decimalNumberStyle,
|
||||
CellStyle baseLeft, CellStyle baseCenter, CellStyle baseRight,
|
||||
CellStyle intLeft, CellStyle intCenter, CellStyle intRight,
|
||||
CellStyle decLeft, CellStyle decCenter, CellStyle decRight) {
|
||||
int columnIndex = COLUMN_START_INDEX;
|
||||
try {
|
||||
for (Field field : fields) {
|
||||
field.setAccessible(true);
|
||||
|
||||
Object value = field.get(record);
|
||||
|
||||
// 수식 처리: ExcelColumn에 formula 설정이 있는 경우 수식을 생성하여 설정
|
||||
ExcelColumn excelColumn = field.getAnnotation(ExcelColumn.class);
|
||||
ExcelColumn.Align align = (excelColumn != null) ? excelColumn.align() : ExcelColumn.Align.AUTO;
|
||||
if (excelColumn != null && excelColumn.formula() && !excelColumn.formulaRefField().isEmpty()) {
|
||||
String refFieldName = excelColumn.formulaRefField();
|
||||
int refColumnIndex = findFieldColumnIndex(fields, refFieldName);
|
||||
if (refColumnIndex >= 0) {
|
||||
// 한글 중요 주석: 참조 대상(X열) 값이 null 또는 공백("")이면 수식을 적용하지 않고 빈 셀로 처리하여 컬럼 정렬 유지
|
||||
Field refField = fields.get(refColumnIndex);
|
||||
refField.setAccessible(true);
|
||||
Object refValue = refField.get(record);
|
||||
boolean isBlankRef = (refValue == null) || (refValue instanceof String && ((String) refValue).trim().isEmpty());
|
||||
CellStyle baseAligned = baseStyle;
|
||||
if (align == ExcelColumn.Align.LEFT) baseAligned = baseLeft;
|
||||
else if (align == ExcelColumn.Align.CENTER) baseAligned = baseCenter;
|
||||
else if (align == ExcelColumn.Align.RIGHT) baseAligned = baseRight;
|
||||
if (isBlankRef) {
|
||||
Cell cell = row.createCell(columnIndex++);
|
||||
cell.setCellValue("");
|
||||
cell.setCellStyle(baseAligned);
|
||||
continue; // 다음 필드 처리
|
||||
}
|
||||
|
||||
String refExcelColumn = toExcelColumnName(refColumnIndex + 1); // 1-based for Excel column letters
|
||||
int excelRowNumber = row.getRowNum() + 1; // 1-based row number in Excel
|
||||
String formula = String.format(excelColumn.formulaPattern(), refExcelColumn, excelRowNumber);
|
||||
|
||||
Cell cell = row.createCell(columnIndex++);
|
||||
cell.setCellFormula(formula);
|
||||
cell.setCellStyle(baseAligned);
|
||||
continue; // 다음 필드 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 값 처리: 숫자 타입일 경우 천단위 콤마 서식 적용 + 정렬 옵션 반영
|
||||
boolean isIntNum = (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long);
|
||||
boolean isDecNum = (value instanceof Float || value instanceof Double || value instanceof BigDecimal);
|
||||
CellStyle styleToUse = baseStyle;
|
||||
if (isIntNum) {
|
||||
styleToUse = integerNumberStyle;
|
||||
} else if (isDecNum) {
|
||||
styleToUse = decimalNumberStyle;
|
||||
}
|
||||
if (align != ExcelColumn.Align.AUTO) {
|
||||
if (isIntNum) {
|
||||
if (align == ExcelColumn.Align.LEFT) styleToUse = intLeft;
|
||||
else if (align == ExcelColumn.Align.CENTER) styleToUse = intCenter;
|
||||
else if (align == ExcelColumn.Align.RIGHT) styleToUse = intRight;
|
||||
} else if (isDecNum) {
|
||||
if (align == ExcelColumn.Align.LEFT) styleToUse = decLeft;
|
||||
else if (align == ExcelColumn.Align.CENTER) styleToUse = decCenter;
|
||||
else if (align == ExcelColumn.Align.RIGHT) styleToUse = decRight;
|
||||
} else {
|
||||
if (align == ExcelColumn.Align.LEFT) styleToUse = baseLeft;
|
||||
else if (align == ExcelColumn.Align.CENTER) styleToUse = baseCenter;
|
||||
else if (align == ExcelColumn.Align.RIGHT) styleToUse = baseRight;
|
||||
}
|
||||
}
|
||||
createCell(row, columnIndex++, value, styleToUse);
|
||||
}
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new RuntimeException("데이터 필드 접근 중 오류가 발생했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드명으로 컬럼 인덱스를 찾습니다.
|
||||
* 한글 중요 주석: ExcelColumn 필드 순서 기준으로 0부터 시작하는 인덱스를 반환.
|
||||
*/
|
||||
private int findFieldColumnIndex(List<Field> fields, String targetFieldName) {
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
if (fields.get(i).getName().equals(targetFieldName)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1부터 시작하는 컬럼 번호를 엑셀 컬럼명(A, B, ... AA, AB ...)으로 변환합니다.
|
||||
* 한글 중요 주석: 엑셀 수식에서 참조할 컬럼 문자열을 생성하기 위한 유틸.
|
||||
*/
|
||||
private String toExcelColumnName(int columnNumber) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int num = columnNumber;
|
||||
while (num > 0) {
|
||||
int rem = (num - 1) % 26;
|
||||
sb.insert(0, (char) ('A' + rem));
|
||||
num = (num - 1) / 26;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 각 컬럼의 너비를 조정합니다.
|
||||
*
|
||||
* <p>headerWidth가 지정된 경우 해당 값을 사용하고,
|
||||
* 그렇지 않은 경우 자동 조정 후 한글 폰트 보정을 적용합니다.</p>
|
||||
*
|
||||
* @param fields ExcelColumn 어노테이션이 있는 필드 리스트
|
||||
* @param metadata 엑셀 메타데이터 (헤더 너비 정보 포함)
|
||||
*/
|
||||
private void adjustColumnWidths(List<Field> fields, ExcelMetadata metadata) {
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
int columnIndex = COLUMN_START_INDEX + i;
|
||||
Field field = fields.get(i);
|
||||
String fieldName = field.getName();
|
||||
|
||||
int headerWidth = metadata.getHeaderWidth(fieldName);
|
||||
|
||||
if (headerWidth > 0) {
|
||||
// headerWidth가 지정된 경우: 지정된 값 사용
|
||||
setColumnWidthInCharacters(columnIndex, headerWidth);
|
||||
} else {
|
||||
// headerWidth가 0인 경우: 자동 조정 + 한글 보정
|
||||
autoSizeColumnWithKoreanFontCorrection(columnIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 너비를 문자 단위로 설정합니다.
|
||||
*
|
||||
* @param columnIndex 컬럼 인덱스
|
||||
* @param widthInCharacters 문자 단위 너비
|
||||
*/
|
||||
private void setColumnWidthInCharacters(int columnIndex, int widthInCharacters) {
|
||||
sheet.setColumnWidth(columnIndex, widthInCharacters * POI_WIDTH_UNIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 너비를 자동 조정하고 한글 폰트 보정을 적용합니다.
|
||||
*
|
||||
* <p>POI의 autoSizeColumn은 한글 폰트 크기를 정확하게 계산하지 못하므로,
|
||||
* 자동 조정 후 1.3배 증가시키고 최소/최대 범위를 적용합니다.</p>
|
||||
*
|
||||
* @param columnIndex 컬럼 인덱스
|
||||
*/
|
||||
private void autoSizeColumnWithKoreanFontCorrection(int columnIndex) {
|
||||
sheet.autoSizeColumn(columnIndex);
|
||||
|
||||
int currentWidth = sheet.getColumnWidth(columnIndex);
|
||||
int adjustedWidth = (int) (currentWidth * AUTO_SIZE_MULTIPLIER);
|
||||
|
||||
// 최소/최대 범위 적용
|
||||
int finalWidth = Math.max(MIN_COLUMN_WIDTH, Math.min(adjustedWidth, MAX_COLUMN_WIDTH));
|
||||
sheet.setColumnWidth(columnIndex, finalWidth);
|
||||
}
|
||||
|
||||
// ==================== ExcelFile 인터페이스 구현 ====================
|
||||
|
||||
/**
|
||||
* 워크북을 출력 스트림에 씁니다.
|
||||
*
|
||||
* @param stream 출력 스트림
|
||||
* @throws RuntimeException IO 오류 발생 시
|
||||
*/
|
||||
@Override
|
||||
public void write(OutputStream stream) {
|
||||
try {
|
||||
workbook.write(stream);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("엑셀 파일 쓰기 중 오류가 발생했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 워크북을 암호화하여 출력 스트림에 씁니다.
|
||||
*
|
||||
* <p>AES Agile 암호화 방식을 사용합니다.
|
||||
* password가 null인 경우 암호화 없이 일반 파일로 작성됩니다.</p>
|
||||
*
|
||||
* @param stream 출력 스트림
|
||||
* @param password 암호화 비밀번호 (null이면 암호화하지 않음)
|
||||
* @throws RuntimeException IO 오류 또는 암호화 오류 발생 시
|
||||
*/
|
||||
@Override
|
||||
public void writeWithEncryption(OutputStream stream, String password) {
|
||||
try {
|
||||
if (password == null) {
|
||||
write(stream);
|
||||
} else {
|
||||
encryptAndWrite(stream, password);
|
||||
}
|
||||
workbook.close();
|
||||
stream.close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("암호화된 엑셀 파일 쓰기 중 오류가 발생했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 워크북을 암호화하여 출력 스트림에 씁니다.
|
||||
*
|
||||
* @param stream 출력 스트림
|
||||
* @param password 암호화 비밀번호
|
||||
* @throws IOException IO 오류 발생 시
|
||||
*/
|
||||
private void encryptAndWrite(OutputStream stream, String password) throws IOException {
|
||||
POIFSFileSystem fileSystem = new POIFSFileSystem();
|
||||
OutputStream encryptorStream = getEncryptorStream(fileSystem, password);
|
||||
|
||||
workbook.write(encryptorStream);
|
||||
encryptorStream.close();
|
||||
|
||||
fileSystem.writeFilesystem(stream);
|
||||
fileSystem.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* POIFSFileSystem에 대한 암호화 스트림을 생성합니다.
|
||||
*
|
||||
* @param fileSystem POI 파일 시스템
|
||||
* @param password 암호화 비밀번호
|
||||
* @return 암호화된 출력 스트림
|
||||
* @throws RuntimeException 암호화 초기화 실패 시
|
||||
*/
|
||||
private OutputStream getEncryptorStream(POIFSFileSystem fileSystem, String password) {
|
||||
try {
|
||||
Encryptor encryptor = new EncryptionInfo(EncryptionMode.agile).getEncryptor();
|
||||
encryptor.confirmPassword(password);
|
||||
return encryptor.getDataStream(fileSystem);
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new RuntimeException("POIFSFileSystem에서 암호화 스트림 생성에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelColumn {
|
||||
String headerName() default "";
|
||||
int headerWidth() default 0; // 0이면 자동 조정, 0보다 크면 지정된 너비 사용
|
||||
|
||||
// ==================== 수식 지원 옵션 ====================
|
||||
/**
|
||||
* 셀을 수식으로 작성할지 여부
|
||||
* 한글 중요 주석: true 인 경우, 해당 필드는 값 대신 수식이 설정됨.
|
||||
*/
|
||||
boolean formula() default false;
|
||||
|
||||
/**
|
||||
* 수식에서 참조할 동일 행 내 다른 필드명
|
||||
* 한글 중요 주석: 예) "deadline" 을 지정하면, 해당 필드의 컬럼을 참조하여 수식을 생성.
|
||||
*/
|
||||
String formulaRefField() default "";
|
||||
|
||||
/**
|
||||
* 수식 패턴. %s는 참조 컬럼의 엑셀 컬럼명(A, B, ...), %d는 현재 엑셀 행 번호(1부터)로 치환됨.
|
||||
* 한글 중요 주석: 기본값은 "=%s%d-TODAY()" 로, "참조셀 - 오늘" 형태의 남은일 계산을 의미.
|
||||
*/
|
||||
String formulaPattern() default "=%s%d-TODAY()";
|
||||
|
||||
// ==================== 정렬 옵션(선택) ====================
|
||||
/**
|
||||
* 데이터 셀 정렬 옵션 (선택)
|
||||
* 한글 중요 주석: 미지정(AUTO) 시 기존 기본 정렬을 유지. 필요 컬럼만 LEFT/CENTER/RIGHT 지정.
|
||||
*/
|
||||
Align align() default Align.AUTO;
|
||||
|
||||
/**
|
||||
* 정렬 열거형
|
||||
* 한글 중요 주석: AUTO는 어노테이션에서 정렬을 지정하지 않음(기본 유지)을 의미.
|
||||
*/
|
||||
enum Align { AUTO, LEFT, CENTER, RIGHT }
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 엑셀 파일 생성을 위한 기본 인터페이스
|
||||
*
|
||||
* <p>이 인터페이스는 엑셀 파일 생성에 필요한 기본 메서드를 정의합니다.
|
||||
* 셀 생성, 셀 스타일 설정, 파일 쓰기 등의 공통 기능을 제공합니다.</p>
|
||||
*
|
||||
* <p><b>주요 기능:</b></p>
|
||||
* <ul>
|
||||
* <li>다양한 타입의 데이터를 엑셀 셀로 변환</li>
|
||||
* <li>셀 스타일 생성 (굵기, 폰트 등)</li>
|
||||
* <li>엑셀 파일 출력 (일반/암호화)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see BaseSxssfExcelFile
|
||||
* @author eGovFrame
|
||||
*/
|
||||
public interface ExcelFile {
|
||||
|
||||
// ==================== 파일 출력 메서드 ====================
|
||||
|
||||
/**
|
||||
* 엑셀 워크북을 출력 스트림에 씁니다.
|
||||
*
|
||||
* @param stream 출력 스트림
|
||||
* @throws IOException 파일 쓰기 중 오류 발생 시
|
||||
*/
|
||||
void write(OutputStream stream) throws IOException;
|
||||
|
||||
/**
|
||||
* 엑셀 워크북을 암호화하여 출력 스트림에 씁니다.
|
||||
*
|
||||
* <p>password가 null이면 암호화하지 않고 일반 파일로 작성됩니다.</p>
|
||||
*
|
||||
* @param stream 출력 스트림
|
||||
* @param password 암호화 비밀번호 (null이면 암호화하지 않음)
|
||||
* @throws IOException 파일 쓰기 또는 암호화 중 오류 발생 시
|
||||
*/
|
||||
void writeWithEncryption(OutputStream stream, String password) throws IOException;
|
||||
|
||||
// ==================== 셀 생성 메서드 ====================
|
||||
|
||||
/**
|
||||
* 엑셀 셀을 생성하고 값과 스타일을 설정합니다.
|
||||
*
|
||||
* <p>이 메서드는 다양한 타입의 데이터를 적절한 엑셀 셀 타입으로 변환합니다:
|
||||
* <ul>
|
||||
* <li>숫자 타입 (Integer, Long, Double, Float): 숫자 셀</li>
|
||||
* <li>불린 타입 (Boolean): 불린 셀</li>
|
||||
* <li>날짜/시간 타입 (LocalDateTime, LocalDate, LocalTime): 포맷된 문자열 셀</li>
|
||||
* <li>기타 타입: toString() 결과를 문자열 셀로 저장</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* <p><b>날짜/시간 포맷:</b></p>
|
||||
* <ul>
|
||||
* <li>LocalDateTime: "yyyy-MM-dd HH:mm:ss"</li>
|
||||
* <li>LocalDate: "yyyy-MM-dd"</li>
|
||||
* <li>LocalTime: "HH:mm:ss"</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param <T> 값의 타입
|
||||
* @param row 셀을 생성할 행
|
||||
* @param column 셀의 컬럼 인덱스 (0부터 시작)
|
||||
* @param value 셀에 저장할 값 (null이면 셀을 생성하지 않음)
|
||||
* @param style 셀에 적용할 스타일
|
||||
*/
|
||||
default <T> void createCell(Row row, int column, T value, CellStyle style) {
|
||||
// null 값도 보더가 보이도록 빈 셀 생성 후 스타일 적용 (NPE 방지)
|
||||
Cell cell = row.createCell(column);
|
||||
if (value == null) {
|
||||
cell.setCellValue("");
|
||||
cell.setCellStyle(style);
|
||||
return;
|
||||
}
|
||||
|
||||
// 타입별로 적절한 셀 값 설정
|
||||
if (value instanceof Integer) {
|
||||
cell.setCellValue((Integer) value);
|
||||
} else if (value instanceof Long) {
|
||||
cell.setCellValue((Long) value);
|
||||
} else if (value instanceof Double) {
|
||||
cell.setCellValue((Double) value);
|
||||
} else if (value instanceof Float) {
|
||||
cell.setCellValue((Float) value);
|
||||
} else if (value instanceof Boolean) {
|
||||
cell.setCellValue((Boolean) value);
|
||||
} else if (value instanceof LocalDateTime) {
|
||||
LocalDateTime dateTime = (LocalDateTime) value;
|
||||
cell.setCellValue(dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
} else if (value instanceof LocalDate) {
|
||||
LocalDate date = (LocalDate) value;
|
||||
cell.setCellValue(date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
|
||||
} else if (value instanceof LocalTime) {
|
||||
LocalTime time = (LocalTime) value;
|
||||
cell.setCellValue(time.format(DateTimeFormatter.ofPattern("HH:mm:ss")));
|
||||
} else {
|
||||
cell.setCellValue(value.toString());
|
||||
}
|
||||
|
||||
// 스타일 적용
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
// ==================== 스타일 생성 메서드 ====================
|
||||
|
||||
/**
|
||||
* 셀 스타일을 생성합니다.
|
||||
*
|
||||
* <p>기본적인 셀 스타일을 생성하며, 굵은 폰트 여부를 지정할 수 있습니다.</p>
|
||||
*
|
||||
* @param wb 워크북
|
||||
* @param isBold 굵은 폰트 사용 여부 (true: 굵게, false: 보통)
|
||||
* @return 생성된 셀 스타일
|
||||
*/
|
||||
default CellStyle createCellStyle(Workbook wb, boolean isBold) {
|
||||
CellStyle style = wb.createCellStyle();
|
||||
Font font = wb.createFont();
|
||||
font.setBold(isBold);
|
||||
style.setFont(font);
|
||||
|
||||
// 중요 로직(한글): 생성되는 모든 셀에 대해 실선(THIN) 보더 적용
|
||||
// 데이터 셀과 헤더 셀은 본 메서드를 통해 스타일이 생성되므로, 여기서 공통 보더를 지정한다.
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
|
||||
return style;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
@Slf4j
|
||||
public class ExcelHandler<T> {
|
||||
|
||||
public List<T> handleExcelUpload(List<MultipartFile> mFiles, Class<T> clazz) {
|
||||
List<T> dataList = new ArrayList<>();
|
||||
|
||||
mFiles.forEach(file -> {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
dataList.addAll(parseExcel(inputStream, clazz));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
return dataList;
|
||||
}
|
||||
|
||||
private List<T> parseExcel(InputStream inputStream, Class<T> clazz) throws IOException {
|
||||
Workbook workbook = new XSSFWorkbook(inputStream);
|
||||
Sheet sheet = workbook.getSheetAt(0); // 첫 번째 시트를 가져옴
|
||||
|
||||
// 헤더 정보 추출
|
||||
Row headerRow = sheet.getRow(0);
|
||||
List<String> headers = StreamSupport.stream(headerRow.spliterator(), false)
|
||||
.map(Cell::getStringCellValue)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<T> dataList = StreamSupport.stream(sheet.spliterator(), false)
|
||||
.skip(1) // 첫 번째 행은 헤더이므로 건너뜁니다.
|
||||
.filter(this::isRowNotEmpty) // 빈 행이 아닌 경우에만 처리합니다.
|
||||
.map(row -> mapRowToDto(row, clazz, headers))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
workbook.close();
|
||||
return dataList;
|
||||
}
|
||||
|
||||
private boolean isRowNotEmpty(Row row) {
|
||||
Iterator<Cell> cellIterator = row.cellIterator();
|
||||
while (cellIterator.hasNext()) {
|
||||
Cell cell = cellIterator.next();
|
||||
if (cell.getCellType() != CellType.BLANK) {
|
||||
return true; // 빈 셀이 아닌 경우에만 true를 반환합니다.
|
||||
}
|
||||
}
|
||||
return false; // 모든 셀이 비어 있으면 false를 반환합니다.
|
||||
}
|
||||
|
||||
private T mapRowToDto(Row row, Class<T> clazz, List<String> excelHeaderList) {
|
||||
T dataDto;
|
||||
try {
|
||||
dataDto = clazz.getDeclaredConstructor().newInstance();
|
||||
|
||||
Iterator<Cell> cellIterator = row.cellIterator();
|
||||
while (cellIterator.hasNext()) {
|
||||
Cell cell = cellIterator.next();
|
||||
String excelHeaderName = excelHeaderList.get(cell.getColumnIndex());
|
||||
|
||||
//각 필드를 순회하며 커스텀 어노테이션인 ExcelHeader값에 맞게 값을 넣어줌
|
||||
Field[] dtoFields = clazz.getDeclaredFields();
|
||||
for (Field field : dtoFields) {
|
||||
if (field.isAnnotationPresent(ExcelColumn.class)) {
|
||||
ExcelColumn annotation = field.getAnnotation(ExcelColumn.class);
|
||||
if (Objects.requireNonNull(annotation).headerName().equals(excelHeaderName)) {
|
||||
field.setAccessible(true);
|
||||
setFieldValue(field, dataDto, cell);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return dataDto;
|
||||
}
|
||||
|
||||
private void setFieldValue(Field field, T dataDto, Cell cell) throws IllegalAccessException {
|
||||
Class<?> fieldType = field.getType();
|
||||
field.setAccessible(true);
|
||||
|
||||
if (fieldType == int.class || fieldType == Integer.class) {
|
||||
field.set(dataDto, (int)cell.getNumericCellValue());
|
||||
} else if (fieldType == long.class || fieldType == Long.class) {
|
||||
field.set(dataDto, (long)cell.getNumericCellValue());
|
||||
} else if (fieldType == double.class || fieldType == Double.class) {
|
||||
field.set(dataDto, cell.getNumericCellValue());
|
||||
} else if (fieldType == boolean.class || fieldType == Boolean.class) {
|
||||
field.set(dataDto, cell.getBooleanCellValue());
|
||||
} else {
|
||||
// if (fieldType == String.class) {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
field.set(dataDto, formatter.formatCellValue(cell));
|
||||
}
|
||||
// 다른 타입에 따른 맵핑을 추가할 수 있습니다.
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
public class ExcelMetadata {
|
||||
private final Map<String, String> excelHeaderNames;
|
||||
private final Map<String, Integer> excelHeaderWidths;
|
||||
private final List<String> dataFieldNames;
|
||||
private final String sheetName;
|
||||
|
||||
public ExcelMetadata(Map<String, String> excelHeaderNames, Map<String, Integer> excelHeaderWidths, List<String> dataFieldNames, String sheetName) {
|
||||
this.excelHeaderNames = excelHeaderNames;
|
||||
this.excelHeaderWidths = excelHeaderWidths;
|
||||
this.dataFieldNames = dataFieldNames;
|
||||
this.sheetName = sheetName;
|
||||
}
|
||||
|
||||
public String getHeaderName(String fieldName) {
|
||||
return excelHeaderNames.getOrDefault(fieldName, "");
|
||||
}
|
||||
|
||||
public int getHeaderWidth(String fieldName) {
|
||||
return excelHeaderWidths.getOrDefault(fieldName, 0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
|
||||
import static egovframework.util.excel.SuperClassReflectionUtils.getAllFields;
|
||||
import static org.springframework.core.annotation.AnnotationUtils.getAnnotation;
|
||||
|
||||
/**
|
||||
* 엑셀 메타데이터 생성을 위한 팩토리 클래스 (싱글톤)
|
||||
*
|
||||
* <p>이 클래스는 VO 클래스의 {@link ExcelColumn} 및 {@link ExcelSheet} 어노테이션을 분석하여
|
||||
* 엑셀 파일 생성에 필요한 메타데이터를 생성합니다.</p>
|
||||
*
|
||||
* <p><b>주요 기능:</b></p>
|
||||
* <ul>
|
||||
* <li>리플렉션을 사용하여 VO 클래스의 필드 정보 추출</li>
|
||||
* <li>ExcelColumn 어노테이션에서 헤더명과 컬럼 너비 수집</li>
|
||||
* <li>ExcelSheet 어노테이션에서 시트명 추출</li>
|
||||
* <li>싱글톤 패턴으로 인스턴스 재사용</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>사용 예제:</b></p>
|
||||
* <pre>{@code
|
||||
* ExcelMetadata metadata = ExcelMetadataFactory.getInstance()
|
||||
* .createMetadata(UserVO.class);
|
||||
*
|
||||
* String sheetName = metadata.getSheetName();
|
||||
* List<String> fieldNames = metadata.getDataFieldNames();
|
||||
* }</pre>
|
||||
*
|
||||
* @see ExcelMetadata
|
||||
* @see ExcelColumn
|
||||
* @see ExcelSheet
|
||||
* @author eGovFrame
|
||||
*/
|
||||
public class ExcelMetadataFactory {
|
||||
|
||||
// ==================== 싱글톤 구현 ====================
|
||||
|
||||
/**
|
||||
* private 생성자 (외부에서 인스턴스 생성 방지)
|
||||
*/
|
||||
private ExcelMetadataFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 싱글톤 인스턴스를 보유하는 내부 클래스
|
||||
* <p>Bill Pugh Singleton 패턴 사용 (Thread-safe, Lazy Loading)</p>
|
||||
*/
|
||||
private static class SingletonHolder {
|
||||
private static final ExcelMetadataFactory INSTANCE = new ExcelMetadataFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 싱글톤 인스턴스를 반환합니다.
|
||||
*
|
||||
* @return ExcelMetadataFactory 싱글톤 인스턴스
|
||||
*/
|
||||
public static ExcelMetadataFactory getInstance() {
|
||||
return SingletonHolder.INSTANCE;
|
||||
}
|
||||
|
||||
// ==================== 메타데이터 생성 ====================
|
||||
|
||||
/**
|
||||
* VO 클래스로부터 엑셀 메타데이터를 생성합니다.
|
||||
*
|
||||
* <p>이 메서드는 VO 클래스의 모든 필드를 검사하여 {@link ExcelColumn} 어노테이션이 붙은
|
||||
* 필드만 추출하고, 각 필드의 헤더명과 컬럼 너비 정보를 수집합니다.</p>
|
||||
*
|
||||
* <p><b>처리 과정:</b></p>
|
||||
* <ol>
|
||||
* <li>상속 계층 구조를 포함한 모든 필드 탐색</li>
|
||||
* <li>ExcelColumn 어노테이션이 있는 필드만 선택</li>
|
||||
* <li>헤더명, 컬럼 너비, 필드명 수집</li>
|
||||
* <li>ExcelSheet 어노테이션에서 시트명 추출</li>
|
||||
* <li>ExcelMetadata 객체 생성 및 반환</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param clazz 메타데이터를 생성할 VO 클래스
|
||||
* @return 생성된 엑셀 메타데이터
|
||||
* @throws RuntimeException ExcelColumn 어노테이션이 하나도 없는 경우
|
||||
*/
|
||||
public ExcelMetadata createMetadata(Class<?> clazz) {
|
||||
// 헤더명, 컬럼 너비, 필드명을 저장할 컬렉션 (순서 유지)
|
||||
Map<String, String> headerNamesMap = new LinkedHashMap<>();
|
||||
Map<String, Integer> headerWidthsMap = new LinkedHashMap<>();
|
||||
List<String> dataFieldNamesList = new ArrayList<>();
|
||||
|
||||
// 모든 필드를 탐색하여 ExcelColumn 어노테이션 정보 수집
|
||||
for (Field field : getAllFields(clazz)) {
|
||||
if (field.isAnnotationPresent(ExcelColumn.class)) {
|
||||
ExcelColumn columnAnnotation = field.getAnnotation(ExcelColumn.class);
|
||||
String fieldName = field.getName();
|
||||
|
||||
// 헤더명, 컬럼 너비, 필드명 저장
|
||||
headerNamesMap.put(fieldName, Objects.requireNonNull(columnAnnotation).headerName());
|
||||
headerWidthsMap.put(fieldName, columnAnnotation.headerWidth());
|
||||
dataFieldNamesList.add(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
// ExcelColumn 어노테이션이 하나도 없으면 예외 발생
|
||||
validateHasExcelColumns(clazz, headerNamesMap);
|
||||
|
||||
// 메타데이터 객체 생성 및 반환
|
||||
return new ExcelMetadata(
|
||||
headerNamesMap,
|
||||
headerWidthsMap,
|
||||
dataFieldNamesList,
|
||||
extractSheetName(clazz)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== private 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* ExcelColumn 어노테이션이 하나 이상 있는지 검증합니다.
|
||||
*
|
||||
* @param clazz 검증할 클래스
|
||||
* @param headerNamesMap 수집된 헤더명 맵
|
||||
* @throws RuntimeException ExcelColumn 어노테이션이 하나도 없는 경우
|
||||
*/
|
||||
private void validateHasExcelColumns(Class<?> clazz, Map<String, String> headerNamesMap) {
|
||||
if (headerNamesMap.isEmpty()) {
|
||||
throw new RuntimeException(
|
||||
String.format("클래스 %s에 @ExcelColumn 어노테이션이 하나도 없습니다.", clazz.getName())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스에서 시트명을 추출합니다.
|
||||
*
|
||||
* <p>{@link ExcelSheet} 어노테이션이 있으면 해당 시트명을 사용하고,
|
||||
* 없으면 기본값 "Sheet1"을 반환합니다.</p>
|
||||
*
|
||||
* @param clazz 시트명을 추출할 클래스
|
||||
* @return 시트명 (ExcelSheet 어노테이션의 name 또는 "Sheet1")
|
||||
*/
|
||||
private String extractSheetName(Class<?> clazz) {
|
||||
ExcelSheet annotation = getAnnotation(clazz, ExcelSheet.class);
|
||||
return annotation != null ? annotation.name() : "Sheet1";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelSheet {
|
||||
String name() default "";
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 엑셀 시트 데이터를 담는 DTO 클래스
|
||||
*
|
||||
* <p>이 클래스는 엑셀 파일 생성에 필요한 데이터와 메타정보를 캡슐화합니다.
|
||||
* 데이터 리스트, VO 클래스 타입, 제목(선택사항)을 포함합니다.</p>
|
||||
*
|
||||
* <p><b>주요 특징:</b></p>
|
||||
* <ul>
|
||||
* <li>불변(Immutable) 객체 - 모든 필드가 final</li>
|
||||
* <li>팩토리 메서드 패턴 - of() 메서드로 객체 생성</li>
|
||||
* <li>제목 행 지원 - 선택적으로 엑셀 상단에 제목 행 추가 가능</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>사용 예제 1: 제목 없는 엑셀 시트</b></p>
|
||||
* <pre>{@code
|
||||
* // VO 리스트 조회
|
||||
* List<UserVO> userList = userService.selectUserList();
|
||||
*
|
||||
* // ExcelSheetData 생성 (제목 없음)
|
||||
* ExcelSheetData sheetData = ExcelSheetData.of(userList, UserVO.class);
|
||||
*
|
||||
* // 엑셀 파일 생성
|
||||
* new SxssfExcelFile(sheetData, request, response, "사용자목록.xlsx");
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>사용 예제 2: 제목이 있는 엑셀 시트</b></p>
|
||||
* <pre>{@code
|
||||
* // VO 리스트 조회
|
||||
* List<UserVO> userList = userService.selectUserList();
|
||||
*
|
||||
* // ExcelSheetData 생성 (제목 포함)
|
||||
* ExcelSheetData sheetData = ExcelSheetData.of(
|
||||
* userList,
|
||||
* UserVO.class,
|
||||
* "2024년 1월 사용자 목록" // 제목 행에 표시될 텍스트
|
||||
* );
|
||||
*
|
||||
* // 엑셀 파일 생성 (첫 행에 제목이 표시됨)
|
||||
* new SxssfExcelFile(sheetData, request, response, "사용자목록.xlsx");
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>사용 예제 3: 다중 시트 엑셀</b></p>
|
||||
* <pre>{@code
|
||||
* // 시트 1: 사용자 목록
|
||||
* List<UserVO> userList = userService.selectUserList();
|
||||
* ExcelSheetData userSheet = ExcelSheetData.of(userList, UserVO.class, "사용자 목록");
|
||||
*
|
||||
* // 시트 2: 부서 목록
|
||||
* List<DeptVO> deptList = deptService.selectDeptList();
|
||||
* ExcelSheetData deptSheet = ExcelSheetData.of(deptList, DeptVO.class, "부서 목록");
|
||||
*
|
||||
* // 다중 시트 그룹 생성
|
||||
* ExcelSheetDataGroup dataGroup = ExcelSheetDataGroup.of(
|
||||
* List.of(userSheet, deptSheet)
|
||||
* );
|
||||
*
|
||||
* // 다중 시트 엑셀 파일 생성
|
||||
* new SxssfMultiSheetExcelFile(dataGroup, response);
|
||||
* }</pre>
|
||||
*
|
||||
* @see SxssfExcelFile
|
||||
* @see SxssfMultiSheetExcelFile
|
||||
* @see ExcelSheetDataGroup
|
||||
* @author eGovFrame
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class ExcelSheetData {
|
||||
|
||||
// ==================== 필드 ====================
|
||||
|
||||
/**
|
||||
* 엑셀에 출력할 데이터 리스트
|
||||
*
|
||||
* <p>각 요소는 {@link ExcelColumn} 어노테이션이 붙은 VO 객체여야 합니다.
|
||||
* 리스트의 각 객체가 엑셀의 한 행(row)으로 변환됩니다.</p>
|
||||
*/
|
||||
private final List<?> dataList;
|
||||
|
||||
/**
|
||||
* 데이터 리스트의 요소 타입 (VO 클래스)
|
||||
*
|
||||
* <p>이 클래스는 다음 어노테이션을 가져야 합니다:
|
||||
* <ul>
|
||||
* <li>{@link ExcelColumn} - 각 필드에 붙여서 헤더명과 컬럼 너비 지정</li>
|
||||
* <li>{@link ExcelSheet} - 클래스에 붙여서 시트명 지정 (선택사항)</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* <p><b>예제:</b></p>
|
||||
* <pre>{@code
|
||||
* @ExcelSheet(name = "사용자")
|
||||
* public class UserVO {
|
||||
* @ExcelColumn(headerName = "이름", headerWidth = 20)
|
||||
* private String userName;
|
||||
*
|
||||
* @ExcelColumn(headerName = "이메일")
|
||||
* private String email;
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
private final Class<?> type;
|
||||
|
||||
/**
|
||||
* 제목 행 텍스트 (선택사항)
|
||||
*
|
||||
* <p>이 값이 null이 아니고 빈 문자열이 아니면, 엑셀 파일의 첫 번째 행에
|
||||
* 제목이 표시됩니다. 제목은 모든 컬럼에 걸쳐 병합된 셀로 표시되며,
|
||||
* 굵은 22포인트 폰트로 렌더링됩니다.</p>
|
||||
*
|
||||
* <p>null이거나 빈 문자열이면 제목 행을 생성하지 않고,
|
||||
* 첫 번째 행부터 헤더가 시작됩니다.</p>
|
||||
*/
|
||||
private final String title;
|
||||
|
||||
// ==================== 팩토리 메서드 ====================
|
||||
|
||||
/**
|
||||
* 제목이 없는 ExcelSheetData 객체를 생성합니다.
|
||||
*
|
||||
* <p>이 메서드로 생성한 객체는 제목 행 없이 헤더와 데이터만 출력됩니다.</p>
|
||||
*
|
||||
* <p><b>생성되는 엑셀 구조:</b></p>
|
||||
* <pre>
|
||||
* Row 0: [헤더1] [헤더2] [헤더3] ...
|
||||
* Row 1: [데이터] [데이터] [데이터] ...
|
||||
* Row 2: [데이터] [데이터] [데이터] ...
|
||||
* ...
|
||||
* </pre>
|
||||
*
|
||||
* @param dataList 엑셀에 출력할 데이터 리스트 (null이거나 비어있어도 됨)
|
||||
* @param type 데이터 리스트의 요소 타입 (VO 클래스, null이면 안됨)
|
||||
* @return 생성된 ExcelSheetData 객체
|
||||
*/
|
||||
public static ExcelSheetData of(List<?> dataList, Class<?> type) {
|
||||
return new ExcelSheetData(dataList, type, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목이 있는 ExcelSheetData 객체를 생성합니다.
|
||||
*
|
||||
* <p>이 메서드로 생성한 객체는 제목 행, 헤더 행, 데이터 행 순서로 출력됩니다.
|
||||
* 제목은 첫 번째 행에 모든 컬럼에 걸쳐 병합되어 표시됩니다.</p>
|
||||
*
|
||||
* <p><b>생성되는 엑셀 구조:</b></p>
|
||||
* <pre>
|
||||
* Row 0: [제목 텍스트 - 병합된 셀]
|
||||
* Row 1: [헤더1] [헤더2] [헤더3] ...
|
||||
* Row 2: [데이터] [데이터] [데이터] ...
|
||||
* Row 3: [데이터] [데이터] [데이터] ...
|
||||
* ...
|
||||
* </pre>
|
||||
*
|
||||
* @param dataList 엑셀에 출력할 데이터 리스트 (null이거나 비어있어도 됨)
|
||||
* @param type 데이터 리스트의 요소 타입 (VO 클래스, null이면 안됨)
|
||||
* @param title 제목 행에 표시될 텍스트 (null이면 제목 행을 생성하지 않음)
|
||||
* @return 생성된 ExcelSheetData 객체
|
||||
*/
|
||||
public static ExcelSheetData of(List<?> dataList, Class<?> type, String title) {
|
||||
return new ExcelSheetData(dataList, type, title);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class ExcelSheetDataGroup { // (2)
|
||||
private final List<ExcelSheetData> dataList;
|
||||
|
||||
private ExcelSheetDataGroup(List<ExcelSheetData> data) {
|
||||
validateEmpty(data);
|
||||
this.dataList = new ArrayList<>(data);
|
||||
}
|
||||
|
||||
public List<ExcelSheetData> getExcelSheetData() {
|
||||
return Collections.unmodifiableList(dataList);
|
||||
}
|
||||
|
||||
public static ExcelSheetDataGroup of(ExcelSheetData... data) {
|
||||
List<ExcelSheetData> list;
|
||||
if (data == null) {
|
||||
list = Collections.emptyList();
|
||||
} else {
|
||||
list = Arrays.asList(data);
|
||||
}
|
||||
return new ExcelSheetDataGroup(list);
|
||||
}
|
||||
|
||||
private void validateEmpty(List<ExcelSheetData> data) {
|
||||
if (data.isEmpty()) {
|
||||
throw new IllegalArgumentException("lists must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class OutputExcelFile {
|
||||
|
||||
public void serveFile(HttpServletRequest request, HttpServletResponse response, Path filePath) throws IOException {
|
||||
|
||||
String fileName = filePath.getFileName().toString();
|
||||
try {
|
||||
String browser = request.getHeader("User-Agent");
|
||||
String encodedFileName;
|
||||
if (browser.contains("MSIE") || browser.contains("Trident")) {
|
||||
// IE 브라우저 대응
|
||||
encodedFileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
|
||||
} else {
|
||||
// IE 외 브라우저 대응 (크롬, 파이어폭스 등)
|
||||
encodedFileName = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
// Content-Disposition 설정
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e.getMessage());
|
||||
}
|
||||
|
||||
try (InputStream is = Files.newInputStream(filePath);
|
||||
OutputStream os = response.getOutputStream()) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = is.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
os.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public void generateExcelFile(ExcelSheetData data, Path filePath) throws IOException {
|
||||
try (OutputStream os = Files.newOutputStream(filePath)) {
|
||||
new SxssfExcelFile(data, os, null); // 엑셀 파일 생성 및 저장
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class SuperClassReflectionUtils {
|
||||
|
||||
private SuperClassReflectionUtils() {
|
||||
}
|
||||
|
||||
public static List<Field> getAllFields(Class<?> clazz) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, true)) {
|
||||
fields.addAll(Arrays.asList(clazzInClasses.getDeclaredFields()));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
public static List<Field> getAllFieldsWithExcelColumn(Class<?> clazz) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
for (Field field : getAllFields(clazz)) {
|
||||
if (field.isAnnotationPresent(ExcelColumn.class)) {
|
||||
fields.add(field);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
|
||||
public static Annotation getAnnotation(Class<?> clazz, Class<? extends Annotation> targetAnnotation) {
|
||||
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, false)) {
|
||||
if (clazzInClasses.isAnnotationPresent(targetAnnotation)) {
|
||||
return clazzInClasses.getAnnotation(targetAnnotation);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Field getField(Class<?> clazz, String name) throws Exception {
|
||||
for (Class<?> clazzInClasses : getAllClassesIncludingSuperClasses(clazz, false)) {
|
||||
for (Field field : clazzInClasses.getDeclaredFields()) {
|
||||
if (field.getName().equals(name)) {
|
||||
return clazzInClasses.getDeclaredField(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException();
|
||||
}
|
||||
|
||||
private static List<Class<?>> getAllClassesIncludingSuperClasses(Class<?> clazz, boolean fromSuper) {
|
||||
List<Class<?>> classes = new ArrayList<>();
|
||||
while (clazz != null) {
|
||||
classes.add(clazz);
|
||||
clazz = clazz.getSuperclass();
|
||||
}
|
||||
if (fromSuper) {
|
||||
Collections.reverse(classes);
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 단일 시트 엑셀 파일 생성 클래스
|
||||
*
|
||||
* <p>이 클래스는 {@link BaseSxssfExcelFile}을 상속하여 단일 시트 엑셀 파일을 생성합니다.
|
||||
* HTTP 응답으로 엑셀 파일을 다운로드하거나, OutputStream으로 파일을 출력할 수 있습니다.</p>
|
||||
*
|
||||
* <p><b>사용 예제 1: HTTP 응답으로 엑셀 다운로드</b></p>
|
||||
* <pre>{@code
|
||||
* @GetMapping("/download.xlsx")
|
||||
* public void downloadExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
* List<UserVO> dataList = userService.selectUserList();
|
||||
* ExcelSheetData sheetData = ExcelSheetData.of(dataList, UserVO.class, "사용자 목록");
|
||||
* new SxssfExcelFile(sheetData, request, response, "사용자목록.xlsx");
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>사용 예제 2: 암호화된 엑셀 파일 다운로드</b></p>
|
||||
* <pre>{@code
|
||||
* @GetMapping("/download-encrypted.xlsx")
|
||||
* public void downloadEncryptedExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
* List<UserVO> dataList = userService.selectUserList();
|
||||
* ExcelSheetData sheetData = ExcelSheetData.of(dataList, UserVO.class, "사용자 목록");
|
||||
* new SxssfExcelFile(sheetData, request, response, "사용자목록.xlsx", "password123");
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>사용 예제 3: OutputStream으로 출력</b></p>
|
||||
* <pre>{@code
|
||||
* try (FileOutputStream fos = new FileOutputStream("output.xlsx")) {
|
||||
* List<UserVO> dataList = userService.selectUserList();
|
||||
* ExcelSheetData sheetData = ExcelSheetData.of(dataList, UserVO.class);
|
||||
* new SxssfExcelFile(sheetData, fos, null);
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>VO 클래스 예제:</b></p>
|
||||
* <pre>{@code
|
||||
* @ExcelSheet(name = "사용자")
|
||||
* public class UserVO {
|
||||
* @ExcelColumn(headerName = "이름", headerWidth = 20)
|
||||
* private String userName;
|
||||
*
|
||||
* @ExcelColumn(headerName = "이메일", headerWidth = 30)
|
||||
* private String email;
|
||||
*
|
||||
* @ExcelColumn(headerName = "전화번호")
|
||||
* private String phone;
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @see BaseSxssfExcelFile
|
||||
* @see ExcelSheetData
|
||||
* @see ExcelColumn
|
||||
* @author eGovFrame
|
||||
*/
|
||||
public class SxssfExcelFile extends BaseSxssfExcelFile {
|
||||
|
||||
// ==================== 생성자 (HTTP 응답 다운로드) ====================
|
||||
|
||||
/**
|
||||
* HTTP 응답으로 엑셀 파일을 다운로드합니다 (암호화 없음).
|
||||
*
|
||||
* <p>이 생성자는 암호화 없이 엑셀 파일을 HTTP 응답으로 다운로드합니다.
|
||||
* 파일명은 브라우저 종류에 따라 적절하게 인코딩됩니다.</p>
|
||||
*
|
||||
* @param data 엑셀 시트 데이터 (데이터 리스트, 타입, 제목)
|
||||
* @param request HTTP 요청 객체 (브라우저 정보 확인용)
|
||||
* @param response HTTP 응답 객체
|
||||
* @param fileName 다운로드될 파일명 (확장자 포함, 예: "사용자목록.xlsx")
|
||||
* @throws RuntimeException 파일 생성 또는 출력 중 오류 발생 시
|
||||
*/
|
||||
public SxssfExcelFile(ExcelSheetData data, HttpServletRequest request, HttpServletResponse response,
|
||||
String fileName) {
|
||||
this(data, request, response, fileName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 응답으로 엑셀 파일을 다운로드합니다 (암호화 지원).
|
||||
*
|
||||
* <p>이 생성자는 선택적으로 암호화하여 엑셀 파일을 HTTP 응답으로 다운로드합니다.
|
||||
* 파일명은 브라우저 종류에 따라 적절하게 인코딩됩니다.</p>
|
||||
*
|
||||
* @param data 엑셀 시트 데이터 (데이터 리스트, 타입, 제목)
|
||||
* @param request HTTP 요청 객체 (브라우저 정보 확인용)
|
||||
* @param response HTTP 응답 객체
|
||||
* @param fileName 다운로드될 파일명 (확장자 포함, 예: "사용자목록.xlsx")
|
||||
* @param password 암호화 비밀번호 (null이면 암호화하지 않음)
|
||||
* @throws RuntimeException 파일 생성 또는 출력 중 오류 발생 시
|
||||
*/
|
||||
public SxssfExcelFile(ExcelSheetData data, HttpServletRequest request, HttpServletResponse response,
|
||||
String fileName, @Nullable String password) {
|
||||
try {
|
||||
setDownloadHeaders(request, response, fileName);
|
||||
ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType());
|
||||
renderSheetContent(data, metadata);
|
||||
writeWithEncryption(response.getOutputStream(), password);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("HTTP 응답으로 엑셀 파일 출력 중 오류가 발생했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 생성자 (OutputStream 출력) ====================
|
||||
|
||||
/**
|
||||
* OutputStream으로 엑셀 파일을 출력합니다.
|
||||
*
|
||||
* <p>이 생성자는 선택적으로 암호화하여 엑셀 파일을 OutputStream으로 출력합니다.
|
||||
* 파일 시스템에 직접 저장하거나 다른 스트림 처리 용도로 사용할 수 있습니다.</p>
|
||||
*
|
||||
* @param data 엑셀 시트 데이터 (데이터 리스트, 타입, 제목)
|
||||
* @param outputStream 출력 스트림
|
||||
* @param password 암호화 비밀번호 (null이면 암호화하지 않음)
|
||||
* @throws RuntimeException 파일 생성 또는 출력 중 오류 발생 시
|
||||
*/
|
||||
public SxssfExcelFile(ExcelSheetData data, OutputStream outputStream, @Nullable String password) {
|
||||
ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType());
|
||||
renderSheetContent(data, metadata);
|
||||
writeWithEncryption(outputStream, password);
|
||||
}
|
||||
|
||||
// ==================== private 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* HTTP 응답 헤더를 설정하여 엑셀 파일 다운로드를 준비합니다.
|
||||
*
|
||||
* <p>브라우저 종류에 따라 파일명을 적절하게 인코딩하여 Content-Disposition 헤더를 설정합니다:
|
||||
* <ul>
|
||||
* <li>IE (MSIE/Trident): UTF-8 URL 인코딩 (+ 공백 처리)</li>
|
||||
* <li>기타 브라우저: UTF-8 → ISO-8859-1 변환</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*
|
||||
* @param request HTTP 요청 객체 (User-Agent 헤더 확인용)
|
||||
* @param response HTTP 응답 객체
|
||||
* @param fileName 다운로드될 파일명
|
||||
* @throws RuntimeException 인코딩 오류 발생 시
|
||||
*/
|
||||
private void setDownloadHeaders(HttpServletRequest request, HttpServletResponse response, String fileName) {
|
||||
try {
|
||||
String browser = request.getHeader("User-Agent");
|
||||
String encodedFileName = encodeFileName(fileName, browser);
|
||||
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException("파일명 인코딩 중 오류가 발생했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에 맞게 파일명을 인코딩합니다.
|
||||
*
|
||||
* @param fileName 원본 파일명
|
||||
* @param userAgent User-Agent 헤더 값
|
||||
* @return 인코딩된 파일명
|
||||
* @throws UnsupportedEncodingException 인코딩 실패 시
|
||||
*/
|
||||
private String encodeFileName(String fileName, String userAgent) throws UnsupportedEncodingException {
|
||||
if (isInternetExplorer(userAgent)) {
|
||||
// IE: UTF-8 URL 인코딩 (+ -> 공백 처리)
|
||||
return URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
|
||||
} else {
|
||||
// Chrome, Firefox 등: UTF-8 바이트를 ISO-8859-1로 변환
|
||||
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-Agent에서 Internet Explorer 브라우저인지 확인합니다.
|
||||
*
|
||||
* @param userAgent User-Agent 헤더 값
|
||||
* @return IE인 경우 true, 아니면 false
|
||||
*/
|
||||
private boolean isInternetExplorer(String userAgent) {
|
||||
return userAgent != null && (userAgent.contains("MSIE") || userAgent.contains("Trident"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package egovframework.util.excel;
|
||||
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 다중 시트 엑셀 파일 생성 클래스
|
||||
*
|
||||
* <p>이 클래스는 {@link BaseSxssfExcelFile}을 상속하여 여러 시트를 포함하는 엑셀 파일을 생성합니다.
|
||||
* 하나의 엑셀 파일에 여러 종류의 데이터를 각각 다른 시트로 구성할 수 있습니다.</p>
|
||||
*
|
||||
* <p><b>사용 예제 1: 다중 시트 엑셀 다운로드</b></p>
|
||||
* <pre>{@code
|
||||
* @GetMapping("/download-multi.xlsx")
|
||||
* public void downloadMultiSheetExcel(HttpServletResponse response) throws IOException {
|
||||
* // 시트 1: 사용자 목록
|
||||
* List<UserVO> userList = userService.selectUserList();
|
||||
* ExcelSheetData userSheet = ExcelSheetData.of(userList, UserVO.class, "사용자 목록");
|
||||
*
|
||||
* // 시트 2: 부서 목록
|
||||
* List<DeptVO> deptList = deptService.selectDeptList();
|
||||
* ExcelSheetData deptSheet = ExcelSheetData.of(deptList, DeptVO.class, "부서 목록");
|
||||
*
|
||||
* // 다중 시트 그룹 생성
|
||||
* ExcelSheetDataGroup dataGroup = ExcelSheetDataGroup.of(
|
||||
* List.of(userSheet, deptSheet)
|
||||
* );
|
||||
*
|
||||
* // 파일 다운로드
|
||||
* response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
* response.setHeader("Content-Disposition", "attachment; filename=\"통합리포트.xlsx\"");
|
||||
* new SxssfMultiSheetExcelFile(dataGroup, response);
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>사용 예제 2: 암호화된 다중 시트 엑셀 다운로드</b></p>
|
||||
* <pre>{@code
|
||||
* @GetMapping("/download-multi-encrypted.xlsx")
|
||||
* public void downloadEncryptedMultiSheetExcel(HttpServletResponse response) throws IOException {
|
||||
* ExcelSheetDataGroup dataGroup = createMultiSheetData();
|
||||
*
|
||||
* response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
* response.setHeader("Content-Disposition", "attachment; filename=\"통합리포트.xlsx\"");
|
||||
* new SxssfMultiSheetExcelFile(dataGroup, response, "password123");
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p><b>주의사항:</b></p>
|
||||
* <ul>
|
||||
* <li>각 시트는 서로 다른 VO 클래스를 사용할 수 있습니다.</li>
|
||||
* <li>각 VO 클래스마다 {@link ExcelSheet} 어노테이션으로 시트명을 지정해야 합니다.</li>
|
||||
* <li>시트가 많을수록 메모리 사용량이 증가할 수 있습니다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see BaseSxssfExcelFile
|
||||
* @see ExcelSheetDataGroup
|
||||
* @see ExcelSheetData
|
||||
* @author eGovFrame
|
||||
*/
|
||||
public class SxssfMultiSheetExcelFile extends BaseSxssfExcelFile {
|
||||
|
||||
// ==================== 생성자 ====================
|
||||
|
||||
/**
|
||||
* HTTP 응답으로 다중 시트 엑셀 파일을 다운로드합니다 (암호화 없음).
|
||||
*
|
||||
* <p>이 생성자는 암호화 없이 다중 시트 엑셀 파일을 HTTP 응답으로 다운로드합니다.</p>
|
||||
*
|
||||
* @param dataGroup 다중 시트 데이터 그룹
|
||||
* @param response HTTP 응답 객체
|
||||
* @throws IOException 파일 생성 또는 출력 중 오류 발생 시
|
||||
*/
|
||||
public SxssfMultiSheetExcelFile(ExcelSheetDataGroup dataGroup, HttpServletResponse response)
|
||||
throws IOException {
|
||||
this(dataGroup, response, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 응답으로 다중 시트 엑셀 파일을 다운로드합니다 (암호화 지원).
|
||||
*
|
||||
* <p>이 생성자는 선택적으로 암호화하여 다중 시트 엑셀 파일을 HTTP 응답으로 다운로드합니다.</p>
|
||||
*
|
||||
* @param dataGroup 다중 시트 데이터 그룹
|
||||
* @param response HTTP 응답 객체
|
||||
* @param password 암호화 비밀번호 (null이면 암호화하지 않음)
|
||||
* @throws IOException 파일 생성 또는 출력 중 오류 발생 시
|
||||
*/
|
||||
public SxssfMultiSheetExcelFile(ExcelSheetDataGroup dataGroup, HttpServletResponse response,
|
||||
@Nullable String password) throws IOException {
|
||||
renderAllSheets(dataGroup);
|
||||
writeWithEncryption(response.getOutputStream(), password);
|
||||
}
|
||||
|
||||
// ==================== private 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* 모든 시트를 렌더링합니다.
|
||||
*
|
||||
* <p>데이터 그룹에 포함된 각 시트 데이터를 순회하면서 개별 시트를 생성합니다.
|
||||
* 각 시트마다 제목, 헤더, 데이터 행을 렌더링하고 새로운 시트로 이동합니다.</p>
|
||||
*
|
||||
* @param dataGroup 다중 시트 데이터 그룹
|
||||
*/
|
||||
private void renderAllSheets(ExcelSheetDataGroup dataGroup) {
|
||||
for (ExcelSheetData data : dataGroup.getExcelSheetData()) {
|
||||
// 현재 시트 참조 초기화 (새 시트 생성 준비)
|
||||
resetSheetContext();
|
||||
|
||||
// 메타데이터 생성
|
||||
ExcelMetadata metadata = ExcelMetadataFactory.getInstance().createMetadata(data.getType());
|
||||
|
||||
// 시트 내용 렌더링 (제목, 헤더, 데이터)
|
||||
renderSheetContent(data, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시트 컨텍스트를 초기화합니다.
|
||||
*
|
||||
* <p>새로운 시트 생성을 위해 현재 시트 참조와 제목 행 오프셋을 초기화합니다.
|
||||
* 이렇게 하면 renderSheetContent 메서드가 새로운 시트를 생성하게 됩니다.</p>
|
||||
*/
|
||||
private void resetSheetContext() {
|
||||
sheet = null;
|
||||
titleRowOffset = 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package go.kr.project;
|
||||
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
|
||||
public class ServletInitializer extends SpringBootServletInitializer {
|
||||
|
||||
@Override
|
||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||
return application.sources(VipsApplication.class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package go.kr.project;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
@Slf4j
|
||||
@ServletComponentScan
|
||||
@SpringBootApplication
|
||||
@ComponentScan(basePackages = {"go.kr.project", "egovframework"})
|
||||
public class VipsApplication extends SpringBootServletInitializer {
|
||||
|
||||
@Override
|
||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||
return application.sources(VipsApplication.class);
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VipsApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import go.kr.project.common.service.AddressService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 주소 검색 관련 컨트롤러
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/common/address")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "주소 검색", description = "외부 주소 API 연동 관련")
|
||||
public class AddressController {
|
||||
|
||||
private final AddressService addressService;
|
||||
|
||||
/**
|
||||
* 외부 주소 API를 호출하여 검색 결과를 반환합니다.
|
||||
* @param keyword 검색어
|
||||
* @param currentPage 현재 페이지
|
||||
* @param countPerPage 페이지당 결과 수
|
||||
* @return API 응답 결과 (JSON 문자열)
|
||||
*/
|
||||
@GetMapping(value = "/search.ajax", produces = "application/json; charset=UTF-8")
|
||||
@Operation(summary = "주소 검색", description = "외부 도로명주소 API를 호출하여 결과를 반환합니다.")
|
||||
public String searchAddress(
|
||||
@Parameter(description = "검색어") @RequestParam("keyword") String keyword,
|
||||
@Parameter(description = "현재 페이지") @RequestParam("currentPage") int currentPage,
|
||||
@Parameter(description = "페이지당 결과 수") @RequestParam("countPerPage") int countPerPage) {
|
||||
|
||||
return addressService.searchAddress(keyword, currentPage, countPerPage);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import egovframework.util.ApiResponseUtil;
|
||||
import go.kr.project.common.model.CmmnCodeSearchVO;
|
||||
import go.kr.project.common.service.CommonCodeService;
|
||||
import go.kr.project.system.code.model.CodeDetailVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.controller
|
||||
* fileName : CommonCodeController
|
||||
* author : 개발자
|
||||
* date : 2025-05-10
|
||||
* description : 공통 코드 관련 요청을 처리하는 컨트롤러
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-10 개발자 최초 생성
|
||||
*/
|
||||
@RequestMapping("/common/code")
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "공통 코드", description = "공통 코드 관련 API")
|
||||
public class CommonCodeController {
|
||||
|
||||
private final CommonCodeService commonCodeService;
|
||||
|
||||
/**
|
||||
* 특정 코드 그룹에 속한 코드 상세 목록을 조회하는 AJAX 메소드
|
||||
*
|
||||
* @param cdGroupId 코드 그룹 ID
|
||||
* @return 코드 상세 목록과 성공 상태를 담은 ResponseEntity 객체
|
||||
* @throws Exception 조회 중 발생할 수 있는 예외
|
||||
*/
|
||||
@Operation(summary = "코드 상세 목록 조회", description = "특정 코드 그룹에 속한 코드 상세 목록을 조회합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "코드 상세 목록 조회 성공"),
|
||||
@ApiResponse(responseCode = "400", description = "코드 상세 목록 조회 실패"),
|
||||
@ApiResponse(description = "오류로 인한 실패")
|
||||
})
|
||||
@GetMapping("/detail/listByGroupId.ajax")
|
||||
public ResponseEntity<?> getCodeDetailListByGroupIdAjax(@RequestParam String cdGroupId) {
|
||||
List<CodeDetailVO> codeDetailList = commonCodeService.selectCodeDetailListByGroupId(cdGroupId);
|
||||
return ApiResponseUtil.success(codeDetailList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 조건에 맞는 코드 상세 목록을 조회하는 AJAX 메소드
|
||||
*
|
||||
* @param searchVO 코드 상세 검색을 위한 검색 조건을 담은 VO 객체
|
||||
* @return 코드 상세 목록과 성공 상태를 담은 ResponseEntity 객체
|
||||
* @throws Exception 조회 중 발생할 수 있는 예외
|
||||
*/
|
||||
@Operation(summary = "코드 상세 목록 조회", description = "검색 조건을 담은 VO 객체를 통해 코드 상세 목록을 조회합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "코드 상세 목록 조회 성공"),
|
||||
@ApiResponse(responseCode = "400", description = "코드 상세 목록 조회 실패"),
|
||||
@ApiResponse(description = "오류로 인한 실패")
|
||||
})
|
||||
@GetMapping("/detail/list.ajax")
|
||||
public ResponseEntity<?> getCodeDetailListAjax(@ModelAttribute CmmnCodeSearchVO searchVO) {
|
||||
List<CodeDetailVO> codeDetailList = commonCodeService.selectCodeDetailList(searchVO);
|
||||
return ApiResponseUtil.success(codeDetailList);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import go.kr.project.common.service.CommonHeaderService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RequestMapping("/common/header")
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "공통 헤더", description = "공통 헤더 관련 API")
|
||||
public class CommonHeaderController {
|
||||
|
||||
private final CommonHeaderService commonHeaderService;
|
||||
|
||||
/**
|
||||
* 사이드바 상태를 세션에 저장하는 API
|
||||
*
|
||||
* @param state 사이드바 상태 (collapsed, expanded, 또는 빈 문자열)
|
||||
* @param session HTTP 세션 객체
|
||||
* @return 응답 데이터
|
||||
*/
|
||||
@PostMapping("/sidebar/state.ajax")
|
||||
@Operation(summary = "사이드바 상태 저장", description = "사이드바 상태를 세션에 저장합니다.")
|
||||
public ResponseEntity<Map<String, Object>> saveSidebarState(
|
||||
@Parameter(description = "사이드바 상태 (sidebar-collapse, 또는 빈 문자열)")
|
||||
@RequestParam(value = "state", required = false, defaultValue = "") String state,
|
||||
HttpSession session) {
|
||||
|
||||
// 세션에 사이드바 상태 저장
|
||||
session.setAttribute("sidebarState", state);
|
||||
|
||||
log.debug("사이드바 상태 저장: {}", state);
|
||||
|
||||
// 응답 데이터 구성
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("result", true);
|
||||
response.put("message", "사이드바 상태가 저장되었습니다.");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import egovframework.configProperties.LoginProperties;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* packageName : egovframework.config
|
||||
* fileName : ConfigController
|
||||
* author : 박성영
|
||||
* date : 25. 5. 19.
|
||||
* description : 설정값을 자바스크립트로 전달하는 컨트롤러
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 19. 박성영 최초 생성
|
||||
*/
|
||||
@RequestMapping("/common/config")
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "시스템 설정", description = "시스템 설정 관련 API")
|
||||
public class ConfigController {
|
||||
|
||||
private final LoginProperties loginProperties;
|
||||
|
||||
/**
|
||||
* 로그인 URL 설정값을 자바스크립트로 전달
|
||||
*
|
||||
* @return 자바스크립트 코드
|
||||
*/
|
||||
@Operation(summary = "로그인 URL 설정값 자바스크립트 반환", description = "로그인 URL 설정값을 자바스크립트 코드로 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "자바스크립트 반환 성공")
|
||||
})
|
||||
@GetMapping(value = "/login-url.do", produces = "application/javascript")
|
||||
public ResponseEntity<String> getLoginUrlJs() {
|
||||
String js = "var loginUrl = '" + loginProperties.getUrl() + "';";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/javascript"))
|
||||
.body(js);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨택스트 패스 설정값을 자바스크립트로 전달
|
||||
* .do, .ajax 등 URL 호출 시 컨택스트 패스를 적용하기 위해 사용
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
* @return 자바스크립트 코드
|
||||
*/
|
||||
@Operation(summary = "컨택스트 패스 설정값 자바스크립트 반환", description = "컨택스트 패스 설정값을 자바스크립트 코드로 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "자바스크립트 반환 성공")
|
||||
})
|
||||
@GetMapping(value = "/context-path.do", produces = "application/javascript")
|
||||
public ResponseEntity<String> getContextPathJs(HttpServletRequest request) {
|
||||
// 컨택스트 패스를 가져와서 JavaScript 변수로 설정
|
||||
String contextPath = request.getContextPath();
|
||||
String js = "var contextPath = '" + contextPath + "';";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType("application/javascript"))
|
||||
.body(js);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import egovframework.util.ApiResponseUtil;
|
||||
import egovframework.util.SessionUtil;
|
||||
import go.kr.project.common.model.HtmlEditorFileVO;
|
||||
import go.kr.project.common.service.HtmlEditorService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.controller
|
||||
* fileName : HtmlEditorController
|
||||
* author : 개발자
|
||||
* date : 2025-05-23
|
||||
* description : HTML 에디터 컨트롤러
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-23 개발자 최초 생성
|
||||
*/
|
||||
@RequestMapping("/common/htmlEditor")
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "HTML 에디터", description = "HTML 에디터 관련 API")
|
||||
public class HtmlEditorController {
|
||||
|
||||
private final HtmlEditorService htmlEditorService;
|
||||
|
||||
/**
|
||||
* HTML 에디터에서 이미지 업로드 처리
|
||||
*
|
||||
* @param image 업로드할 이미지 파일
|
||||
* @param moduleId 모듈 ID (선택적)
|
||||
* @param request HTTP 요청 객체
|
||||
* @return 업로드된 이미지 URL과 성공 상태를 담은 ResponseEntity 객체
|
||||
*/
|
||||
@Operation(summary = "에디터 이미지 업로드", description = "HTML 에디터에서 이미지를 업로드합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "이미지 업로드 성공"),
|
||||
@ApiResponse(responseCode = "400", description = "이미지 업로드 실패"),
|
||||
@ApiResponse(description = "오류로 인한 실패")
|
||||
})
|
||||
@PostMapping("/uploadImage.ajax")
|
||||
public ResponseEntity<?> uploadImage(
|
||||
@RequestParam("image") MultipartFile image,
|
||||
@RequestParam(value = "moduleId", required = false) String moduleId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
// 이미지 파일 검증
|
||||
if (image.isEmpty()) {
|
||||
return ApiResponseUtil.error("이미지 파일이 없습니다.");
|
||||
}
|
||||
|
||||
// 이미지 파일 확장자 검증
|
||||
String originalFilename = image.getOriginalFilename();
|
||||
String fileExt = Objects.requireNonNull(originalFilename).substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
|
||||
|
||||
if (!fileExt.matches("jpg|jpeg|png|gif")) {
|
||||
return ApiResponseUtil.error("허용되지 않은 이미지 형식입니다. (jpg, jpeg, png, gif만 가능)");
|
||||
}
|
||||
|
||||
// 파일 업로드 처리
|
||||
HtmlEditorFileVO uploadedFile = htmlEditorService.uploadHtmlEditorFile(
|
||||
image, moduleId, "image", SessionUtil.getUserId());
|
||||
|
||||
// 이미지 URL 생성
|
||||
String imageUrl = request.getContextPath() + "/common/htmlEditor/download.do?fileId=" + uploadedFile.getFileId();
|
||||
|
||||
// 응답 데이터 생성
|
||||
HashMap<String, Object> data = new HashMap<>();
|
||||
data.put("url", imageUrl);
|
||||
data.put("alt", uploadedFile.getOriginalFileNm());
|
||||
data.put("fileId", uploadedFile.getFileId());
|
||||
|
||||
return ApiResponseUtil.success(data);
|
||||
} catch (Exception e) {
|
||||
log.error("이미지 업로드 중 오류 발생", e);
|
||||
return ApiResponseUtil.error("이미지 업로드 중 오류가 발생했습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 다운로드합니다.
|
||||
*
|
||||
* @param fileId 다운로드할 파일의 ID
|
||||
* @param request HTTP 요청 객체
|
||||
* @param response HTTP 응답 객체
|
||||
*/
|
||||
@Operation(summary = "파일 다운로드", description = "HTML 에디터에서 사용된 파일을 다운로드합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "파일 다운로드 성공"),
|
||||
@ApiResponse(responseCode = "400", description = "파일 다운로드 실패"),
|
||||
@ApiResponse(description = "오류로 인한 실패")
|
||||
})
|
||||
@GetMapping("/download.do")
|
||||
public void downloadFile(
|
||||
@RequestParam String fileId,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
// 파일 다운로드 처리 - 서비스 계층으로 위임
|
||||
htmlEditorService.downloadHtmlEditorFile(fileId, request, response);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package go.kr.project.common.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.controller
|
||||
* fileName : SearchAddressController
|
||||
* author : 박성영
|
||||
* date : 25. 5. 13.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 13. 박성영 최초 생성
|
||||
*/
|
||||
@RequestMapping("/common/address")
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "주소 검색", description = "주소 검색 관련 API")
|
||||
public class SearchAddressController {
|
||||
|
||||
@Operation(summary = "주소 검색 팝업", description = "주소 검색 관련 요청을 처리 페이지를 조회합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "주소 검색 팝업 조회 성공")
|
||||
})
|
||||
@RequestMapping(value = "/search.do", method={RequestMethod.GET, RequestMethod.POST})
|
||||
public String addressSearchPage() {
|
||||
return "common/address/searchAddress";
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package go.kr.project.common.error;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.error
|
||||
* fileName : ErrorController
|
||||
* author : 박성영
|
||||
* date : 25. 6. 5.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 6. 5. 박성영 최초 생성
|
||||
*/
|
||||
@Controller
|
||||
@Tag(name = "에러 페이지", description = "에러 페이지 관련 API")
|
||||
public class ErrorController {
|
||||
@Operation(summary = "404 에러 페이지", description = "404 에러 페이지를 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "404 에러 페이지 반환 성공")
|
||||
})
|
||||
@GetMapping("/error/404")
|
||||
public String handleError404() {
|
||||
return "error/404";
|
||||
}
|
||||
|
||||
@Operation(summary = "500 에러 페이지", description = "500 에러 페이지를 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "500 에러 페이지 반환 성공")
|
||||
})
|
||||
@GetMapping("/error/500")
|
||||
public String handleError500() {
|
||||
return "error/500"; //
|
||||
}
|
||||
|
||||
@Operation(summary = "일반 에러 페이지", description = "일반 에러 페이지를 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "일반 에러 페이지 반환 성공")
|
||||
})
|
||||
@GetMapping("/error/error")
|
||||
public String handleError() {
|
||||
return "error/error";
|
||||
}
|
||||
|
||||
@Operation(summary = "eGov 에러 페이지", description = "eGov 에러 페이지를 반환합니다.")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "eGov 에러 페이지 반환 성공")
|
||||
})
|
||||
@GetMapping("/error/egovError")
|
||||
public String handleEgovError() {
|
||||
return "error/egovError";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package go.kr.project.common.mapper;
|
||||
|
||||
import go.kr.project.common.model.CmmnCodeSearchVO;
|
||||
import go.kr.project.system.code.model.CodeDetailVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.mapper
|
||||
* fileName : CommonCodeMapper
|
||||
* author : 개발자
|
||||
* date : 2025-05-10
|
||||
* description : 공통 코드 관련 데이터 접근을 담당하는 매퍼 인터페이스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-10 개발자 최초 생성
|
||||
*/
|
||||
@Mapper
|
||||
public interface CommonCodeMapper {
|
||||
|
||||
/**
|
||||
* 특정 코드 그룹에 속한 코드 상세 목록을 조회합니다.
|
||||
*
|
||||
* @param cdGroupId 코드 그룹 ID
|
||||
* @return 코드 상세 목록
|
||||
*/
|
||||
List<CodeDetailVO> selectCodeDetailListByGroupId(String cdGroupId);
|
||||
|
||||
|
||||
/**
|
||||
* 검색 조건에 맞는 코드 상세 목록을 조회합니다.
|
||||
*
|
||||
* @param searchVO 코드 상세 검색을 위한 검색 조건을 담은 VO 객체
|
||||
* @return 검색 조건에 맞는 코드 상세 목록
|
||||
*/
|
||||
List<CodeDetailVO> selectCodeDetailList(CmmnCodeSearchVO searchVO);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package go.kr.project.common.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.mapper
|
||||
* fileName : CommonCodeMapper
|
||||
* author : 개발자
|
||||
* date : 2025-05-10
|
||||
* description : 공통 코드 관련 데이터 접근을 담당하는 매퍼 인터페이스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-10 개발자 최초 생성
|
||||
*/
|
||||
@Mapper
|
||||
public interface CommonHeaderMapper {
|
||||
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package go.kr.project.common.mapper;
|
||||
|
||||
import go.kr.project.common.model.HtmlEditorFileVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.mapper
|
||||
* fileName : HtmlEditorMapper
|
||||
* author : 개발자
|
||||
* date : 2025-05-23
|
||||
* description : HTML 에디터 Mapper 인터페이스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-23 개발자 최초 생성
|
||||
*/
|
||||
@Mapper
|
||||
public interface HtmlEditorMapper {
|
||||
|
||||
/**
|
||||
* 파일 정보를 조회합니다.
|
||||
*
|
||||
* @param fileId 파일 ID
|
||||
* @return 파일 정보
|
||||
*/
|
||||
HtmlEditorFileVO selectHtmlEditorFile(String fileId);
|
||||
|
||||
/**
|
||||
* 모듈에 첨부된 파일 목록을 조회합니다.
|
||||
*
|
||||
* @param moduleId 모듈 ID
|
||||
* @return 파일 목록
|
||||
*/
|
||||
List<HtmlEditorFileVO> selectHtmlEditorFileListByModuleId(String moduleId);
|
||||
|
||||
/**
|
||||
* 파일 목록을 조회합니다.
|
||||
*
|
||||
* @param vo 검색 조건을 담은 VO 객체
|
||||
* @return 파일 목록
|
||||
*/
|
||||
List<HtmlEditorFileVO> selectHtmlEditorFileList(HtmlEditorFileVO vo);
|
||||
|
||||
/**
|
||||
* 파일 목록의 총 개수를 조회합니다.
|
||||
*
|
||||
* @param vo 검색 조건을 담은 VO 객체
|
||||
* @return 파일 목록의 총 개수
|
||||
*/
|
||||
int selectHtmlEditorFileListTotalCount(HtmlEditorFileVO vo);
|
||||
|
||||
/**
|
||||
* 파일 ID를 생성합니다.
|
||||
*
|
||||
* @return HEDF00000001 형태의 파일 ID
|
||||
*/
|
||||
String generateFileId();
|
||||
|
||||
/**
|
||||
* 파일 정보를 등록합니다.
|
||||
*
|
||||
* @param vo 등록할 파일 정보를 담은 VO 객체
|
||||
* @return 등록된 행의 수
|
||||
*/
|
||||
int insertHtmlEditorFile(HtmlEditorFileVO vo);
|
||||
|
||||
/**
|
||||
* 파일 정보를 수정합니다.
|
||||
*
|
||||
* @param vo 수정할 파일 정보를 담은 VO 객체
|
||||
* @return 수정된 행의 수
|
||||
*/
|
||||
int updateHtmlEditorFile(HtmlEditorFileVO vo);
|
||||
|
||||
/**
|
||||
* 파일 정보를 삭제합니다.
|
||||
*
|
||||
* @param fileId 파일 ID
|
||||
* @return 삭제된 행의 수
|
||||
*/
|
||||
int deleteHtmlEditorFile(String fileId);
|
||||
|
||||
/**
|
||||
* 모듈에 첨부된 파일 정보를 삭제합니다.
|
||||
*
|
||||
* @param moduleId 모듈 ID
|
||||
* @return 삭제된 행의 수
|
||||
*/
|
||||
int deleteHtmlEditorFileByModuleId(String moduleId);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package go.kr.project.common.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.system.menu.model
|
||||
* fileName : MenuVO
|
||||
* author : 시스템 관리자
|
||||
* date : 2023-06-01
|
||||
* description : 메뉴 정보를 담는 VO 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2023-06-01 시스템 관리자 최초 생성
|
||||
*/
|
||||
@Builder
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@ToString
|
||||
public class CmmnCodeSearchVO {
|
||||
|
||||
private String searchCdGroupId;
|
||||
private String searchCdId;
|
||||
private String searchCdNm;
|
||||
private String searchAttribute1;
|
||||
private String searchAttribute2;
|
||||
private String searchAttribute3;
|
||||
private String searchAttribute4;
|
||||
private String searchAttribute5;
|
||||
/** 사용유무 */
|
||||
private String searchUseYn;
|
||||
|
||||
/** 등록일시 시작일 */
|
||||
private String searchRegDttmStart;
|
||||
/** 등록일시 종료일 */
|
||||
private String searchRegDttmEnd;
|
||||
/** 등록자 */
|
||||
private String searchRgtr;
|
||||
/** 수정일시 시작일 */
|
||||
private String searchMdfcnDttmStart;
|
||||
/** 수정일시 종료일 */
|
||||
private String searchMdfcnDttmEnd;
|
||||
/** 수정자 */
|
||||
private String searchMdfr;
|
||||
|
||||
/** 정렬컬럼 */
|
||||
private String sortColumn;
|
||||
|
||||
/** 정렬구분 */
|
||||
private Boolean sortAscending;
|
||||
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package go.kr.project.common.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common
|
||||
* fileName : DefaultVO
|
||||
* author : 박성영
|
||||
* date : 25. 5. 8.
|
||||
* description :
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 8. 박성영 최초 생성
|
||||
*/
|
||||
@Data
|
||||
public class DefaultVO implements Serializable {
|
||||
|
||||
/** 저장구분 : I(insert), U(update), D(delete) */
|
||||
private String crudType;
|
||||
|
||||
/** checkbox */
|
||||
private Integer chk;
|
||||
|
||||
/* 페이징여부 */
|
||||
private String pagingYn;
|
||||
|
||||
/** 검색조건 */
|
||||
private String searchCondition = "";
|
||||
|
||||
/** 검색Keyword */
|
||||
private String searchKeyword = "";
|
||||
|
||||
/** TotalCount */
|
||||
private Integer totalCount;
|
||||
|
||||
/** 사용유무 */
|
||||
private String searchUseYn;
|
||||
|
||||
/** 정렬컬럼 */
|
||||
private String sortColumn;
|
||||
|
||||
/** 정렬구분 */
|
||||
private Boolean sortAscending;
|
||||
|
||||
/** TUI Grid 속성 (row class 등) */
|
||||
private Map<String, Object> _attributes;
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package go.kr.project.common.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 파일 정보를 담는 VO 클래스
|
||||
*/
|
||||
@Data
|
||||
public class FileVO {
|
||||
|
||||
/** 파일 ID */
|
||||
private String fileId;
|
||||
|
||||
/** 참조 ID (공지사항 ID 등) */
|
||||
private String refId;
|
||||
|
||||
/** 원본 파일명 */
|
||||
private String originalFileNm;
|
||||
|
||||
/** 저장 파일명 */
|
||||
private String storedFileNm;
|
||||
|
||||
/** 파일 경로 */
|
||||
private String filePath;
|
||||
|
||||
/** 파일 크기 */
|
||||
private long fileSize;
|
||||
|
||||
/** 파일 확장자 */
|
||||
private String fileExt;
|
||||
|
||||
/** 등록 일시 */
|
||||
private String regDttm;
|
||||
|
||||
/** 등록자 */
|
||||
private String rgtr;
|
||||
|
||||
/** 파일 데이터 (MultipartFile에서 변환) */
|
||||
private byte[] fileData;
|
||||
|
||||
/** 파일 컨텐츠 타입 */
|
||||
private String contentType;
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package go.kr.project.common.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.*;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.model
|
||||
* fileName : HtmlEditorFileVO
|
||||
* author : 개발자
|
||||
* date : 2025-05-23
|
||||
* description : HTML 에디터 파일 VO 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-23 개발자 최초 생성
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper=true)
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@ToString
|
||||
public class HtmlEditorFileVO extends PagingVO {
|
||||
|
||||
private String fileId;
|
||||
|
||||
private String moduleId;
|
||||
|
||||
private String originalFileNm;
|
||||
|
||||
private String storedFileNm;
|
||||
|
||||
private String filePath;
|
||||
|
||||
private Long fileSize;
|
||||
|
||||
private String fileExt;
|
||||
|
||||
private String fileType;
|
||||
|
||||
private String useYn;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
|
||||
private LocalDateTime regDttm;
|
||||
|
||||
private String rgtr;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
|
||||
private LocalDateTime mdfcnDttm;
|
||||
|
||||
private String mdfr;
|
||||
|
||||
// 파일 크기 표시용 (KB, MB 등)
|
||||
private String fileSizeStr;
|
||||
|
||||
// 다운로드 URL
|
||||
private String downloadUrl;
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package go.kr.project.common.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common
|
||||
* fileName : PagingVO
|
||||
* author : 박성영
|
||||
* date : 25. 5. 8.
|
||||
* description : 페이징 처리를 위한 VO 클래스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 25. 5. 8. 박성영 최초 생성
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper=true)
|
||||
public class PagingVO extends DefaultVO {
|
||||
|
||||
/** 현재 페이지 번호 */
|
||||
|
||||
private Integer page;
|
||||
|
||||
/** 페이지당 항목 수 */
|
||||
private Integer perPage;
|
||||
|
||||
/** 시작 인덱스 */
|
||||
private Integer startIndex;
|
||||
|
||||
/** 끝 인덱스 */
|
||||
private Integer endIndex;
|
||||
|
||||
/** 전체 페이지 수 */
|
||||
private Integer totalPages;
|
||||
|
||||
/** 기본 페이지당 항목 수 */
|
||||
private static final int DEFAULT_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* 페이징 처리를 위한 시작 인덱스와 끝 인덱스를 계산합니다.
|
||||
*/
|
||||
public void calculateIndex() {
|
||||
if (this.page == null) {
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
if (this.perPage == null) {
|
||||
this.perPage = DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
this.startIndex = (this.page - 1) * this.perPage;
|
||||
this.endIndex = this.startIndex + this.perPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 페이지 수를 계산합니다.
|
||||
*/
|
||||
public void calculateTotalPages() {
|
||||
if (this.perPage == null) {
|
||||
this.perPage = DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
if (this.getTotalCount() > 0) {
|
||||
this.totalPages = (int) Math.ceil((double) this.getTotalCount() / this.perPage);
|
||||
} else {
|
||||
this.totalPages = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 여부를 설정합니다.
|
||||
* "Y" 값이 들어오면 자동으로 페이지 인덱스와 전체 페이지 수를 계산합니다.
|
||||
*
|
||||
* @param pagingYn 페이징 여부 ("Y" 또는 "N")
|
||||
*/
|
||||
@Override
|
||||
public void setPagingYn(String pagingYn) {
|
||||
super.setPagingYn(pagingYn);
|
||||
|
||||
if ("Y".equals(pagingYn)) {
|
||||
// 페이지 인덱스 계산
|
||||
calculateIndex();
|
||||
// 전체 페이지 수 계산
|
||||
calculateTotalPages();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package go.kr.project.common.service;
|
||||
|
||||
/**
|
||||
* 주소 검색 서비스 인터페이스
|
||||
*/
|
||||
public interface AddressService {
|
||||
|
||||
/**
|
||||
* 외부 주소 API를 호출하여 주소를 검색합니다.
|
||||
*
|
||||
* @param keyword 검색어
|
||||
* @param currentPage 현재 페이지
|
||||
* @param countPerPage 페이지당 결과 수
|
||||
* @return API 응답 결과 (JSON 문자열)
|
||||
*/
|
||||
String searchAddress(String keyword, int currentPage, int countPerPage);
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package go.kr.project.common.service;
|
||||
|
||||
import go.kr.project.common.model.CmmnCodeSearchVO;
|
||||
import go.kr.project.system.code.model.CodeDetailVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* packageName : go.kr.project.common.service
|
||||
* fileName : CommonCodeService
|
||||
* author : 개발자
|
||||
* date : 2025-05-10
|
||||
* description : 공통 코드 관련 비즈니스 로직을 처리하는 서비스 인터페이스
|
||||
* ===========================================================
|
||||
* DATE AUTHOR NOTE
|
||||
* -----------------------------------------------------------
|
||||
* 2025-05-10 개발자 최초 생성
|
||||
*/
|
||||
public interface CommonCodeService {
|
||||
|
||||
/**
|
||||
* 특정 코드 그룹에 속한 코드 상세 목록을 조회합니다.
|
||||
*
|
||||
* @param cdGroupId 코드 그룹 ID
|
||||
* @return 코드 상세 목록
|
||||
*/
|
||||
List<CodeDetailVO> selectCodeDetailListByGroupId(String cdGroupId);
|
||||
|
||||
/**
|
||||
* 검색 조건에 맞는 코드 상세 목록을 조회합니다.
|
||||
*
|
||||
* @param searchVO 코드 상세 검색을 위한 검색 조건을 담은 VO 객체
|
||||
* @return 검색 조건에 맞는 코드 상세 목록
|
||||
*/
|
||||
List<CodeDetailVO> selectCodeDetailList(CmmnCodeSearchVO searchVO);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue