commit f1ecbca21651202910fed33b0f9ef3d5e108005d Author: 박성영 Date: Wed Nov 5 15:26:59 2025 +0900 용인시 수지구청 자동차 검사 과태료 시스템 최초 등록 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abba133 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DB/DDL/ECL_DECRYPT.sql b/DB/DDL/ECL_DECRYPT.sql new file mode 100644 index 0000000..15cf1d1 --- /dev/null +++ b/DB/DDL/ECL_DECRYPT.sql @@ -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 \ No newline at end of file diff --git a/DB/DDL/ECL_ENCRYPT.sql b/DB/DDL/ECL_ENCRYPT.sql new file mode 100644 index 0000000..76c3367 --- /dev/null +++ b/DB/DDL/ECL_ENCRYPT.sql @@ -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 \ No newline at end of file diff --git a/DB/DDL/seq_file_id.sql b/DB/DDL/seq_file_id.sql new file mode 100644 index 0000000..d757de9 --- /dev/null +++ b/DB/DDL/seq_file_id.sql @@ -0,0 +1,8 @@ +CREATE SEQUENCE seq_file_id + START WITH 1000 + INCREMENT BY 1 + MINVALUE 1 + MAXVALUE 9999999999999999 + CACHE 1000 + CYCLE; + diff --git a/DB/DDL/seq_login_log_id.sql b/DB/DDL/seq_login_log_id.sql new file mode 100644 index 0000000..582950d --- /dev/null +++ b/DB/DDL/seq_login_log_id.sql @@ -0,0 +1,7 @@ +CREATE SEQUENCE seq_login_log_id + START WITH 1000 + INCREMENT BY 1 + MINVALUE 1 + MAXVALUE 9999999999999999 + CACHE 1000 + CYCLE; \ No newline at end of file diff --git a/DB/DDL/seq_menu_id.sql b/DB/DDL/seq_menu_id.sql new file mode 100644 index 0000000..54ad529 --- /dev/null +++ b/DB/DDL/seq_menu_id.sql @@ -0,0 +1,8 @@ +CREATE SEQUENCE seq_menu_id + START WITH 1000 + INCREMENT BY 1 + MINVALUE 1 + MAXVALUE 9999999999999999 + CACHE 1000 + CYCLE; + diff --git a/DB/DDL/seq_user_id.sql b/DB/DDL/seq_user_id.sql new file mode 100644 index 0000000..f698071 --- /dev/null +++ b/DB/DDL/seq_user_id.sql @@ -0,0 +1,8 @@ +CREATE SEQUENCE seq_user_id + START WITH 1000 + INCREMENT BY 1 + MINVALUE 1 + MAXVALUE 9999999999999999 + CACHE 1000 + CYCLE; + diff --git a/DB/DDL/tb_cd_detail.sql b/DB/DDL/tb_cd_detail.sql new file mode 100644 index 0000000..39edbee --- /dev/null +++ b/DB/DDL/tb_cd_detail.sql @@ -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 '코드 상세'; + diff --git a/DB/DDL/tb_cd_group.sql b/DB/DDL/tb_cd_group.sql new file mode 100644 index 0000000..992e58a --- /dev/null +++ b/DB/DDL/tb_cd_group.sql @@ -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 '코드 그룹'; + diff --git a/DB/DDL/tb_group.sql b/DB/DDL/tb_group.sql new file mode 100644 index 0000000..49df2ee --- /dev/null +++ b/DB/DDL/tb_group.sql @@ -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 '그룹'; + diff --git a/DB/DDL/tb_group_role.sql b/DB/DDL/tb_group_role.sql new file mode 100644 index 0000000..8740f11 --- /dev/null +++ b/DB/DDL/tb_group_role.sql @@ -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 '그룹-역할 매핑'; + diff --git a/DB/DDL/tb_login_log.sql b/DB/DDL/tb_login_log.sql new file mode 100644 index 0000000..9da3d99 --- /dev/null +++ b/DB/DDL/tb_login_log.sql @@ -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); + diff --git a/DB/DDL/tb_menu.sql b/DB/DDL/tb_menu.sql new file mode 100644 index 0000000..b671621 --- /dev/null +++ b/DB/DDL/tb_menu.sql @@ -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 '메뉴'; + diff --git a/DB/DDL/tb_role.sql b/DB/DDL/tb_role.sql new file mode 100644 index 0000000..6978980 --- /dev/null +++ b/DB/DDL/tb_role.sql @@ -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 '역할'; + diff --git a/DB/DDL/tb_role_menu.sql b/DB/DDL/tb_role_menu.sql new file mode 100644 index 0000000..9003558 --- /dev/null +++ b/DB/DDL/tb_role_menu.sql @@ -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 '역할-메뉴 매핑'; + diff --git a/DB/DDL/tb_user.sql b/DB/DDL/tb_user.sql new file mode 100644 index 0000000..3d8badb --- /dev/null +++ b/DB/DDL/tb_user.sql @@ -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 '사용자'; + diff --git a/DB/DDL/tb_user_session.sql b/DB/DDL/tb_user_session.sql new file mode 100644 index 0000000..4dc6c3d --- /dev/null +++ b/DB/DDL/tb_user_session.sql @@ -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); + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..e2045e3 --- /dev/null +++ b/build.gradle @@ -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() + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f9fb677 --- /dev/null +++ b/gradle.properties @@ -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 \ No newline at end of file diff --git a/src/main/java/egovframework/config/DataSourceProxyConfig.java b/src/main/java/egovframework/config/DataSourceProxyConfig.java new file mode 100644 index 0000000..940b0fd --- /dev/null +++ b/src/main/java/egovframework/config/DataSourceProxyConfig.java @@ -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 queryInfoList) { + // 쿼리 실행 전 처리 (필요시 구현) + } + + @Override + public void afterQuery(ExecutionInfo execInfo, List queryInfoList) { + try { + // 쿼리 실행 후 파라미터가 바인딩된 SQL 출력 + for (QueryInfo queryInfo : queryInfoList) { + String query = queryInfo.getQuery(); + List> parametersList = queryInfo.getParametersList(); + + // Mapper 경로 및 메서드명 추출 + String mapperInfo = extractMapperInfo(); + logger.info(" ========================== Mapper: {} ========================== ", mapperInfo); + + // 쿼리 실행 시간 및 기본 정보 + long executionTime = execInfo.getElapsedTime(); + logger.debug("실행 시간: {}ms", executionTime); + + // 파라미터 값 추출 + List 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 extractParameterValues(List> parametersList) { + java.util.Map parameterMap = new java.util.TreeMap<>(); + + if (parametersList != null && !parametersList.isEmpty()) { + // 첫 번째 파라미터 세트를 사용 (일반적으로 PreparedStatement는 하나의 파라미터 세트를 가짐) + List 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 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 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 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 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 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(); + } + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/config/EgovConfigCommon.java b/src/main/java/egovframework/config/EgovConfigCommon.java new file mode 100644 index 0000000..6e19b1a --- /dev/null +++ b/src/main/java/egovframework/config/EgovConfigCommon.java @@ -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; + } + +} diff --git a/src/main/java/egovframework/config/EgovConfigInterceptor.java b/src/main/java/egovframework/config/EgovConfigInterceptor.java new file mode 100644 index 0000000..55faecf --- /dev/null +++ b/src/main/java/egovframework/config/EgovConfigInterceptor.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/config/EgovConfigTransaction.java b/src/main/java/egovframework/config/EgovConfigTransaction.java new file mode 100644 index 0000000..6d8836d --- /dev/null +++ b/src/main/java/egovframework/config/EgovConfigTransaction.java @@ -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 + * + *
+ * << 개정이력(Modification Information) >>
+ *
+ *   수정일              수정자               수정내용
+ *  -------------  ------------   ---------------------
+ *   2021. 7. 20    윤주호               최초 생성
+ * 
+ * + */ +@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 getRuleBasedTxAttributeMap() { + HashMap txMethods = new HashMap(); + + 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)); + } +} diff --git a/src/main/java/egovframework/config/EgovConfigWeb.java b/src/main/java/egovframework/config/EgovConfigWeb.java new file mode 100644 index 0000000..950d1a8 --- /dev/null +++ b/src/main/java/egovframework/config/EgovConfigWeb.java @@ -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; + } + + +} diff --git a/src/main/java/egovframework/config/EgovErrorConfig.java b/src/main/java/egovframework/config/EgovErrorConfig.java new file mode 100644 index 0000000..d5cbe00 --- /dev/null +++ b/src/main/java/egovframework/config/EgovErrorConfig.java @@ -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 + ); + } + }; + } +} diff --git a/src/main/java/egovframework/config/HtmlCharacterEscapes.java b/src/main/java/egovframework/config/HtmlCharacterEscapes.java new file mode 100644 index 0000000..621801e --- /dev/null +++ b/src/main/java/egovframework/config/HtmlCharacterEscapes.java @@ -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))); + } +} diff --git a/src/main/java/egovframework/config/SqlLoggingInterceptor.java b/src/main/java/egovframework/config/SqlLoggingInterceptor.java new file mode 100644 index 0000000..0d1e588 --- /dev/null +++ b/src/main/java/egovframework/config/SqlLoggingInterceptor.java @@ -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 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) { + // 설정 프로퍼티가 있다면 여기서 처리 + // 현재는 특별한 설정이 필요없음 + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/config/SwaggerConfig.java b/src/main/java/egovframework/config/SwaggerConfig.java new file mode 100644 index 0000000..b9163a5 --- /dev/null +++ b/src/main/java/egovframework/config/SwaggerConfig.java @@ -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); + } +} diff --git a/src/main/java/egovframework/configProperties/FileUploadProperties.java b/src/main/java/egovframework/configProperties/FileUploadProperties.java new file mode 100644 index 0000000..ea14d6c --- /dev/null +++ b/src/main/java/egovframework/configProperties/FileUploadProperties.java @@ -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 subDirs; +} \ No newline at end of file diff --git a/src/main/java/egovframework/configProperties/InterceptorProperties.java b/src/main/java/egovframework/configProperties/InterceptorProperties.java new file mode 100644 index 0000000..402cdb3 --- /dev/null +++ b/src/main/java/egovframework/configProperties/InterceptorProperties.java @@ -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 interceptorExclude; + private List refererExclude; + +} + diff --git a/src/main/java/egovframework/configProperties/LoginProperties.java b/src/main/java/egovframework/configProperties/LoginProperties.java new file mode 100644 index 0000000..8e66a4d --- /dev/null +++ b/src/main/java/egovframework/configProperties/LoginProperties.java @@ -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; // 비밀번호 잠김 최종 카운트 + } +} diff --git a/src/main/java/egovframework/constant/BatchConstants.java b/src/main/java/egovframework/constant/BatchConstants.java new file mode 100644 index 0000000..41015d5 --- /dev/null +++ b/src/main/java/egovframework/constant/BatchConstants.java @@ -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"; +} diff --git a/src/main/java/egovframework/constant/CrdnPrcsSttsConstants.java b/src/main/java/egovframework/constant/CrdnPrcsSttsConstants.java new file mode 100644 index 0000000..574c576 --- /dev/null +++ b/src/main/java/egovframework/constant/CrdnPrcsSttsConstants.java @@ -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"; + +} \ No newline at end of file diff --git a/src/main/java/egovframework/constant/FileContentTypeConstants.java b/src/main/java/egovframework/constant/FileContentTypeConstants.java new file mode 100644 index 0000000..d8e650d --- /dev/null +++ b/src/main/java/egovframework/constant/FileContentTypeConstants.java @@ -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 CONTENT_TYPE_MAP; + + static { + Map 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); + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/constant/ImpltTaskSeConstants.java b/src/main/java/egovframework/constant/ImpltTaskSeConstants.java new file mode 100644 index 0000000..be098ef --- /dev/null +++ b/src/main/java/egovframework/constant/ImpltTaskSeConstants.java @@ -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; + } + } + +} \ No newline at end of file diff --git a/src/main/java/egovframework/constant/MessageConstants.java b/src/main/java/egovframework/constant/MessageConstants.java new file mode 100644 index 0000000..4e63368 --- /dev/null +++ b/src/main/java/egovframework/constant/MessageConstants.java @@ -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 = "시스템 오류가 발생했습니다."; + } +} diff --git a/src/main/java/egovframework/constant/SEQConstants.java b/src/main/java/egovframework/constant/SEQConstants.java new file mode 100644 index 0000000..4595f9f --- /dev/null +++ b/src/main/java/egovframework/constant/SEQConstants.java @@ -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_"; + +} diff --git a/src/main/java/egovframework/constant/SessionConstants.java b/src/main/java/egovframework/constant/SessionConstants.java new file mode 100644 index 0000000..5b213da --- /dev/null +++ b/src/main/java/egovframework/constant/SessionConstants.java @@ -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"; +} \ No newline at end of file diff --git a/src/main/java/egovframework/constant/TilesConstants.java b/src/main/java/egovframework/constant/TilesConstants.java new file mode 100644 index 0000000..6574c33 --- /dev/null +++ b/src/main/java/egovframework/constant/TilesConstants.java @@ -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"; +} \ No newline at end of file diff --git a/src/main/java/egovframework/constant/TuiGridColorConstants.java b/src/main/java/egovframework/constant/TuiGridColorConstants.java new file mode 100644 index 0000000..c426996 --- /dev/null +++ b/src/main/java/egovframework/constant/TuiGridColorConstants.java @@ -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() { + // 유틸 상수 클래스 - 인스턴스화 방지 + } +} diff --git a/src/main/java/egovframework/exception/AccessDeniedException.java b/src/main/java/egovframework/exception/AccessDeniedException.java new file mode 100644 index 0000000..0089846 --- /dev/null +++ b/src/main/java/egovframework/exception/AccessDeniedException.java @@ -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; + } + +} \ No newline at end of file diff --git a/src/main/java/egovframework/exception/EgovAopExceptionTransfer.java b/src/main/java/egovframework/exception/EgovAopExceptionTransfer.java new file mode 100644 index 0000000..10ea9c3 --- /dev/null +++ b/src/main/java/egovframework/exception/EgovAopExceptionTransfer.java @@ -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); + } + +} diff --git a/src/main/java/egovframework/exception/EgovDefaultExcepHndlr.java b/src/main/java/egovframework/exception/EgovDefaultExcepHndlr.java new file mode 100644 index 0000000..baede4e --- /dev/null +++ b/src/main/java/egovframework/exception/EgovDefaultExcepHndlr.java @@ -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..."); + } + +} diff --git a/src/main/java/egovframework/exception/EgovDefaultOthersExcepHndlr.java b/src/main/java/egovframework/exception/EgovDefaultOthersExcepHndlr.java new file mode 100644 index 0000000..5a548a0 --- /dev/null +++ b/src/main/java/egovframework/exception/EgovDefaultOthersExcepHndlr.java @@ -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..."); + } + +} diff --git a/src/main/java/egovframework/exception/EgovExceptionAdvice.java b/src/main/java/egovframework/exception/EgovExceptionAdvice.java new file mode 100644 index 0000000..a518305 --- /dev/null +++ b/src/main/java/egovframework/exception/EgovExceptionAdvice.java @@ -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>를 반환하고, + * 일반 요청인 경우 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 responseBody = ApiResponseEntity.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; + } +} diff --git a/src/main/java/egovframework/exception/MessageException.java b/src/main/java/egovframework/exception/MessageException.java new file mode 100644 index 0000000..41d44f9 --- /dev/null +++ b/src/main/java/egovframework/exception/MessageException.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/filter/SessionRefreshFilter.java b/src/main/java/egovframework/filter/SessionRefreshFilter.java new file mode 100644 index 0000000..3d951b3 --- /dev/null +++ b/src/main/java/egovframework/filter/SessionRefreshFilter.java @@ -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); + } +} diff --git a/src/main/java/egovframework/filter/XssFilter.java b/src/main/java/egovframework/filter/XssFilter.java new file mode 100644 index 0000000..37bfbbb --- /dev/null +++ b/src/main/java/egovframework/filter/XssFilter.java @@ -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); + } +} diff --git a/src/main/java/egovframework/filter/XssFilterConfig.java b/src/main/java/egovframework/filter/XssFilterConfig.java new file mode 100644 index 0000000..d97c058 --- /dev/null +++ b/src/main/java/egovframework/filter/XssFilterConfig.java @@ -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 xssFilterRegistration() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new XssFilter(xssUtil)); + registrationBean.addUrlPatterns("/*"); // 모든 URL에 적용 + registrationBean.setName("xssFilter"); + registrationBean.setOrder(1); // 필터 순서 (낮은 숫자가 먼저 실행) + return registrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/filter/XssRequestWrapper.java b/src/main/java/egovframework/filter/XssRequestWrapper.java new file mode 100644 index 0000000..c25b7c9 --- /dev/null +++ b/src/main/java/egovframework/filter/XssRequestWrapper.java @@ -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 getParameterMap() { + Map paramMap = super.getParameterMap(); + Map filteredParamMap = new HashMap<>(); + + for (Map.Entry 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; + } +} \ No newline at end of file diff --git a/src/main/java/egovframework/filter/XssUtil.java b/src/main/java/egovframework/filter/XssUtil.java new file mode 100644 index 0000000..eaed7a0 --- /dev/null +++ b/src/main/java/egovframework/filter/XssUtil.java @@ -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 = "

|
||||||