초기 업로드

This commit is contained in:
CSKIM
2026-04-17 13:35:40 +09:00
commit e547089f66
265 changed files with 86707 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
out/
target/
.idea/
*.iml
+66
View File
@@ -0,0 +1,66 @@
# IOTDOOR Spring Boot 마이그레이션 계획
## 목표
- 레거시 `eGovFrame + Spring MVC` 구조를 `Spring Boot 3.x + Java 17` 기반으로 전환
- 기존 공개 사이트 디자인은 최대한 유지
- 관리자 영역은 운영성과 보안성을 우선으로 재설계
- 불필요한 레거시 자산과 연결되지 않은 화면은 점진적으로 제외
## 현재 1차 이행 범위
- Spring Boot 3 / Java 17 신규 모듈 생성
- 공개 메인 페이지 이식
- 연락처 페이지 이식
- 포트폴리오 페이지를 정적/큐레이션 방식으로 재구성
- 관리자 로그인/로그아웃 이식
- 관리자 대시보드 신규 구성
- 문의 접수를 서버 검증 API 기반으로 교체
## 이번 단계에서 의도적으로 보류한 범위
- 게시판 CRUD
- 회원가입 및 사용자 상세 프로필
- 메시지/알림
- 설문
- 관리자 CMS 전체
- 운영 로그 및 통계
- Hypermeta 연동
- DB 기반 메뉴/권한 시스템
## 설계 원칙
1. 레거시와 직접 충돌하지 않도록 신규 모듈 `iotdoor-boot/` 에서 시작한다.
2. URL은 가능한 한 기존 `.do` 패턴을 유지한다.
3. 화면은 기존 Thymeleaf 템플릿과 정적 리소스를 재사용한다.
4. 인증은 Spring Security 6 표준 구성으로 재작성한다.
5. 보안 취약점으로 보였던 항목은 기본값부터 개선한다.
## 우선순위 제안
### 2차
- 공통 레이아웃/메뉴를 DB 비의존 구조로 정리
- 게시판 읽기 전용 목록/상세 먼저 이식
- 파일 업로드/다운로드 서비스 재구성
### 3차
- 관리자 게시판/공지 관리
- 회원 조회 및 권한 관리
- 사이트 정보/배너/팝업 관리
### 4차
- 메시지, 설문, 통계, 로그 이식
- MyBatis 매퍼 재정비 및 DB 스키마 문서화
- 외부 연동(Hypermeta) 재적용
## 보안 개선 기준
- CSRF 기본 활성화
- BCrypt 비밀번호 사용
- 관리자 계정은 환경변수로 주입
- 클라이언트 직접 메일 발송 제거
- 운영 민감정보의 템플릿/JS 하드코딩 최소화
+722
View File
@@ -0,0 +1,722 @@
# IOTDOOR 프로그램 명세서
## 1. 문서 개요
- 작성일: 2026-04-15
- 분석 대상: 현재 저장소 기준 소스코드 전체
- 작성 목적: 프로젝트 구조, 기능 범위, 운영 방식, 배포 구조, 유지보수 유의사항을 한 문서로 정리
- 분석 기준 파일:
- `pom.xml`
- `src/main/webapp/WEB-INF/web.xml`
- `src/main/resources/egovframework/spring/com/*.xml`
- `src/main/webapp/WEB-INF/config/egovframework/springmvc/*.xml`
- `src/main/java/kr/iotdoor/**`
- `src/main/resources/egovframework/mapper/kr/iotdoor/**`
본 문서는 저장소에 포함된 소스코드를 기준으로 작성되었으며, 저장소에 없는 DB DDL, 인프라 구성, 외부 시스템 상세 계약은 확인 가능한 범위 내에서만 기술하였다.
## 2. 시스템 개요
IOTDOOR는 `eGovFrame + Spring MVC + MyBatis + Thymeleaf` 기반의 포털형 웹 애플리케이션이다.
단순 게시판 서비스가 아니라 다음 성격이 함께 포함된 통합형 시스템으로 판단된다.
- 사이트 운영/CMS 기능
- 사이트 정보 관리
- 메뉴 관리
- 동적 페이지 편집
- 팝업/배너 운영
- 정책 페이지 관리
- 커뮤니티 기능
- 게시판
- 댓글
- FAQ
- Q&A
- 설문
- 쪽지
- 사용자 기능
- 로그인/로그아웃
- 회원가입
- 개인정보/프로필 관리
- 최근 검색어 관리
- 운영자 기능
- 관리자 대시보드
- 사용자/권한/사이트/코드 관리
- 운영 로그 및 통계 화면
- 외부 연동 기능
- Hypermeta API 연동
URI 네임스페이스는 대체로 아래처럼 구분된다.
| 구분 | URI 패턴 | 역할 |
| --- | --- | --- |
| 포털 메인 | `/`, `/index.do` | 일반 사용자 메인 진입점 |
| 공통 기능 | `/comn/**` | 로그인, 회원, 파일, 메시지, 사이트 공통 기능 |
| 커뮤니티 | `/cmty/**` | 게시판, 댓글, FAQ, Q&A, 설문 |
| 관리자 | `/mfuc/**` | 운영자 화면 및 백오피스 |
| 통계 | `/scnt/**` | 통계 대시보드 및 집계 화면 |
## 3. 기술 스택
| 항목 | 내용 |
| --- | --- |
| 언어/런타임 | Java 8 |
| 빌드 도구 | Maven |
| 패키징 | WAR |
| 배포 형태 | `ROOT.war`로 패키징되는 톰캣 배포형 웹앱 |
| 프레임워크 | Spring 5.3.20, eGovFrame 4.1.0 |
| 웹 MVC | Spring MVC Annotation Controller |
| 뷰 엔진 | Thymeleaf 3, Spring Security Dialect, Layout Dialect |
| 호환 설정 | JSP View Resolver도 설정되어 있으나 실제 화면은 Thymeleaf 중심 |
| 영속성 | MyBatis, Mapper Interface + SQL XML |
| DB | MariaDB 계열 전제, 런타임은 JNDI `java:comp/env/jdbc/serviceDB` 사용 |
| 보안 | Spring Security + eGov Security/Access |
| JSON 처리 | Jackson |
| 외부 통신 | OkHttp, Moshi, Apache HttpClient |
| 파일 처리 | Commons FileUpload, Thumbnailator |
| 스케줄링 | Quartz 의존성 포함, 일부 스케줄 설정 흔적 존재 |
| 로깅 | Log4j2, log4jdbc |
## 4. 빌드 및 실행 구조
### 4.1 Maven 프로필
프로젝트는 환경별 리소스를 분리해서 사용한다.
- `local`
- `dev`
- `real`
- `2025`
기본 활성 프로필은 `local`이다.
### 4.2 빌드 산출물
- 산출 디렉터리: `target/`
- 최종 WAR 이름: `ROOT.war`
예시 빌드 명령:
- `mvn clean package -Plocal`
- `mvn clean package -Pdev`
- `mvn clean package -Preal`
### 4.3 환경별 리소스
환경별 프로퍼티와 로그 설정은 아래 디렉터리로 분리되어 있다.
- `src/main/resources-local`
- `src/main/resources-dev`
- `src/main/resources-real`
Maven 리소스 설정상 `src/main/resources-${env}` 가 선택적으로 포함된다.
### 4.4 웹 설정
`web.xml` 기준 주요 설정은 다음과 같다.
- 세션 타임아웃: 60분
- 웰컴 파일: `index.jsp`
- 에러 페이지:
- `404 -> /code404.jsp`
- `500 -> /code500.jsp`
`egov-com-servlet.xml` 기준 주요 설정은 다음과 같다.
- 컨트롤러 스캔 대상: `egovframework, kr`
- 인터셉터 등록:
- `LocaleChangeInterceptor`
- `WebLogInterceptor`
- `MenuPageInterceptor`
- Thymeleaf View Resolver 사용
- JSON View 사용
- `/resources/**` 정적 리소스 제공
## 5. 시스템 아키텍처
### 5.1 계층 구조
시스템은 전형적인 계층형 구조를 따른다.
1. Controller
2. Service
3. Mapper Interface
4. MyBatis SQL XML
5. DB
6. Thymeleaf Template / Static Resource
대표적인 요청 흐름은 아래와 같다.
1. 클라이언트 요청이 Spring MVC Controller로 진입
2. 인터셉터에서 웹 로그 기록 및 메뉴 정보 주입
3. Controller에서 인증/권한/파라미터 검증 수행
4. Service에서 업무 로직 처리
5. Mapper와 SQL XML을 통해 DB 접근
6. HTML 또는 JSON 응답 반환
### 5.2 패키지 구조
| 패키지 | 역할 |
| --- | --- |
| `kr.iotdoor.comn` | 사이트 공통 기능, 회원, 로그인, 파일, 메시지, 메뉴, 팝업, 페이지, API |
| `kr.iotdoor.cmty` | 게시판, 댓글, FAQ, Q&A, 설문 등 커뮤니티 기능 |
| `kr.iotdoor.mfuc` | 관리자 기능, 권한/접근/운영/로그 관리 |
| `kr.iotdoor.scnt` | 통계 기능 |
| `kr.iotdoor.iotdoor` | 포털 메인 외 추가 공개 페이지 |
### 5.3 메뉴 기반 포털 구조
이 프로젝트는 하드코딩된 메뉴보다 DB 기반 메뉴 구조를 중심으로 동작한다.
- `MenuServiceImpl` 에서 헤더 메뉴, 사이드 메뉴, breadcrumb location 구성
- `MenuPageInterceptor``menuSn` 값을 기준으로 `menuTree` 를 요청에 주입
- 메뉴 수정/등록/활성화/삭제 시 메뉴 캐시를 갱신
- 최대 4차 메뉴까지 고려한 구조가 구현되어 있음
### 5.4 뷰 렌더링 구조
- 메인 템플릿 위치:
- `src/main/webapp/WEB-INF/template/kr/iotdoor/**`
- 정적 리소스 위치:
- `src/main/webapp/resources/**`
- 동적 페이지 편집 대상:
- HTML: `WEB-INF/template/kr/iotdoor/comn/site/page/*.html`
- CSS/JS: `resources/kr/iotdoor/comn/site/page/**`
## 6. 기능 명세
### 6.1 포털 메인 및 공통 사이트 기능
#### 메인 페이지
주요 URI:
- `/`
- `/index.do`
- `/portfolio.do`
- `/contact.do`
- `/prvcPrcsPlan.do`
- `/emlClctReject.do`
주요 동작:
- 메뉴 트리 로딩
- 메인 공지사항 목록 노출
- 활성 팝업 목록 노출
- 사이트 정보 및 정책 페이지 렌더링
#### 관리자 메인
주요 URI:
- `/mngr/index.do`
- `/mfuc/index.do`
- `/mfuc/system/dashboard/siteSumryPge.do`
주요 동작:
- 로그인 여부 및 `ROLE_MANAGER` 권한 확인
- 공통 메뉴/프로필/게시판 목록 로딩
- 운영자 대시보드 화면 제공
### 6.2 로그인/회원가입/회원 정보
#### 로그인
주요 URI:
- `/comn/user/login/loginUser.do`
- `/comn/user/login/actionLogin.do`
- `/comn/user/login/main.do`
- `/comn/user/login/logout.do`
- `/comn/user/login/lgnLmtPge.do`
주요 동작:
- 개발 모드에서 개발용 로그인 보조값 표시 가능
- 로그인 후 이전 URL로 복귀 처리
- 마지막 로그인 일시 갱신
#### 회원가입
주요 URI:
- `/comn/user/join/mbrRegPge.do`
- `/comn/user/join/writeJoinMber.do`
주요 동작:
- 세션의 `join_member` 정보를 기반으로 회원가입 진행
- 기관 정보, 학년/반, 지역 코드 처리
- 개인정보 처리 동의 여부 필수 검증
#### 개인정보/프로필
주요 URI:
- `/comn/user/info/indvdlInfo.do`
- `/comn/user/info/modifyIndvdlInfo.do`
- `/comn/user/info/modifyIndvdlInfo.json`
- `/comn/user/info/searchUser.json`
- `/comn/user/info/searchRcvr.json`
- `/comn/user/info/proflPgeMdfcn.do`
- `/comn/user/info/checkDpcnNcnm.json`
- `/comn/user/info/uploadImage.json`
주요 동작:
- 사용자 기본 정보와 상세 정보 분리 조회
- 거주지역 공통코드 조회
- 별명 중복검사 후 프로필 수정
- 프로필 이미지 업로드
- 메시지 수신자 자동완성 검색
### 6.3 메시지 기능
주요 URI:
- `/comn/msg/info/rcptnMsgList.do`
- `/comn/msg/info/dsptchMsgList.do`
- `/comn/msg/info/msgDtl.do`
- `/comn/msg/info/deleteRcptnMsg.do`
- `/comn/msg/info/msgTrsm.do`
- `/comn/msg/info/sendMsgDstch.json`
- `/comn/msg/info/ntcnList.json`
- `/comn/msg/info/ntcnCnt.json`
- `/comn/msg/tmplat/selectMsgTmplat.json`
주요 동작:
- 수신/발신 메시지 목록 관리
- 메시지 상세 확인 시 수신 여부 갱신
- 수신자 검색 기반 쪽지 발송
- 메시지 템플릿 기반 전송 지원
- 알림 개수/목록 조회
### 6.4 파일 및 첨부파일 기능
주요 URI:
- `/comn/file/downloadFile.do`
- `/comn/file/getImage.do`
- `/comn/file/deleteFile.json`
- `/comn/file/imgUpload.json`
- `/comn/file/fileUpload.json`
주요 동작:
- 첨부파일 메타데이터는 DB에 저장
- 실제 파일은 환경별 파일 저장 경로에 저장
- 에디터용 이미지/파일 업로드 지원
- 이미지 미존재 시 기본 이미지 대체 처리
### 6.5 게시판/댓글/신고 기능
주요 URI:
- `/cmty/bbs/pst/pstList.do`
- `/cmty/bbs/pst/pstDtl.do`
- `/cmty/bbs/pst/pstReg.do`
- `/cmty/bbs/pst/pstMdfcn.do`
- `/cmty/bbs/pst/pstReply.do`
- `/cmty/bbs/pst/deletePst.do`
- `/cmty/bbs/pst/download.do`
- `/cmty/bbs/pst/pstList.json`
댓글 관련 URI:
- `/cmty/cmnt/writeCmnt.json`
- `/cmty/cmnt/cmntList.json`
- `/cmty/cmnt/replyCmnt.json`
- `/cmty/cmnt/cmntDtl.json`
- `/cmty/cmnt/modifyCmnt.json`
- `/cmty/cmnt/deleteCmnt.json`
- `/cmty/cmnt/cmntRcmdtn.json`
게시글 신고 URI:
- `/comn/dclr/writePstDclr.json`
주요 동작:
- 게시판별 권한 코드에 따른 조회/등록/수정/삭제/답글/다운로드/댓글 권한 통제
- 게시판 템플릿 코드(`BT001`, `BT002`)에 따른 화면 분기
- 공지 여부, 비공개 여부, 게시 기간 설정 지원
- 첨부파일 업로드/다운로드 지원
- 댓글 목록, 답글, 추천 기능 지원
- 본문 저장 전 스크립트 제거 처리 수행
### 6.6 FAQ 기능
주요 URI:
- `/cmty/faq/faqList.do`
- `/cmty/faq/faqDtl.do`
- `/cmty/faq/faqReg.do`
- `/cmty/faq/faqMdfcn.do`
- `/cmty/faq/deleteFaq.do`
관리자 FAQ URI:
- `/mfuc/system/faq/faqListMng.do`
- `/mfuc/system/faq/faqListMng.json`
- `/mfuc/system/faq/mngrFaqReg.do`
- `/mfuc/system/faq/faqMdfcn.do`
- `/mfuc/system/faq/deleteFaq.json`
### 6.7 Q&A 기능
코드상 Q&A는 두 갈래로 운영된다.
1. 일반 사이트 Q&A
2. 교실/수업형 Q&A
대표 URI:
- 일반 Q&A
- `/cmty/qna/qnaList.do`
- `/cmty/qna/qnaDtl.do`
- `/cmty/qna/qnaReg.do`
- `/cmty/qna/qnaMdfcn.do`
- `/cmty/qna/deleteQstn.do`
- `/cmty/qna/qstnAnsReg.do`
- 수업형 Q&A
- `/cmty/qna/clas/tcher/tcherQstnList.do`
- `/cmty/qna/clas/stdnt/stdntSelfQstnList.do`
- `/cmty/qna/clas/stdnt/stdntQstnReg.do`
- `/cmty/qna/clas/stdnt/stdntPreQstnReg.do`
- `/cmty/qna/clas/stdnt/writeStdntQstn.json`
- `/cmty/qna/clas/tcher/modifyTcherQstnAns.json`
- `/cmty/qna/clas/stdnt/modifyStdntQstn.json`
- `/cmty/qna/clas/qstnDownload.do`
- `/cmty/qna/clas/ansDownload.do`
주요 동작:
- 교사/관리자/모니터/학생 권한에 따라 접근 제어
- 질문 첨부파일과 답변 첨부파일을 분리 관리
- 일부 수정/등록 요청은 세션 기반 수동 토큰 검증 수행
- 질문 유형/상태는 공통 상세코드에서 조회
### 6.8 설문 기능
주요 URI:
- `/cmty/srvy/srvyList.do`
- `/cmty/srvy/srvyReg.do`
- `/cmty/srvy/srvyMdfcn.do`
- `/cmty/srvy/writeMngrSrvy.json`
- `/cmty/srvy/modifyMngrSrvy.json`
- `/cmty/srvy/deleteSrvy.json`
- `/cmty/srvy/deleteSrvyQstn.json`
- `/cmty/srvy/srvySbmsn.do`
- `/cmty/srvy/insertSrvySbmsn.json`
- `/cmty/srvy/searchSrvy.json`
주요 동작:
- 사이트 설문 목록 조회
- 설문 등록/수정/삭제
- 설문 질문 삭제
- 응답자 중복 참여 방지
- 설문 자동완성 검색 지원
### 6.9 사이트 운영/CMS 기능
#### 사이트 정보 및 정책 관리
주요 URI:
- `/mfuc/system/site/siteInfo.do`
- `/mfuc/system/site/modifySiteInfo.do`
- `/mfuc/system/site/modifySiteInfo.json`
- `/comn/site/sitePolicyList.do`
- `/comn/site/sitePolicy.do`
주요 동작:
- 사이트 상세 정보 수정
- 도메인 목록 관리
- 정책 변경 시 이력형 관리
#### 페이지 편집
주요 URI:
- `/mfuc/system/pge/pageEdit.do`
- `/mfuc/system/pge/savePge.json`
주요 동작:
- 페이지 정보 DB 저장
- HTML/CSS/JS 소스를 실제 파일 경로와 동기화
- 파일이 없으면 DB 소스를 복구용 데이터로 사용
#### 메뉴 관리
주요 URI:
- `/mfuc/system/menu/menuListMng.do`
- `/mfuc/system/menu/menuReg.do`
- `/mfuc/system/menu/menuRegist.json`
- `/mfuc/system/menu/menuMdfcn.do`
- `/mfuc/system/menu/menuMdfcn.json`
- `/mfuc/system/menu/menuReactvtn.do`
#### 팝업/배너 관리
주요 URI:
- 사용자 팝업:
- `/comn/popup/windowPopup.do`
- 관리자 팝업:
- `/mfuc/system/popup/popupListMng.do`
- `/mfuc/system/popup/popupReg.do`
- `/mfuc/system/popup/popupMdfcn.do`
- `/mfuc/system/popup/deletePopup.do`
- 관리자 배너:
- `/mfuc/system/banner/bannerListMng.do`
- `/mfuc/system/banner/bannerReg.do`
- `/mfuc/system/banner/bannerMdfcn.do`
- `/mfuc/system/banner/deleteBanner.do`
- `/mfuc/system/banner/uploadImage.json`
### 6.10 관리자 백오피스 기능
`/mfuc/system/**` 영역에서 확인되는 주요 운영 기능은 다음과 같다.
- 관리자 대시보드
- 사용자 관리
- 게시판/공지 관리
- 댓글 관리
- FAQ 관리
- Q&A 관리
- 설문 관리
- 사이트 관리
- 메뉴 관리
- 페이지 관리
- 팝업/배너 관리
- 공통코드 관리
- 메시지/템플릿 관리
- 관리자 접근 IP 제한 관리
- 권한 관련 화면
- 운영 로그 관리
대표 URI 예시:
- `/mfuc/system/user/userListMng.do`
- `/mfuc/system/bbs/info/mngrBbsList.do`
- `/mfuc/system/bbs/pst/mngrBbsSntncList.do`
- `/mfuc/system/bbs/pst/mngrNotice.do`
- `/mfuc/system/cd/cmmnCdListMng.do`
- `/mfuc/system/cd/cmmnDtlCdList.do`
- `/mfuc/system/acs/mngrIpLmtMng.do`
- `/mfuc/system/msg/msgTrsmListMng.do`
- `/mfuc/system/msg/tmplat/msgTmplatList.do`
### 6.11 통계 및 로그 기능
통계 영역 URI:
- `/scnt/intro/statsSumryPge.do`
- `/scnt/conts/contsStats.do`
- `/scnt/clg/cntnStats.do`
- `/scnt/msg/msgStats.do`
- `/scnt/plg/pstStats.do`
- `/scnt/srch/srchKwrdSttusStats.do`
- `/scnt/srvy/srvyStats.do`
통계 범주:
- 콘텐츠 통계
- 콘텐츠 접근 통계
- 메시지 통계
- 게시물 통계
- 검색어 통계
- 설문 통계
- 요약 통계
운영 로그 URI 예시:
- `/mfuc/system/log/api/apiCommLog.do`
- `/mfuc/system/log/cd/cmmnCdChgLog.do`
- `/mfuc/system/log/conts/userContsAtnlcLog.do`
- `/mfuc/system/log/err/srvrErrLog.do`
- `/mfuc/system/log/prvc/prvcAcsDelHstryList.do`
- `/mfuc/system/log/prvc/userInfoChgHstryList.do`
- `/mfuc/system/log/prvc/authrtChgHstryList.do`
로그 범주:
- 웹 접근 로그
- 로그인 관련 로그
- 개인정보 변경/삭제 이력
- 코드 변경 이력
- API 사용 이력
- 서버 에러 로그
- 콘텐츠 접근 로그
## 7. 보안 및 권한 구조
### 7.1 인증/인가 방식
Spring Security + eGov Security 기반의 DB 인증 구조를 사용한다.
대표 설정:
- 로그인 URL: `/comn/user/login/loginUser.do`
- 로그인 실패 URL: `/comn/user/login/loginUser.do?login_error=1`
- 접근 거부 URL: `/authrt/acsReject.do`
보안 질의에 사용되는 대표 테이블:
- `TN_USER`
- `TN_ROLE_INFO`
- `TN_AUTHRT_ROLE_REL`
- `TN_AUTHRT_SCLSRT`
코드에서 직접 확인되는 대표 권한:
- `ROLE_MANAGER`
- `ROLE_TEACHER`
- `ROLE_MONITOR`
이 외에도 게시판별 권한 코드는 게시판 메타데이터 기반으로 별도 적용된다.
### 7.2 인터셉터 기반 공통 처리
- `WebLogInterceptor`
- 요청 URL, 요청자 ID, 요청자 IP 기록
- `MenuPageInterceptor`
- `menuSn` 기준 메뉴 트리 주입
- `LocaleChangeInterceptor`
- `lang` 파라미터로 locale 전환
### 7.3 공통 예외/검증 처리
- `CommonDefaultController`
- JSON 검증 오류 조립
- 공통 에러 로깅
- `SiteExceptionResolver`
- 데이터 접근 예외 및 프레임워크 예외의 에러 화면 매핑
## 8. 데이터 및 리소스 구조
### 8.1 MyBatis 매퍼 구조
- Mapper Interface:
- `src/main/java/kr/iotdoor/**/mapper`
- SQL XML:
- `src/main/resources/egovframework/mapper/kr/iotdoor/**/*_SQL.xml`
Java 패키지 구조와 SQL XML 구조가 대체로 1:1에 가깝게 대응되어 있다.
### 8.2 파일 저장 구조
코드상 파일 저장은 첨부파일 저장소와 콘텐츠 저장소로 구분된다.
- 첨부파일 기준 프로퍼티:
- `Globals.fileStorePath`
- 콘텐츠 저장 기준 프로퍼티:
- `Globals.contentsStorePath`
- 웹 노출 루트:
- `Globals.contentsRoot`
대표 저장 패턴:
- 게시판 첨부: `BBS/{bbsIdntfNo}/{날짜}`
- Q&A 첨부: `QNA/{qnaIdntfNo}/{날짜}`
- 에디터 업로드: `{구분코드}/IMAGE`, `{구분코드}/FILE`
- 프로필/콘텐츠 이미지: 콘텐츠 루트 URL 생성 방식
### 8.3 외부 연동
Hypermeta 연동이 구현되어 있다.
주요 URI:
- `/comn/api/getContsHypermeta.json`
- `/comn/api/getMetaHypermeta.json`
서비스 호출 대상:
- `/api/contents/search`
- `/api/iotdoor/Lesson/list?pageIndex=...`
기준 URL은 `Globals.HYPERMETA_URL` 프로퍼티를 사용한다.
## 9. 프로젝트 규모 요약
현재 저장소 기준 집계 값은 다음과 같다.
| 항목 | 수량 |
| --- | ---: |
| Java 소스 파일 | 392 |
| Controller 클래스 | 59 |
| Service 구현 클래스 | 51 |
| Mapper 인터페이스 | 54 |
| MyBatis SQL XML | 53 |
| Thymeleaf HTML 템플릿 | 156 |
| `WEB-INF/jsp` 하위 JSP | 0 |
| `src/test` 하위 Java 테스트 | 0 |
해석:
- 규모상 중대형 웹애플리케이션에 해당
- 도메인 분리가 비교적 명확함
- JSP Resolver는 남아 있으나 실제 화면은 사실상 Thymeleaf 중심
## 10. 운영 및 유지보수 유의사항
소스 분석 기준으로 아래 항목은 반드시 운영 검토가 필요하다.
1. Spring Security CSRF가 전역 비활성화되어 있다.
- 위치: `src/main/resources/egovframework/spring/com/context-security.xml`
- 현재 설정: `csrf="false"`
- 일부 화면은 수동 세션 토큰을 쓰고 일부는 쓰지 않아 일관성이 부족하다.
2. 비밀번호 해시 방식이 `plaintext` 로 설정되어 있다.
- 위치: `src/main/resources/egovframework/spring/com/context-security.xml`
- 현재 설정: `hash="plaintext"`
- 운영 환경 적용 여부를 재검토해야 한다.
3. 업로드 최대 용량 설정이 서로 다르다.
- Multipart Resolver: `50000000` 바이트
- 전역 프로퍼티: `1048576` 바이트
- 실제 정책과 코드 동작을 일치시킬 필요가 있다.
4. 자동화 테스트 자산이 사실상 없다.
- `src/test` 하위 Java 테스트 수: 0
- Maven Surefire도 `skipTests=true` 로 설정되어 있다.
5. 저장소 내 설정 파일에 민감한 운영 값이 포함되어 있다.
- 개발용 계정 보조값
- 암호화 관련 값
- 로컬 환경 연결 정보
- 외부 보안 설정으로 분리하는 것이 바람직하다.
6. 스케줄링 관련 구현은 있으나 실제 활성화 여부 확인이 필요하다.
- Quartz 의존성과 스케줄 서비스 클래스가 존재
- 일부 XML 트리거 설정은 주석 처리되어 있다.
7. 페이지 편집 기능은 배포된 파일 시스템에 직접 읽기/쓰기를 수행한다.
- 배포 계정 권한이 필요
- 운영 배포 파이프라인과 충돌 여부를 확인해야 한다.
## 11. 후속 문서화 권장 항목
본 문서를 기반으로 아래 문서를 추가 작성하면 운영 인수인계 품질이 높아진다.
- DB 테이블 명세서 및 ERD
- 화면 목록 및 메뉴 트리 문서
- 권한 매트릭스
- 외부 API 명세서
- 배치/스케줄 운영 정책서
- 배포 구조도 및 시크릿 관리 정책
- 테스트 시나리오 및 회귀 점검 목록
## 12. 결론
IOTDOOR는 `사이트 운영 CMS + 커뮤니티 기능 + 통계/로그 백오피스` 가 결합된 통합 포털 시스템이다.
도메인 분리와 계층 구조는 비교적 명확하며, 운영형 포털로 확장 가능한 구조를 가지고 있다.
다만 보안 설정, 민감정보 관리, 테스트 부재, 업로드 정책 불일치, 스케줄 활성화 여부 등은 운영 안정화를 위해 우선 점검이 필요한 항목이다.
+2131
View File
File diff suppressed because it is too large Load Diff
+2117
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
D:\Projects\998.IOTDOOR\iotdoor_v5>if "Windows_NT" == "Windows_NT" setlocal
D:\Projects\998.IOTDOOR\iotdoor_v5>set DIRNAME=D:\Projects\998.IOTDOOR\iotdoor_v5\
D:\Projects\998.IOTDOOR\iotdoor_v5>if "D:\Projects\998.IOTDOOR\iotdoor_v5\" == "" set DIRNAME=.
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_BASE_NAME=gradlew
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_HOME=D:\Projects\998.IOTDOOR\iotdoor_v5\
D:\Projects\998.IOTDOOR\iotdoor_v5>for %i in ("D:\Projects\998.IOTDOOR\iotdoor_v5\") do set APP_HOME=%~fi
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_HOME=D:\Projects\998.IOTDOOR\iotdoor_v5\
D:\Projects\998.IOTDOOR\iotdoor_v5>set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
D:\Projects\998.IOTDOOR\iotdoor_v5>if defined JAVA_HOME goto findJavaFromJavaHome
D:\Projects\998.IOTDOOR\iotdoor_v5>set JAVA_HOME=C:\Program Files\Java\jdk-17
D:\Projects\998.IOTDOOR\iotdoor_v5>set JAVA_EXE=C:\Program Files\Java\jdk-17 /bin/java.exe
D:\Projects\998.IOTDOOR\iotdoor_v5>if exist "C:\Program Files\Java\jdk-17 /bin/java.exe" goto execute
D:\Projects\998.IOTDOOR\iotdoor_v5>echo. 1>&2
D:\Projects\998.IOTDOOR\iotdoor_v5>echo ERROR: JAVA_HOME is set to an invalid directory: C:\Program Files\Java\jdk-17 1>&2
ERROR: JAVA_HOME is set to an invalid directory: C:\Program Files\Java\jdk-17
D:\Projects\998.IOTDOOR\iotdoor_v5>echo. 1>&2
D:\Projects\998.IOTDOOR\iotdoor_v5>echo Please set the JAVA_HOME variable in your environment to match the 1>&2
Please set the JAVA_HOME variable in your environment to match the
D:\Projects\998.IOTDOOR\iotdoor_v5>echo location of your Java installation. 1>&2
location of your Java installation.
D:\Projects\998.IOTDOOR\iotdoor_v5>goto fail
D:\Projects\998.IOTDOOR\iotdoor_v5>rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
D:\Projects\998.IOTDOOR\iotdoor_v5>rem the _cmd.exe /c_ return code!
D:\Projects\998.IOTDOOR\iotdoor_v5>set EXIT_CODE=0
D:\Projects\998.IOTDOOR\iotdoor_v5>if 0 EQU 0 set EXIT_CODE=1
D:\Projects\998.IOTDOOR\iotdoor_v5>if not "" == "" exit 1
D:\Projects\998.IOTDOOR\iotdoor_v5>exit /b 1
+61
View File
@@ -0,0 +1,61 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.11'
}
group = 'kr.iotdoor'
version = '0.0.1-SNAPSHOT'
description = 'IOTDOOR modernized Spring Boot application'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
ext {
mybatisSpringBootVersion = '3.0.4'
}
dependencies {
implementation platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
runtimeOnly platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
annotationProcessor platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
testImplementation platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation "org.mybatis.spring.boot:mybatis-spring-boot-starter:${mybatisSpringBootVersion}"
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-expression-processor:3.2.0'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('bootRun') {
// Let bootRun read templates/static resources directly from src/main/resources.
sourceResources sourceSets.main
systemProperty 'spring.devtools.restart.enabled', 'true'
}
tasks.named('test') {
useJUnitPlatform()
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+554
View File
@@ -0,0 +1,554 @@
HTTP/1.1 200
Set-Cookie: JSESSIONID=0D10A051D1AEAE1FBA23B6B1217704A1; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/html;charset=UTF-8
Content-Language: ko-KR
Transfer-Encoding: chunked
Date: Wed, 15 Apr 2026 16:11:49 GMT
Connection: close
2000
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="description" content="">
<meta property="og:title" content="">
<meta property="og:description" content="">
<meta property="og:url" content="">
<meta property="og:type" content="website">
<meta property="og:image" content="">
<title>국제오토텍</title>
<!-- Favicon -->
<link href="/resources/kr/iotdoor/comn/site/layout/image/template/favicon.ico" rel="icon" />
<!-- Google Web Fonts -->
<link rel="preconnect" href="//fonts.googleapis.com" />
<link rel="preconnect" href="//fonts.gstatic.com" crossorigin />
<link href="//fonts.googleapis.com/css2?family=Open+Sans:wght@400;500&family=Roboto:wght@500;700;900&display=swap" rel="stylesheet"/>
<!-- Icon Font Stylesheet -->
<link href="//cdnjs.cloudflare.com/ajax/libs/font-awesome/5.10.0/css/all.min.css" rel="stylesheet"/>
<link href="//cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css" rel="stylesheet"/>
<!-- Libraries Stylesheet -->
<link href="/resources/lib/animate/animate.min.css" rel="stylesheet" />
<link href="/resources/lib/owlcarousel/assets/owl.carousel.min.css" rel="stylesheet"/>
<link href="/resources/lib/lightbox/css/lightbox.min.css" rel="stylesheet" />
<!-- Customized Bootstrap Stylesheet -->
<link href="/resources/kr/iotdoor/comn/site/layout/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/resources/kr/iotdoor/comn/site/layout/css/iotdoor.css?v=202404021718">
<!-- Template Stylesheet -->
<link href="/resources/kr/iotdoor/comn/site/layout/css/templateStyle.css?220712" rel="stylesheet" />
<!-- <link th:if="${mainYn == 'Y' && popupList.size > 0}" rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/popup/css/popupListLayout.css}"> -->
<!-- <script th:if="${not #strings.isEmpty(checkMessage)}">alert('');</script> -->
<script>const global_isMber = false;</script>
<script>
const global_errCd = '';
</script>
<meta name="_csrf" content="SLzppFw0SGIOkKBdGipf2tA8MsSw_77GJf--F0dMYWmQg6PFf4nal20Ae1YjpZVleAdr6-ANH6aJmofrQM6JJCYoWVmotpKm">
<meta name="_csrf_header" content="X-CSRF-TOKEN">
<meta name="theme-color" content="#ffffff">
<style>
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 40px;
height: 40px;
border-radius: 50%;
border-left-color: #09f;
margin: 0 auto;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="skip-nav">
<a href="#site-contents" class="visually-hidden">본문 바로가기</a>
</div>
<!-- Spinner Start -->
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Spinner End -->
<!-- Topbar Start -->
<div class="container-fluid bg-dark p-0">
<div class="row gx-0 d-none d-lg-flex">
<div class="col-lg-7 px-5 text-start">
<div class="h-100 d-inline-flex align-items-center me-4">
<small class="fa fa-map-marker-alt text-primary me-2"></small>
<small>대전광역시 서구 유등로 55 102호</small>
</div>
<div class="h-100 d-inline-flex align-items-center">
<small class="far fa-clock text-primary me-2"></small>
<small>연중무휴 24시간 영업</small>
</div>
</div>
<div class="col-lg-5 px-5 text-end">
<div class="h-100 d-inline-flex align-items-center me-4">
<small class="fa fa-phone-alt text-primary me-2"></small>
<small>
<span>042-585-1749</span>
<span>&nbsp;&nbsp;</span>
<span>010-3476-1749</span>
</small>
</div>
<div class="h-100 d-inline-flex align-items-center mx-n2">
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-facebook-f"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-twitter"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-linkedin-in"></i></a>
<a class="btn btn-square btn-link rounded-0" href=""><i class="fab fa-instagram"></i></a>
</div>
</div>
</div>
</div>
<!-- Topbar End -->
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top p-0">
<a href="/" class="navbar-brand d-flex align-items-center border-end px-4 px-lg-5">
<!-- <h2 class="m-0 text-primary">국제오토텍</h2> -->
<img src="/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg" alt="로고" style="height:75px;">
</a>
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav ms-auto p-4 p-lg-0">
<a href="/" class="nav-item nav-link">Home</a>
<a href="/" class="nav-item nav-link">About</a>
<a href="/portfolio.do" class="nav-item nav-link">Portfolio</a>
<a href="/contact.do" class="nav-item nav-link">Contact</a>
<a href="/mfuc/index.do" class="nav-item nav-link">ADMIN</a>
</div>
<!--<a href="" class="btn btn-primary rounded-0 py-4 px-lg-5 d-none d-lg-block">Get A Quote<i class="fa fa-arrow-right ms-3"></i></a>-->
</div>
</nav>
<div id="site-contents" class="content-wrap">
<!-- About Start -->
<div class="container-fluid bg-light overflow-hidden my-5 px-lg-0">
<div class="container about px-lg-0">
<div class="row g-0 mx-lg-0">
<div
class="col-lg-6 ps-lg-0 wow fadeIn"
data-wow-delay="0.1s"
style="min-height: 400px"
>
<div class="position-relative h-100">
<img
class="position-absolute img-fluid w-100 h-100"
src="/resources/kr/iotdoor/comn/site/layout/image/template/about.jpg"
style="object-fit: cover"
alt=""
/>
</div>
</div>
<div
class="col-lg-6 about-text py-5 wow fadeIn"
data-wow-delay="0.5s"
>
<div class="p-lg-5 pe-lg-0">
<h6 class="text-primary">About Us</h6>
<h1 class="mb-4">
국제오토텍자동문
</h1>
<p>
OVER-HEAD DOOR, AUTO GATE
</p>
<p>
SLIDING DOOR, PARKING DOOR, ROLLER SHUTTER
</p>
<p>
<i class="fa fa-check-circle text-primary me-3"></i>
슬라이딩자동문
</p>
<p>
<i class="fa fa-check-circle text-primary me-3"></i>
산업용자동문(고속스피드도어)
</p>
<p>
<i class="fa fa-check-circle text-primary me-3"></i>
내풍압셔터(알
2000
루미늄셔터)
</p>
<p>
<i class="fa fa-check-circle text-primary me-3"></i>
오버헤드도어
</p>
<!--<a
href=""
class="btn btn-primary rounded-pill py-3 px-5 mt-3"
>Explore More</a
>-->
</div>
</div>
</div>
</div>
</div>
<!-- About End -->
<!-- Service Start -->
<div class="container-xxl py-5">
<div class="container">
<div
class="text-center mx-auto mb-5 wow fadeInUp"
data-wow-delay="0.1s"
style="max-width: 600px"
>
<h6 class="text-primary">Our Services</h6>
<h1 class="mb-4">
고객님이 원하시는 서비스는<br/>무엇이든 가능합니다
</h1>
</div>
<div class="row g-4">
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">슬라이딩자동문</h4>
<!-- <p>슬라이드 오토도어를 전문적으로 다룹니다.</p> -->
<a class="small fw-medium" href="/portfolio.do"
>Read More<i class="fa fa-arrow-right ms-2"></i></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
</div>
<h4 class="mb-3">산업용자동문</h4>
<a class="small fw-medium" href="/portfolio.do"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">고급산업용자동문</h4>
<a class="small fw-medium" href="/portfolio.do"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">내풍압고속셔터</h4>
<a class="small fw-medium" href="/portfolio.do"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
</div>
<h4 class="mb-3">오버헤드도어</h4>
<a class="small fw-medium" href="/portfolio.do"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">방화용슬라이딩자동문</h4>
<a class="small fw-medium" href="/portfolio.do"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Service End -->
<!-- Feature Start -->
<div class="container-fluid bg-light overflow-hidden my-5 px-lg-0">
<div class="container feature px-lg-0">
<div class="row g-0 mx-lg-0">
2000
<div
class="col-lg-6 feature-text py-5 wow fadeIn"
data-wow-delay="0.1s"
>
<div class="p-lg-5 ps-lg-0">
<h6 class="text-primary">Why Choose Us!</h6>
<h1 class="mb-4">
고객여러분의 가치있는 선택에 보답하겠습니다.
</h1>
<p class="mb-4 pb-2">
자동문 전문 업체로 철거.시공.A/S까지 원스톱으로 상담해드립니다.
<br>
전문기계교체상담.오퍼레이터교체.판매문의 환영합니다.
</p>
<div class="row g-4">
<div class="col-6">
<div class="d-flex align-items-center">
<div
class="btn-lg-square bg-primary rounded-circle"
>
<i
class="fa fa-check text-white"
></i>
</div>
<div class="ms-4">
<p class="mb-0">Quality</p>
<h5 class="mb-0">Services</h5>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div
class="btn-lg-square bg-primary rounded-circle"
>
<i
class="fa fa-user-check text-white"
></i>
</div>
<div class="ms-4">
<p class="mb-0">Expert</p>
<h5 class="mb-0">Workers</h5>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div
class="btn-lg-square bg-primary rounded-circle"
>
<i
class="fa fa-drafting-compass text-white"
></i>
</div>
<div class="ms-4">
<p class="mb-0">Free</p>
<h5 class="mb-0">Consultation</h5>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div
class="btn-lg-square bg-primary rounded-circle"
>
<i
class="fa fa-headphones text-white"
></i>
</div>
<div class="ms-4">
<p class="mb-0">Customer</p>
<h5 class="mb-0">Support</h5>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="col-lg-6 pe-lg-0 wow fadeIn"
data-wow-delay="0.5s"
style="min-height: 400px"
>
<div class="position-relative h-100">
<img
class="position-absolute img-fluid w-100 h-100"
src="/resources/kr/iotdoor/comn/site/layout/image/template/feature.jpg"
style="object-fit: cover"
alt=""
/>
</div>
</div>
</div>
</div>
</div>
<!-- Feature End -->
<!-- Contact Start -->
<div class="container-fluid bg-light overflow-hidden px-lg-0" style="margin: 6rem 0;">
<div class="container contact px-lg-0">
<div class="row g-0 mx-lg-0">
<div class="col-lg-6 p-lg-5 " style="min-height: 300px;">
<div class="position-relative h-100">
<div id="map" style="width:100%;height:500px;"></div>
<code id="snippet" class="snippet"></code>
</div>
</div>
<div class="col-lg-6 contact-text wow fadeIn" data-wow-delay="0.5s">
<div class="p-lg-5 ps-lg-0">
<h6 class="text-primary">Contact Us</h6>
<p class="mb-4">내용을 자세하게 입력해주시면 상담진행시에 좀 더 원활하게 상담이 가능합니다. 연중무휴 24시간 주저말고 남겨주세요. </p>
<form>
<div class="row g-3">
<div class="col-md-6">
<div class="form-floating">
<input type="text" class="form-control" id="name" placeholder="Your Name">
<label for="name">Name</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating">
<input type="email" class="form-control" id="email" placeholder="Your Email" >
<label for="email">Email</label>
</div>
<div id="emailError" style="color:red; font-size:13px; display:none;">
올바른 이메일 형식이 아닙니다.
</div>
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="phone" placeholder="Phone" >
<label for="subject">Phone</label>
</div>
<div id="phoneError" style="color:red; font-size:13px; display:none;">
올바른 휴대폰 형식이 아닙니다.
</div>
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="subject" placeholder="Subject">
<label for="subject">Subject</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
34e
<textarea class="form-control" placeholder="Leave a message here" id="message" style="height: 100px"></textarea>
<label for="message">Message</label>
</div>
</div>
<div class="col-12">
<button id="btnSendMail" class="btn btn-primary rounded-pill py-3 px-5" type="button">Send Message</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Contact End -->
<!-- <?php include_once './common/footer.php'?> -->
<!-- Template Javascript -->
</div>
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'iotdoor-boot'
@@ -0,0 +1,14 @@
package kr.iotdoor.boot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class IotdoorBootApplication {
public static void main(String[] args) {
SpringApplication.run(IotdoorBootApplication.class, args);
}
}
@@ -0,0 +1,167 @@
package kr.iotdoor.boot.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "iotdoor")
public class IotdoorProperties {
private final Site site = new Site();
private final Security security = new Security();
private final File file = new File();
public Site getSite() {
return site;
}
public Security getSecurity() {
return security;
}
public File getFile() {
return file;
}
public static class Site {
private String name;
private String adminTitle;
private String email;
private String phone;
private String phoneAlt;
private String address;
private String summary;
private String description;
private double mapLatitude;
private double mapLongitude;
private String mapClientId;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAdminTitle() {
return adminTitle;
}
public void setAdminTitle(String adminTitle) {
this.adminTitle = adminTitle;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getPhoneAlt() {
return phoneAlt;
}
public void setPhoneAlt(String phoneAlt) {
this.phoneAlt = phoneAlt;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public double getMapLatitude() {
return mapLatitude;
}
public void setMapLatitude(double mapLatitude) {
this.mapLatitude = mapLatitude;
}
public double getMapLongitude() {
return mapLongitude;
}
public void setMapLongitude(double mapLongitude) {
this.mapLongitude = mapLongitude;
}
public String getMapClientId() {
return mapClientId;
}
public void setMapClientId(String mapClientId) {
this.mapClientId = mapClientId;
}
}
public static class Security {
private String adminUsername;
private String adminPassword;
private String adminDisplayName;
public String getAdminUsername() {
return adminUsername;
}
public void setAdminUsername(String adminUsername) {
this.adminUsername = adminUsername;
}
public String getAdminPassword() {
return adminPassword;
}
public void setAdminPassword(String adminPassword) {
this.adminPassword = adminPassword;
}
public String getAdminDisplayName() {
return adminDisplayName;
}
public void setAdminDisplayName(String adminDisplayName) {
this.adminDisplayName = adminDisplayName;
}
}
public static class File {
private String uploadPath;
public String getUploadPath() {
return uploadPath;
}
public void setUploadPath(String uploadPath) {
this.uploadPath = uploadPath;
}
}
}
@@ -0,0 +1,59 @@
package kr.iotdoor.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/index.do",
"/portfolio.do",
"/portfolio/**",
"/contact.do",
"/api/contact",
"/comn/file/view/**",
"/comn/user/login/login.do",
"/comn/user/login/loginUser.do",
"/resources/**",
"/error",
"/actuator/health"
).permitAll()
.requestMatchers("/mfuc/**", "/mngr/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/comn/user/login/loginUser.do")
.loginProcessingUrl("/comn/user/login/actionLogin.do")
.usernameParameter("id")
.passwordParameter("password")
.defaultSuccessUrl("/mfuc/index.do", true)
.failureUrl("/comn/user/login/loginUser.do?login_error=1")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/comn/user/login/logout.do")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.rememberMe(Customizer.withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,82 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.file.StoredFile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface AtchFileDetailMapper {
@Insert("""
INSERT INTO TN_ATCH_FILE_DTL (
ATCH_FILE_IDNTF_NO,
FILE_SN,
FILE_STRG_PATH_NM,
FILE_STRG_NM,
ORGNL_FILE_NM,
FILE_EXTN_NM,
FILE_CN,
FILE_SZ
) VALUES (
#{atchFileIdntfNo},
#{fileSn},
#{fileStrgPathNm},
#{fileStrgNm},
#{orgnlFileNm},
#{fileExtnNm},
#{fileCn},
#{fileSz}
)
""")
int insertAtchFileDtl(StoredFile storedFile);
@Select("""
SELECT COALESCE(MAX(FILE_SN), 0) + 1 AS FILE_SN
FROM TN_ATCH_FILE_DTL
WHERE ATCH_FILE_IDNTF_NO = #{atchFileIdntfNo}
""")
int selectAtchFileDtlNextSn(@Param("atchFileIdntfNo") String atchFileIdntfNo);
@Select("""
SELECT
ATCH_FILE_IDNTF_NO,
FILE_SN,
FILE_STRG_PATH_NM,
FILE_STRG_NM,
ORGNL_FILE_NM,
FILE_EXTN_NM,
FILE_CN,
COALESCE(FILE_SZ, 0) AS FILE_SZ
FROM TN_ATCH_FILE_DTL
WHERE ATCH_FILE_IDNTF_NO = #{atchFileIdntfNo}
""")
List<StoredFile> selectAtchFileDtlList(@Param("atchFileIdntfNo") String atchFileIdntfNo);
@Select("""
SELECT
ATCH_FILE_IDNTF_NO,
FILE_SN,
FILE_STRG_PATH_NM,
FILE_STRG_NM,
ORGNL_FILE_NM,
FILE_EXTN_NM,
FILE_CN,
COALESCE(FILE_SZ, 0) AS FILE_SZ
FROM TN_ATCH_FILE_DTL
WHERE ATCH_FILE_IDNTF_NO = #{atchFileIdntfNo}
AND FILE_SN = #{fileSn}
""")
StoredFile selectAtchFileDtl(@Param("atchFileIdntfNo") String atchFileIdntfNo, @Param("fileSn") int fileSn);
@Delete("""
DELETE FROM TN_ATCH_FILE_DTL
WHERE ATCH_FILE_IDNTF_NO = #{atchFileIdntfNo}
AND FILE_SN = #{fileSn}
""")
int deleteAtchFileDtl(@Param("atchFileIdntfNo") String atchFileIdntfNo, @Param("fileSn") int fileSn);
}
@@ -0,0 +1,30 @@
package kr.iotdoor.boot.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface AtchFileMapper {
@Insert("""
INSERT INTO TN_ATCH_FILE (
ATCH_FILE_IDNTF_NO,
CRT_DT,
DEL_YN
) VALUES (
#{atchFileIdntfNo},
CURRENT_TIMESTAMP,
'N'
)
""")
int insertAtchFile(@Param("atchFileIdntfNo") String atchFileIdntfNo);
@Update("""
UPDATE TN_ATCH_FILE SET
DEL_YN = 'Y'
WHERE ATCH_FILE_IDNTF_NO = #{atchFileIdntfNo}
""")
int deleteAtchFile(@Param("atchFileIdntfNo") String atchFileIdntfNo);
}
@@ -0,0 +1,69 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.contact.ContactSubmission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ContactMapper {
@Insert("""
INSERT INTO TN_CONTACT_INQRY (
CONTACT_IDNTF_NO,
NM,
EML_ADDR,
TELNO,
TTL,
CN,
SUBMIT_DT
) VALUES (
#{contactIdntfNo},
#{name},
#{email},
#{phone},
#{subject},
#{message},
CURRENT_TIMESTAMP
)
""")
int insertContact(ContactSubmission contactSubmission);
@Select("""
SELECT
CONTACT_IDNTF_NO,
NM AS NAME,
EML_ADDR AS EMAIL,
TELNO AS PHONE,
TTL AS SUBJECT,
CN AS MESSAGE,
SUBMIT_DT AS SUBMITTED_AT
FROM TN_CONTACT_INQRY
ORDER BY SUBMIT_DT DESC
LIMIT #{limit}
""")
List<ContactSubmission> selectRecentContacts(@Param("limit") int limit);
@Select("""
SELECT
CONTACT_IDNTF_NO,
NM AS NAME,
EML_ADDR AS EMAIL,
TELNO AS PHONE,
TTL AS SUBJECT,
CN AS MESSAGE,
SUBMIT_DT AS SUBMITTED_AT
FROM TN_CONTACT_INQRY
ORDER BY SUBMIT_DT DESC
""")
List<ContactSubmission> selectAllContacts();
@Select("""
SELECT COUNT(*)
FROM TN_CONTACT_INQRY
""")
int selectContactCount();
}
@@ -0,0 +1,118 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.portfolio.PortfolioItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PortfolioMapper {
@Select("""
SELECT
p.PORTFOLIO_IDNTF_NO,
p.TITLE,
p.CONTENT,
p.THUMBNAIL_ATCH_FILE_IDNTF_NO,
p.THUMBNAIL_FILE_SN,
p.REG_DT AS REGISTERED_AT,
p.INQ_CNT AS VIEW_COUNT,
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
u.USER_NM AS REGISTERED_BY_NAME,
p.DEL_YN,
p.UPD_DT AS UPDATED_AT
FROM TN_PORTFOLIO p
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
WHERE p.DEL_YN = 'N'
ORDER BY p.REG_DT DESC
""")
List<PortfolioItem> selectPortfolioList();
@Select("""
SELECT
p.PORTFOLIO_IDNTF_NO,
p.TITLE,
p.CONTENT,
p.THUMBNAIL_ATCH_FILE_IDNTF_NO,
p.THUMBNAIL_FILE_SN,
p.REG_DT AS REGISTERED_AT,
p.INQ_CNT AS VIEW_COUNT,
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
u.USER_NM AS REGISTERED_BY_NAME,
p.DEL_YN,
p.UPD_DT AS UPDATED_AT
FROM TN_PORTFOLIO p
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
WHERE p.PORTFOLIO_IDNTF_NO = #{portfolioIdntfNo}
AND p.DEL_YN = 'N'
""")
PortfolioItem selectPortfolioDetail(@Param("portfolioIdntfNo") String portfolioIdntfNo);
@Insert("""
INSERT INTO TN_PORTFOLIO (
PORTFOLIO_IDNTF_NO,
TITLE,
THUMBNAIL_ATCH_FILE_IDNTF_NO,
THUMBNAIL_FILE_SN,
CONTENT,
REG_DT,
INQ_CNT,
REG_USER_IDNTF_NO,
DEL_YN,
UPD_DT
) VALUES (
#{portfolioIdntfNo},
#{title},
#{thumbnailAtchFileIdntfNo},
#{thumbnailFileSn},
#{content},
#{registeredAt},
0,
#{registeredByUserIdntfNo},
'N',
NULL
)
""")
int insertPortfolio(PortfolioItem portfolioItem);
@Update("""
UPDATE TN_PORTFOLIO
SET
TITLE = #{title},
THUMBNAIL_ATCH_FILE_IDNTF_NO = #{thumbnailAtchFileIdntfNo},
THUMBNAIL_FILE_SN = #{thumbnailFileSn},
CONTENT = #{content},
UPD_DT = #{updatedAt}
WHERE PORTFOLIO_IDNTF_NO = #{portfolioIdntfNo}
AND DEL_YN = 'N'
""")
int updatePortfolio(PortfolioItem portfolioItem);
@Update("""
UPDATE TN_PORTFOLIO
SET
DEL_YN = 'Y',
UPD_DT = CURRENT_TIMESTAMP
WHERE PORTFOLIO_IDNTF_NO = #{portfolioIdntfNo}
""")
int deletePortfolio(@Param("portfolioIdntfNo") String portfolioIdntfNo);
@Update("""
UPDATE TN_PORTFOLIO
SET INQ_CNT = INQ_CNT + 1
WHERE PORTFOLIO_IDNTF_NO = #{portfolioIdntfNo}
AND DEL_YN = 'N'
""")
int incrementViewCount(@Param("portfolioIdntfNo") String portfolioIdntfNo);
@Select("""
SELECT COUNT(*)
FROM TN_PORTFOLIO
WHERE DEL_YN = 'N'
""")
int countPortfolio();
}
@@ -0,0 +1,79 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.user.UserAccount;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface UserMapper {
@Select("""
SELECT
USER_IDNTF_NO,
USER_ID,
PSWD,
USER_NM,
EML_ADDR,
USER_STTS_CD,
USER_TYPE_CD,
JOIN_DT,
INDVDL_INFO_AGRE_DT,
LOCK_YN,
LGN_FAIL_NMTM,
LOCK_DT,
LAST_LGN_DT
FROM TN_USER
WHERE USER_ID = #{userId}
""")
UserAccount selectByUserId(@Param("userId") String userId);
@Select("""
SELECT COUNT(*)
FROM TN_USER
WHERE USER_ID = #{userId}
""")
int countByUserId(@Param("userId") String userId);
@Insert("""
INSERT INTO TN_USER (
USER_IDNTF_NO,
USER_ID,
PSWD,
USER_NM,
EML_ADDR,
USER_STTS_CD,
USER_TYPE_CD,
JOIN_DT,
INDVDL_INFO_AGRE_DT,
LOCK_YN,
LGN_FAIL_NMTM,
LOCK_DT,
LAST_LGN_DT
) VALUES (
#{userIdntfNo},
#{userId},
#{pswd},
#{userNm},
#{emlAddr},
#{userSttsCd},
#{userTypeCd},
#{joinDt},
#{indvdlInfoAgreDt},
#{lockYn},
#{lgnFailNmtm},
#{lockDt},
#{lastLgnDt}
)
""")
int insertUser(UserAccount userAccount);
@Update("""
UPDATE TN_USER
SET LAST_LGN_DT = CURRENT_TIMESTAMP
WHERE USER_IDNTF_NO = #{userIdntfNo}
""")
int updateLastLoginDt(@Param("userIdntfNo") String userIdntfNo);
}
@@ -0,0 +1,9 @@
package kr.iotdoor.boot.model.admin;
public record MigrationFeature(
String name,
String status,
String summary,
String routes
) {
}
@@ -0,0 +1,30 @@
package kr.iotdoor.boot.model.contact;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record ContactRequest(
@NotBlank(message = "이름은 필수입니다.")
@Size(max = 60, message = "이름은 60자 이하여야 합니다.")
String name,
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
@Size(max = 120, message = "이메일은 120자 이하여야 합니다.")
String email,
@NotBlank(message = "연락처는 필수입니다.")
@Pattern(regexp = "^01[016789]-?\\d{3,4}-?\\d{4}$", message = "올바른 휴대폰번호 형식이 아닙니다.")
String phone,
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 120, message = "제목은 120자 이하여야 합니다.")
String subject,
@NotBlank(message = "문의 내용은 필수입니다.")
@Size(max = 3000, message = "문의 내용은 3000자 이하여야 합니다.")
String message
) {
}
@@ -0,0 +1,70 @@
package kr.iotdoor.boot.model.contact;
import java.time.LocalDateTime;
public class ContactSubmission {
private String contactIdntfNo;
private String name;
private String email;
private String phone;
private String subject;
private String message;
private LocalDateTime submittedAt;
public String getContactIdntfNo() {
return contactIdntfNo;
}
public void setContactIdntfNo(String contactIdntfNo) {
this.contactIdntfNo = contactIdntfNo;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getSubject() {
return subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public LocalDateTime getSubmittedAt() {
return submittedAt;
}
public void setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
}
}
@@ -0,0 +1,86 @@
package kr.iotdoor.boot.model.file;
public class StoredFile {
private String atchFileIdntfNo;
private int fileSn;
private String fileStrgPathNm;
private String fileStrgNm;
private String orgnlFileNm;
private String fileExtnNm;
private String fileCn;
private long fileSz;
private String url;
public String getAtchFileIdntfNo() {
return atchFileIdntfNo;
}
public void setAtchFileIdntfNo(String atchFileIdntfNo) {
this.atchFileIdntfNo = atchFileIdntfNo;
}
public int getFileSn() {
return fileSn;
}
public void setFileSn(int fileSn) {
this.fileSn = fileSn;
}
public String getFileStrgPathNm() {
return fileStrgPathNm;
}
public void setFileStrgPathNm(String fileStrgPathNm) {
this.fileStrgPathNm = fileStrgPathNm;
}
public String getFileStrgNm() {
return fileStrgNm;
}
public void setFileStrgNm(String fileStrgNm) {
this.fileStrgNm = fileStrgNm;
}
public String getOrgnlFileNm() {
return orgnlFileNm;
}
public void setOrgnlFileNm(String orgnlFileNm) {
this.orgnlFileNm = orgnlFileNm;
}
public String getFileExtnNm() {
return fileExtnNm;
}
public void setFileExtnNm(String fileExtnNm) {
this.fileExtnNm = fileExtnNm;
}
public String getFileCn() {
return fileCn;
}
public void setFileCn(String fileCn) {
this.fileCn = fileCn;
}
public long getFileSz() {
return fileSz;
}
public void setFileSz(long fileSz) {
this.fileSz = fileSz;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}
@@ -0,0 +1,48 @@
package kr.iotdoor.boot.model.portfolio;
import jakarta.validation.constraints.NotBlank;
public class PortfolioForm {
private String portfolioIdntfNo;
@NotBlank(message = "제목은 필수입니다.")
private String title;
@NotBlank(message = "내용은 필수입니다.")
private String content;
private String thumbnailAtchFileIdntfNo;
public String getPortfolioIdntfNo() {
return portfolioIdntfNo;
}
public void setPortfolioIdntfNo(String portfolioIdntfNo) {
this.portfolioIdntfNo = portfolioIdntfNo;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getThumbnailAtchFileIdntfNo() {
return thumbnailAtchFileIdntfNo;
}
public void setThumbnailAtchFileIdntfNo(String thumbnailAtchFileIdntfNo) {
this.thumbnailAtchFileIdntfNo = thumbnailAtchFileIdntfNo;
}
}
@@ -0,0 +1,124 @@
package kr.iotdoor.boot.model.portfolio;
import java.time.LocalDateTime;
public class PortfolioItem {
private String portfolioIdntfNo;
private String title;
private String summary;
private String imageUrl;
private String content;
private String thumbnailAtchFileIdntfNo;
private int thumbnailFileSn;
private LocalDateTime registeredAt;
private long viewCount;
private String registeredByUserIdntfNo;
private String registeredByName;
private String delYn;
private LocalDateTime updatedAt;
public String getPortfolioIdntfNo() {
return portfolioIdntfNo;
}
public void setPortfolioIdntfNo(String portfolioIdntfNo) {
this.portfolioIdntfNo = portfolioIdntfNo;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getThumbnailAtchFileIdntfNo() {
return thumbnailAtchFileIdntfNo;
}
public void setThumbnailAtchFileIdntfNo(String thumbnailAtchFileIdntfNo) {
this.thumbnailAtchFileIdntfNo = thumbnailAtchFileIdntfNo;
}
public int getThumbnailFileSn() {
return thumbnailFileSn;
}
public void setThumbnailFileSn(int thumbnailFileSn) {
this.thumbnailFileSn = thumbnailFileSn;
}
public LocalDateTime getRegisteredAt() {
return registeredAt;
}
public void setRegisteredAt(LocalDateTime registeredAt) {
this.registeredAt = registeredAt;
}
public long getViewCount() {
return viewCount;
}
public void setViewCount(long viewCount) {
this.viewCount = viewCount;
}
public String getRegisteredByUserIdntfNo() {
return registeredByUserIdntfNo;
}
public void setRegisteredByUserIdntfNo(String registeredByUserIdntfNo) {
this.registeredByUserIdntfNo = registeredByUserIdntfNo;
}
public String getRegisteredByName() {
return registeredByName;
}
public void setRegisteredByName(String registeredByName) {
this.registeredByName = registeredByName;
}
public String getDelYn() {
return delYn;
}
public void setDelYn(String delYn) {
this.delYn = delYn;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,60 @@
package kr.iotdoor.boot.model.user;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class AdminUserPrincipal implements UserDetails {
private final UserAccount userAccount;
public AdminUserPrincipal(UserAccount userAccount) {
this.userAccount = userAccount;
}
public String getUserIdntfNo() {
return userAccount.getUserIdntfNo();
}
public String getDisplayName() {
return userAccount.getUserNm();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
@Override
public String getPassword() {
return userAccount.getPswd();
}
@Override
public String getUsername() {
return userAccount.getUserId();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return !"Y".equalsIgnoreCase(userAccount.getLockYn());
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return "A".equalsIgnoreCase(userAccount.getUserSttsCd()) || "P".equalsIgnoreCase(userAccount.getUserSttsCd());
}
}
@@ -0,0 +1,124 @@
package kr.iotdoor.boot.model.user;
import java.time.LocalDateTime;
public class UserAccount {
private String userIdntfNo;
private String userId;
private String pswd;
private String userNm;
private String emlAddr;
private String userSttsCd;
private String userTypeCd;
private LocalDateTime joinDt;
private LocalDateTime indvdlInfoAgreDt;
private String lockYn;
private Integer lgnFailNmtm;
private LocalDateTime lockDt;
private LocalDateTime lastLgnDt;
public String getUserIdntfNo() {
return userIdntfNo;
}
public void setUserIdntfNo(String userIdntfNo) {
this.userIdntfNo = userIdntfNo;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getPswd() {
return pswd;
}
public void setPswd(String pswd) {
this.pswd = pswd;
}
public String getUserNm() {
return userNm;
}
public void setUserNm(String userNm) {
this.userNm = userNm;
}
public String getEmlAddr() {
return emlAddr;
}
public void setEmlAddr(String emlAddr) {
this.emlAddr = emlAddr;
}
public String getUserSttsCd() {
return userSttsCd;
}
public void setUserSttsCd(String userSttsCd) {
this.userSttsCd = userSttsCd;
}
public String getUserTypeCd() {
return userTypeCd;
}
public void setUserTypeCd(String userTypeCd) {
this.userTypeCd = userTypeCd;
}
public LocalDateTime getJoinDt() {
return joinDt;
}
public void setJoinDt(LocalDateTime joinDt) {
this.joinDt = joinDt;
}
public LocalDateTime getIndvdlInfoAgreDt() {
return indvdlInfoAgreDt;
}
public void setIndvdlInfoAgreDt(LocalDateTime indvdlInfoAgreDt) {
this.indvdlInfoAgreDt = indvdlInfoAgreDt;
}
public String getLockYn() {
return lockYn;
}
public void setLockYn(String lockYn) {
this.lockYn = lockYn;
}
public Integer getLgnFailNmtm() {
return lgnFailNmtm;
}
public void setLgnFailNmtm(Integer lgnFailNmtm) {
this.lgnFailNmtm = lgnFailNmtm;
}
public LocalDateTime getLockDt() {
return lockDt;
}
public void setLockDt(LocalDateTime lockDt) {
this.lockDt = lockDt;
}
public LocalDateTime getLastLgnDt() {
return lastLgnDt;
}
public void setLastLgnDt(LocalDateTime lastLgnDt) {
this.lastLgnDt = lastLgnDt;
}
}
@@ -0,0 +1,132 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.config.IotdoorProperties;
import kr.iotdoor.boot.mapper.UserMapper;
import kr.iotdoor.boot.model.user.UserAccount;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import java.util.UUID;
@Configuration
public class AdminAccountInitializer {
private static final Logger LOGGER = LoggerFactory.getLogger(AdminAccountInitializer.class);
@Bean
public ApplicationRunner adminUserSeedRunner(
UserMapper userMapper,
JdbcTemplate jdbcTemplate,
IotdoorProperties properties,
PasswordEncoder passwordEncoder
) {
return args -> {
String adminUsername = properties.getSecurity().getAdminUsername();
if (userMapper.countByUserId(adminUsername) > 0) {
UserAccount existing = userMapper.selectByUserId(adminUsername);
if (existing != null && !isBcryptHash(existing.getPswd())) {
jdbcTemplate.update("""
UPDATE TN_USER
SET
PSWD = ?,
USER_NM = ?,
USER_STTS_CD = ?,
USER_TYPE_CD = ?,
LOCK_YN = ?,
LGN_FAIL_NMTM = 0,
LOCK_DT = NULL
WHERE USER_ID = ?
""",
passwordEncoder.encode(properties.getSecurity().getAdminPassword()),
properties.getSecurity().getAdminDisplayName(),
"A",
"ADM",
"N",
adminUsername
);
LOGGER.info("Normalized legacy admin account for Spring Security login. username={}", adminUsername);
}
return;
}
UserAccount userAccount = new UserAccount();
userAccount.setUserIdntfNo(UUID.randomUUID().toString().replace("-", ""));
userAccount.setUserId(adminUsername);
userAccount.setPswd(passwordEncoder.encode(properties.getSecurity().getAdminPassword()));
userAccount.setUserNm(properties.getSecurity().getAdminDisplayName());
userAccount.setEmlAddr("admin@iotdoor.local");
userAccount.setUserSttsCd("A");
userAccount.setUserTypeCd("ADM");
userAccount.setJoinDt(LocalDateTime.now());
userAccount.setIndvdlInfoAgreDt(LocalDateTime.now());
userAccount.setLockYn("N");
userAccount.setLgnFailNmtm(0);
if (hasLegacyColumns(jdbcTemplate)) {
jdbcTemplate.update("""
INSERT INTO TN_USER (
USER_IDNTF_NO,
GROUP_IDNTF_NO,
AUTHRT_CD,
USER_ID,
PSWD,
USER_NM,
EML_ADDR,
USER_STTS_CD,
USER_TYPE_CD,
JOIN_DT,
INDVDL_INFO_AGRE_DT,
LOCK_YN,
LGN_FAIL_NMTM,
LOCK_DT,
LAST_LGN_DT
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
userAccount.getUserIdntfNo(),
"group-boot-default",
"ROLE_ADMIN",
userAccount.getUserId(),
userAccount.getPswd(),
userAccount.getUserNm(),
userAccount.getEmlAddr(),
userAccount.getUserSttsCd(),
userAccount.getUserTypeCd(),
userAccount.getJoinDt(),
userAccount.getIndvdlInfoAgreDt(),
userAccount.getLockYn(),
userAccount.getLgnFailNmtm(),
userAccount.getLockDt(),
userAccount.getLastLgnDt()
);
} else {
userMapper.insertUser(userAccount);
}
LOGGER.info("Seeded default admin account into TN_USER. username={}", adminUsername);
};
}
private boolean hasLegacyColumns(JdbcTemplate jdbcTemplate) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'TN_USER'
AND COLUMN_NAME IN ('GROUP_IDNTF_NO', 'AUTHRT_CD')
""", Integer.class);
return count != null && count == 2;
}
private boolean isBcryptHash(String value) {
if (value == null) {
return false;
}
return value.startsWith("$2a$") || value.startsWith("$2b$") || value.startsWith("$2y$");
}
}
@@ -0,0 +1,29 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.UserMapper;
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import kr.iotdoor.boot.model.user.UserAccount;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AdminUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
public AdminUserDetailsService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserAccount userAccount = userMapper.selectByUserId(username);
if (userAccount == null) {
throw new UsernameNotFoundException("관리자 계정을 찾을 수 없습니다.");
}
return new AdminUserPrincipal(userAccount);
}
}
@@ -0,0 +1,54 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.ContactMapper;
import kr.iotdoor.boot.model.contact.ContactRequest;
import kr.iotdoor.boot.model.contact.ContactSubmission;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class ContactSubmissionService {
private static final Logger LOGGER = LoggerFactory.getLogger(ContactSubmissionService.class);
private final ContactMapper contactMapper;
public ContactSubmissionService(ContactMapper contactMapper) {
this.contactMapper = contactMapper;
}
public ContactSubmission submit(ContactRequest request) {
ContactSubmission submission = new ContactSubmission();
submission.setContactIdntfNo(UUID.randomUUID().toString().replace("-", ""));
submission.setName(request.name().trim());
submission.setEmail(request.email().trim());
submission.setPhone(request.phone().trim());
submission.setSubject(request.subject().trim());
submission.setMessage(request.message().trim());
submission.setSubmittedAt(LocalDateTime.now());
contactMapper.insertContact(submission);
LOGGER.info("Contact inquiry received from {} <{}> / subject={}",
submission.getName(), submission.getEmail(), submission.getSubject());
return submission;
}
public List<ContactSubmission> recentSubmissions(int limit) {
return contactMapper.selectRecentContacts(limit);
}
public List<ContactSubmission> findAll() {
return contactMapper.selectAllContacts();
}
public int count() {
return contactMapper.selectContactCount();
}
}
@@ -0,0 +1,136 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.config.IotdoorProperties;
import kr.iotdoor.boot.mapper.AtchFileDetailMapper;
import kr.iotdoor.boot.mapper.AtchFileMapper;
import kr.iotdoor.boot.model.file.StoredFile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Service
public class FileStorageService {
private static final DateTimeFormatter DATE_PATH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd");
private final IotdoorProperties properties;
private final AtchFileMapper atchFileMapper;
private final AtchFileDetailMapper atchFileDetailMapper;
public FileStorageService(
IotdoorProperties properties,
AtchFileMapper atchFileMapper,
AtchFileDetailMapper atchFileDetailMapper
) {
this.properties = properties;
this.atchFileMapper = atchFileMapper;
this.atchFileDetailMapper = atchFileDetailMapper;
}
@Transactional
public StoredFile storeSingleFile(MultipartFile multipartFile, String directory, String description) {
if (multipartFile == null || multipartFile.isEmpty()) {
throw new IllegalArgumentException("업로드할 파일이 없습니다.");
}
String atchFileIdntfNo = UUID.randomUUID().toString().replace("-", "");
atchFileMapper.insertAtchFile(atchFileIdntfNo);
String sanitizedDirectory = sanitizeDirectory(directory);
String relativeDirectory = sanitizedDirectory + "/" + LocalDate.now().format(DATE_PATH_FORMATTER);
Path rootPath = Paths.get(properties.getFile().getUploadPath()).toAbsolutePath().normalize();
Path storageDirectory = rootPath.resolve(relativeDirectory).normalize();
if (!storageDirectory.startsWith(rootPath)) {
throw new IllegalArgumentException("허용되지 않은 업로드 경로입니다.");
}
try {
Files.createDirectories(storageDirectory);
} catch (IOException exception) {
throw new IllegalStateException("업로드 디렉터리를 생성할 수 없습니다.", exception);
}
String originalFileName = StringUtils.cleanPath(multipartFile.getOriginalFilename());
String extension = extractExtension(originalFileName);
String storedFileName = UUID.randomUUID().toString().replace("-", "");
if (StringUtils.hasText(extension)) {
storedFileName += "." + extension;
}
Path targetPath = storageDirectory.resolve(storedFileName).normalize();
if (!targetPath.startsWith(rootPath)) {
throw new IllegalArgumentException("허용되지 않은 파일 저장 위치입니다.");
}
try {
multipartFile.transferTo(targetPath);
} catch (IOException exception) {
throw new IllegalStateException("파일 저장에 실패했습니다.", exception);
}
StoredFile storedFile = new StoredFile();
storedFile.setAtchFileIdntfNo(atchFileIdntfNo);
storedFile.setFileSn(atchFileDetailMapper.selectAtchFileDtlNextSn(atchFileIdntfNo));
storedFile.setFileStrgPathNm(relativeDirectory.replace('\\', '/'));
storedFile.setFileStrgNm(storedFileName);
storedFile.setOrgnlFileNm(originalFileName);
storedFile.setFileExtnNm(extension);
storedFile.setFileCn(description);
storedFile.setFileSz(multipartFile.getSize());
atchFileDetailMapper.insertAtchFileDtl(storedFile);
storedFile.setUrl(toPublicUrl(atchFileIdntfNo, storedFile.getFileSn()));
return storedFile;
}
@Transactional(readOnly = true)
public StoredFile getStoredFile(String atchFileIdntfNo, int fileSn) {
StoredFile storedFile = atchFileDetailMapper.selectAtchFileDtl(atchFileIdntfNo, fileSn);
if (storedFile != null) {
storedFile.setUrl(toPublicUrl(atchFileIdntfNo, fileSn));
}
return storedFile;
}
@Transactional(readOnly = true)
public Path resolvePath(StoredFile storedFile) {
Path rootPath = Paths.get(properties.getFile().getUploadPath()).toAbsolutePath().normalize();
Path targetPath = rootPath
.resolve(storedFile.getFileStrgPathNm())
.resolve(storedFile.getFileStrgNm())
.normalize();
if (!targetPath.startsWith(rootPath)) {
throw new IllegalArgumentException("허용되지 않은 파일 경로입니다.");
}
return targetPath;
}
public String toPublicUrl(String atchFileIdntfNo, int fileSn) {
return "/comn/file/view/" + atchFileIdntfNo + "/" + fileSn;
}
private String sanitizeDirectory(String directory) {
String value = StringUtils.hasText(directory) ? directory : "common";
value = value.replace('\\', '/').replaceAll("[^a-zA-Z0-9/_-]", "");
value = value.replaceAll("/+", "/");
value = value.replaceAll("^/+", "");
return StringUtils.hasText(value) ? value : "common";
}
private String extractExtension(String fileName) {
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
return "";
}
return fileName.substring(fileName.lastIndexOf('.') + 1);
}
}
@@ -0,0 +1,25 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.UserMapper;
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
@Component
public class LoginAuditService {
private final UserMapper userMapper;
public LoginAuditService(UserMapper userMapper) {
this.userMapper = userMapper;
}
@EventListener
public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
Object principal = event.getAuthentication().getPrincipal();
if (principal instanceof AdminUserPrincipal adminUserPrincipal) {
userMapper.updateLastLoginDt(adminUserPrincipal.getUserIdntfNo());
}
}
}
@@ -0,0 +1,45 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.model.admin.MigrationFeature;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MigrationCatalogService {
public List<MigrationFeature> getFeatures() {
return List.of(
new MigrationFeature(
"공개 메인/정적 페이지",
"운영 가능",
"메인, 연락처, 포트폴리오와 공용 레이아웃을 Boot 3 템플릿으로 재배치했습니다.",
"/, /index.do, /contact.do, /portfolio.do"
),
new MigrationFeature(
"관리자 인증",
"DB 연동 완료",
"TN_USER 기반으로 관리자 로그인/로그아웃과 BCrypt 비밀번호 검증을 연결했습니다.",
"/comn/user/login/loginUser.do, /comn/user/login/actionLogin.do, /mfuc/index.do"
),
new MigrationFeature(
"문의 접수",
"DB 저장 완료",
"문의 API가 TN_CONTACT_INQRY 테이블에 저장되고 관리자 화면에서 최근 문의를 확인할 수 있습니다.",
"/api/contact, /mfuc/contact/list.do"
),
new MigrationFeature(
"포트폴리오",
"DB 연동 완료",
"TN_PORTFOLIO와 첨부파일 모듈을 추가해 공개 목록/상세와 관리자 등록/수정/삭제를 구성했습니다.",
"/portfolio.do, /portfolio/{id}, /mfuc/portfolio/list.do"
),
new MigrationFeature(
"파일 모듈",
"기초 모듈 완료",
"레거시 TN_ATCH_FILE / TN_ATCH_FILE_DTL 스키마를 따라 업로드 및 파일 조회 엔드포인트를 구성했습니다.",
"/mfuc/file/upload.do, /comn/file/view/**"
)
);
}
}
@@ -0,0 +1,147 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.PortfolioMapper;
import kr.iotdoor.boot.model.portfolio.PortfolioForm;
import kr.iotdoor.boot.model.portfolio.PortfolioItem;
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class PortfolioService {
private static final String FALLBACK_IMAGE = "/resources/kr/iotdoor/comn/site/layout/image/common/no_img.svg";
private final PortfolioMapper portfolioMapper;
private final FileStorageService fileStorageService;
public PortfolioService(PortfolioMapper portfolioMapper, FileStorageService fileStorageService) {
this.portfolioMapper = portfolioMapper;
this.fileStorageService = fileStorageService;
}
@Transactional(readOnly = true)
public List<PortfolioItem> getPortfolioItems() {
return portfolioMapper.selectPortfolioList().stream()
.map(this::enrich)
.toList();
}
@Transactional
public PortfolioItem getPortfolioDetail(String portfolioIdntfNo) {
if (portfolioMapper.incrementViewCount(portfolioIdntfNo) == 0) {
return null;
}
PortfolioItem item = portfolioMapper.selectPortfolioDetail(portfolioIdntfNo);
return item == null ? null : enrich(item);
}
@Transactional(readOnly = true)
public List<PortfolioItem> getAdminPortfolioItems() {
return portfolioMapper.selectPortfolioList().stream()
.map(this::enrich)
.toList();
}
@Transactional(readOnly = true)
public PortfolioItem getPortfolioForEdit(String portfolioIdntfNo) {
PortfolioItem item = portfolioMapper.selectPortfolioDetail(portfolioIdntfNo);
return item == null ? null : enrich(item);
}
@Transactional(readOnly = true)
public int count() {
return portfolioMapper.countPortfolio();
}
@Transactional
public void save(PortfolioForm form, MultipartFile thumbnailFile, AdminUserPrincipal principal) {
String thumbnailAtchFileIdntfNo = form.getThumbnailAtchFileIdntfNo();
int thumbnailFileSn = 1;
if (thumbnailFile != null && !thumbnailFile.isEmpty()) {
thumbnailAtchFileIdntfNo = fileStorageService.storeSingleFile(
thumbnailFile,
"portfolio/thumbnail",
"portfolio-thumbnail"
).getAtchFileIdntfNo();
}
if (!StringUtils.hasText(thumbnailAtchFileIdntfNo)) {
throw new IllegalArgumentException("대표 이미지는 필수입니다.");
}
if (StringUtils.hasText(form.getPortfolioIdntfNo())) {
PortfolioItem existing = portfolioMapper.selectPortfolioDetail(form.getPortfolioIdntfNo());
if (existing == null) {
throw new IllegalArgumentException("수정할 포트폴리오를 찾을 수 없습니다.");
}
existing.setTitle(form.getTitle().trim());
existing.setContent(form.getContent().trim());
existing.setThumbnailAtchFileIdntfNo(thumbnailAtchFileIdntfNo);
existing.setThumbnailFileSn(thumbnailFileSn);
existing.setUpdatedAt(LocalDateTime.now());
portfolioMapper.updatePortfolio(existing);
return;
}
PortfolioItem item = new PortfolioItem();
item.setPortfolioIdntfNo(newIdentifier());
item.setTitle(form.getTitle().trim());
item.setContent(form.getContent().trim());
item.setThumbnailAtchFileIdntfNo(thumbnailAtchFileIdntfNo);
item.setThumbnailFileSn(thumbnailFileSn);
item.setRegisteredAt(LocalDateTime.now());
item.setRegisteredByUserIdntfNo(principal.getUserIdntfNo());
portfolioMapper.insertPortfolio(item);
}
@Transactional
public void delete(String portfolioIdntfNo) {
portfolioMapper.deletePortfolio(portfolioIdntfNo);
}
@Transactional(readOnly = true)
public String resolveThumbnailUrl(String atchFileIdntfNo, int fileSn) {
if (!StringUtils.hasText(atchFileIdntfNo)) {
return FALLBACK_IMAGE;
}
return fileStorageService.toPublicUrl(atchFileIdntfNo, fileSn <= 0 ? 1 : fileSn);
}
private PortfolioItem enrich(PortfolioItem item) {
item.setSummary(summarize(item.getContent()));
item.setImageUrl(resolveThumbnailUrl(item.getThumbnailAtchFileIdntfNo(), item.getThumbnailFileSn()));
return item;
}
private String summarize(String content) {
if (!StringUtils.hasText(content)) {
return "";
}
String plainText = content
.replaceAll("<[^>]*>", " ")
.replace("&nbsp;", " ")
.replaceAll("\\s+", " ")
.trim();
if (plainText.length() <= 120) {
return plainText;
}
return plainText.substring(0, 120) + "...";
}
private String newIdentifier() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@@ -0,0 +1,41 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.service.ContactSubmissionService;
import kr.iotdoor.boot.service.MigrationCatalogService;
import kr.iotdoor.boot.service.PortfolioService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AdminController {
private final ContactSubmissionService contactSubmissionService;
private final MigrationCatalogService migrationCatalogService;
private final PortfolioService portfolioService;
public AdminController(
ContactSubmissionService contactSubmissionService,
MigrationCatalogService migrationCatalogService,
PortfolioService portfolioService
) {
this.contactSubmissionService = contactSubmissionService;
this.migrationCatalogService = migrationCatalogService;
this.portfolioService = portfolioService;
}
@GetMapping({"/mfuc/index.do", "/mngr/index.do"})
public String dashboard(Model model) {
model.addAttribute("recentContacts", contactSubmissionService.recentSubmissions(10));
model.addAttribute("contactCount", contactSubmissionService.count());
model.addAttribute("portfolioCount", portfolioService.count());
model.addAttribute("migrationFeatures", migrationCatalogService.getFeatures());
return "kr/iotdoor/boot/admin/dashboard";
}
@GetMapping("/mfuc/contact/list.do")
public String contactList(Model model) {
model.addAttribute("contacts", contactSubmissionService.findAll());
return "kr/iotdoor/boot/admin/contact/list";
}
}
@@ -0,0 +1,83 @@
package kr.iotdoor.boot.web;
import jakarta.validation.Valid;
import kr.iotdoor.boot.model.portfolio.PortfolioForm;
import kr.iotdoor.boot.model.portfolio.PortfolioItem;
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import kr.iotdoor.boot.service.PortfolioService;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class AdminPortfolioController {
private final PortfolioService portfolioService;
public AdminPortfolioController(PortfolioService portfolioService) {
this.portfolioService = portfolioService;
}
@GetMapping("/mfuc/portfolio/list.do")
public String portfolioList(Model model) {
model.addAttribute("portfolioItems", portfolioService.getAdminPortfolioItems());
return "kr/iotdoor/boot/admin/portfolio/list";
}
@GetMapping("/mfuc/portfolio/form.do")
public String portfolioForm(
@RequestParam(name = "portfolioIdntfNo", required = false) String portfolioIdntfNo,
Model model
) {
PortfolioForm form = new PortfolioForm();
if (portfolioIdntfNo != null && !portfolioIdntfNo.isBlank()) {
PortfolioItem portfolio = portfolioService.getPortfolioForEdit(portfolioIdntfNo);
if (portfolio != null) {
form.setPortfolioIdntfNo(portfolio.getPortfolioIdntfNo());
form.setTitle(portfolio.getTitle());
form.setContent(portfolio.getContent());
form.setThumbnailAtchFileIdntfNo(portfolio.getThumbnailAtchFileIdntfNo());
model.addAttribute("currentThumbnailUrl", portfolio.getImageUrl());
model.addAttribute("portfolioMeta", portfolio);
}
}
model.addAttribute("form", form);
return "kr/iotdoor/boot/admin/portfolio/form";
}
@PostMapping("/mfuc/portfolio/save.do")
public String savePortfolio(
@Valid @ModelAttribute("form") PortfolioForm form,
BindingResult bindingResult,
@RequestParam(name = "thumbnailFile", required = false) MultipartFile thumbnailFile,
Authentication authentication,
Model model
) {
if ((thumbnailFile == null || thumbnailFile.isEmpty()) && (form.getThumbnailAtchFileIdntfNo() == null || form.getThumbnailAtchFileIdntfNo().isBlank())) {
bindingResult.rejectValue("thumbnailAtchFileIdntfNo", "required", "대표 이미지는 필수입니다.");
}
if (bindingResult.hasErrors()) {
if (form.getThumbnailAtchFileIdntfNo() != null && !form.getThumbnailAtchFileIdntfNo().isBlank()) {
model.addAttribute("currentThumbnailUrl", portfolioService.resolveThumbnailUrl(form.getThumbnailAtchFileIdntfNo(), 1));
}
return "kr/iotdoor/boot/admin/portfolio/form";
}
portfolioService.save(form, thumbnailFile, (AdminUserPrincipal) authentication.getPrincipal());
return "redirect:/mfuc/portfolio/list.do?saved=1";
}
@PostMapping("/mfuc/portfolio/delete.do")
public String deletePortfolio(@RequestParam("portfolioIdntfNo") String portfolioIdntfNo) {
portfolioService.delete(portfolioIdntfNo);
return "redirect:/mfuc/portfolio/list.do?deleted=1";
}
}
@@ -0,0 +1,40 @@
package kr.iotdoor.boot.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class AuthController {
@GetMapping("/comn/user/login/login.do")
public String loginRedirect() {
return "redirect:/comn/user/login/loginUser.do";
}
@GetMapping("/comn/user/login/loginUser.do")
public String loginPage(
@RequestParam(name = "login_error", required = false) String loginError,
Authentication authentication,
HttpServletRequest request,
Model model
) {
if (authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)) {
return "redirect:/mfuc/index.do";
}
if (loginError != null) {
model.addAttribute("loginMessage", "아이디 또는 비밀번호를 확인해주세요.");
}
String referer = request.getHeader("referer");
model.addAttribute("returnUrl", referer == null ? "/" : referer);
return "kr/iotdoor/comn/user/login/lgnPge";
}
}
@@ -0,0 +1,54 @@
package kr.iotdoor.boot.web;
import jakarta.validation.Valid;
import kr.iotdoor.boot.model.contact.ContactRequest;
import kr.iotdoor.boot.model.contact.ContactSubmission;
import kr.iotdoor.boot.service.ContactSubmissionService;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/contact")
public class ContactApiController {
private final ContactSubmissionService contactSubmissionService;
public ContactApiController(ContactSubmissionService contactSubmissionService) {
this.contactSubmissionService = contactSubmissionService;
}
@PostMapping
public ResponseEntity<Map<String, Object>> submit(@Valid @RequestBody ContactRequest request) {
ContactSubmission submission = contactSubmissionService.submit(request);
Map<String, Object> body = new LinkedHashMap<>();
body.put("success", true);
body.put("message", "문의가 정상적으로 접수되었습니다.");
body.put("submittedAt", submission.getSubmittedAt().toString());
return ResponseEntity.ok(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException exception) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("success", false);
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError error : exception.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(error.getField(), error.getDefaultMessage());
}
body.put("message", errors.values().stream().findFirst().orElse("입력값을 확인해주세요."));
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}
@@ -0,0 +1,88 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.model.file.StoredFile;
import kr.iotdoor.boot.service.FileStorageService;
import org.springframework.core.io.UrlResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Controller
public class FileController {
private final FileStorageService fileStorageService;
public FileController(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
@ResponseBody
@PostMapping("/mfuc/file/upload.do")
public ResponseEntity<Map<String, Object>> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(name = "directory", defaultValue = "common") String directory,
@RequestParam(name = "description", required = false) String description
) {
StoredFile storedFile = fileStorageService.storeSingleFile(file, directory, description);
Map<String, Object> body = new LinkedHashMap<>();
body.put("success", true);
body.put("atchFileIdntfNo", storedFile.getAtchFileIdntfNo());
body.put("fileSn", storedFile.getFileSn());
body.put("orgnlFileNm", storedFile.getOrgnlFileNm());
body.put("url", storedFile.getUrl());
return ResponseEntity.ok(body);
}
@GetMapping("/comn/file/view/{atchFileIdntfNo}/{fileSn}")
public ResponseEntity<UrlResource> viewFile(
@PathVariable String atchFileIdntfNo,
@PathVariable int fileSn
) throws IOException {
StoredFile storedFile = fileStorageService.getStoredFile(atchFileIdntfNo, fileSn);
if (storedFile == null) {
throw new ResponseStatusException(NOT_FOUND);
}
Path filePath = fileStorageService.resolvePath(storedFile);
if (!Files.exists(filePath)) {
throw new ResponseStatusException(NOT_FOUND);
}
UrlResource resource = new UrlResource(filePath.toUri());
String contentType = Files.probeContentType(filePath);
MediaType mediaType = StringUtils.hasText(contentType)
? MediaType.parseMediaType(contentType)
: MediaType.APPLICATION_OCTET_STREAM;
return ResponseEntity.ok()
.contentType(mediaType)
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline()
.filename(storedFile.getOrgnlFileNm(), StandardCharsets.UTF_8)
.build()
.toString()
)
.body(resource);
}
}
@@ -0,0 +1,48 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.config.IotdoorProperties;
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import java.util.List;
@ControllerAdvice(annotations = Controller.class)
public class GlobalViewModelAdvice {
private final IotdoorProperties properties;
public GlobalViewModelAdvice(IotdoorProperties properties) {
this.properties = properties;
}
@ModelAttribute("siteInfo")
public IotdoorProperties.Site siteInfo() {
return properties.getSite();
}
@ModelAttribute("popupList")
public List<Object> popupList() {
return List.of();
}
@ModelAttribute("subMainClass")
public String subMainClass() {
return "";
}
@ModelAttribute("currentAdminName")
public String currentAdminName(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken) {
return "";
}
Object principal = authentication.getPrincipal();
if (principal instanceof AdminUserPrincipal adminUserPrincipal) {
return adminUserPrincipal.getDisplayName();
}
return properties.getSecurity().getAdminDisplayName();
}
}
@@ -0,0 +1,47 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.service.PortfolioService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PublicPageController {
private final PortfolioService portfolioService;
public PublicPageController(PortfolioService portfolioService) {
this.portfolioService = portfolioService;
}
@GetMapping({"/", "/index.do"})
public String home(Model model) {
model.addAttribute("mainYn", "Y");
return "kr/iotdoor/comn/site/mainPge";
}
@GetMapping("/contact.do")
public String contact() {
return "kr/iotdoor/cmty/contact/contact";
}
@GetMapping("/portfolio.do")
public String portfolio(Model model) {
model.addAttribute("portfolioItems", portfolioService.getPortfolioItems());
return "kr/iotdoor/cmty/portfolio/portfolio";
}
@GetMapping("/portfolio/{portfolioId}")
public String portfolioDetail(@org.springframework.web.bind.annotation.PathVariable String portfolioId, Model model) {
var portfolio = portfolioService.getPortfolioDetail(portfolioId);
if (portfolio == null) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND,
"포트폴리오를 찾을 수 없습니다."
);
}
model.addAttribute("portfolio", portfolio);
return "kr/iotdoor/cmty/portfolio/detail";
}
}
+71
View File
@@ -0,0 +1,71 @@
server:
port: ${PORT:9090}
servlet:
session:
timeout: 60m
spring:
application:
name: iotdoor-boot
devtools:
restart:
enabled: true
poll-interval: 2s
quiet-period: 500ms
livereload:
enabled: true
thymeleaf:
cache: false
servlet:
multipart:
max-file-size: ${IOTDOOR_MAX_FILE_SIZE:20MB}
max-request-size: ${IOTDOOR_MAX_REQUEST_SIZE:100MB}
datasource:
# Legacy context.xml resource name: java:comp/env/jdbc/serviceDB
url: ${IOTDOOR_DB_URL:jdbc:mariadb://localhost:3306/iotdoor2_db}
username: ${IOTDOOR_DB_USERNAME:iotdoor_user}
password: ${IOTDOOR_DB_PASSWORD:iotdoor_pass1!}
driver-class-name: ${IOTDOOR_DB_DRIVER:org.mariadb.jdbc.Driver}
hikari:
pool-name: ${IOTDOOR_DB_POOL_NAME:IotdoorHikariPool}
maximum-pool-size: ${IOTDOOR_DB_MAX_POOL_SIZE:20}
minimum-idle: ${IOTDOOR_DB_MIN_IDLE:10}
connection-test-query: SELECT 1
sql:
init:
mode: always
jackson:
time-zone: Asia/Seoul
mvc:
problemdetails:
enabled: true
management:
endpoints:
web:
exposure:
include: health,info
mybatis:
configuration:
map-underscore-to-camel-case: true
iotdoor:
site:
name: 국제오토텍
admin-title: 국제오토텍 관리자
email: iotdoor1749@naver.com
phone: 042-585-1749
phone-alt: 010-3476-1749
address: 대전광역시 서구 유등로 55 102호
summary: 자동문 전문 업체로 철거, 시공, A/S까지 원스톱으로 대응합니다.
description: 슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.
map-latitude: 36.2980733
map-longitude: 127.3814560
map-client-id: ${IOTDOOR_NAVER_MAP_CLIENT_ID:xkb35c3i3k}
security:
admin-username: ${IOTDOOR_ADMIN_USERNAME:admin}
admin-password: ${IOTDOOR_ADMIN_PASSWORD:ChangeMe123!}
admin-display-name: ${IOTDOOR_ADMIN_DISPLAY_NAME:관리자}
file:
upload-path: ${IOTDOOR_FILE_UPLOAD_PATH:./uploads}
+71
View File
@@ -0,0 +1,71 @@
CREATE TABLE IF NOT EXISTS TN_USER (
USER_IDNTF_NO VARCHAR(32) NOT NULL,
USER_ID VARCHAR(100) NOT NULL,
PSWD VARCHAR(200) NOT NULL,
USER_NM VARCHAR(100) NOT NULL,
EML_ADDR VARCHAR(200) NOT NULL,
USER_STTS_CD VARCHAR(10) NOT NULL DEFAULT 'A',
USER_TYPE_CD VARCHAR(10) NOT NULL DEFAULT 'ADM',
JOIN_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDVDL_INFO_AGRE_DT DATETIME NULL,
LOCK_YN CHAR(1) NOT NULL DEFAULT 'N',
LGN_FAIL_NMTM INT NULL DEFAULT 0,
LOCK_DT DATETIME NULL,
LAST_LGN_DT DATETIME NULL,
PRIMARY KEY (USER_IDNTF_NO),
UNIQUE (USER_ID)
);
CREATE TABLE IF NOT EXISTS TN_ATCH_FILE (
ATCH_FILE_IDNTF_NO VARCHAR(32) NOT NULL,
CRT_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
DEL_YN CHAR(1) NOT NULL DEFAULT 'N',
PRIMARY KEY (ATCH_FILE_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_ATCH_FILE_DTL (
ATCH_FILE_IDNTF_NO VARCHAR(32) NOT NULL,
FILE_SN INT NOT NULL,
FILE_STRG_PATH_NM VARCHAR(500) NOT NULL,
FILE_STRG_NM VARCHAR(255) NOT NULL,
ORGNL_FILE_NM VARCHAR(255) NOT NULL,
FILE_EXTN_NM VARCHAR(50) NULL,
FILE_CN VARCHAR(255) NULL,
FILE_SZ BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (ATCH_FILE_IDNTF_NO, FILE_SN),
CONSTRAINT FK_TN_ATCH_FILE_DTL__MASTER FOREIGN KEY (ATCH_FILE_IDNTF_NO)
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_PORTFOLIO (
PORTFOLIO_IDNTF_NO VARCHAR(32) NOT NULL,
TITLE VARCHAR(200) NOT NULL,
THUMBNAIL_ATCH_FILE_IDNTF_NO VARCHAR(32) NOT NULL,
THUMBNAIL_FILE_SN INT NOT NULL DEFAULT 1,
CONTENT TEXT NOT NULL,
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INQ_CNT BIGINT NOT NULL DEFAULT 0,
REG_USER_IDNTF_NO VARCHAR(32) NOT NULL,
DEL_YN CHAR(1) NOT NULL DEFAULT 'N',
UPD_DT DATETIME NULL,
PRIMARY KEY (PORTFOLIO_IDNTF_NO),
CONSTRAINT FK_TN_PORTFOLIO__USER FOREIGN KEY (REG_USER_IDNTF_NO)
REFERENCES TN_USER (USER_IDNTF_NO),
CONSTRAINT FK_TN_PORTFOLIO__THUMBNAIL FOREIGN KEY (THUMBNAIL_ATCH_FILE_IDNTF_NO)
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_CONTACT_INQRY (
CONTACT_IDNTF_NO VARCHAR(32) NOT NULL,
NM VARCHAR(100) NOT NULL,
EML_ADDR VARCHAR(200) NOT NULL,
TELNO VARCHAR(30) NOT NULL,
TTL VARCHAR(200) NOT NULL,
CN TEXT NOT NULL,
SUBMIT_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (CONTACT_IDNTF_NO)
);
CREATE INDEX IF NOT EXISTS IDX_TN_ATCH_FILE_DTL__PATH ON TN_ATCH_FILE_DTL (FILE_STRG_PATH_NM);
CREATE INDEX IF NOT EXISTS IDX_TN_PORTFOLIO__REG_DT ON TN_PORTFOLIO (REG_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_CONTACT_INQRY__SUBMIT_DT ON TN_CONTACT_INQRY (SUBMIT_DT);
@@ -0,0 +1,236 @@
(function () {
const EMAIL_JS_PUBLIC_KEY = 'd7I4hw_UMkVTo2UXy';
const EMAIL_JS_SERVICE_ID = 'service_jfxkmcd';
const EMAIL_JS_TEMPLATE_ID = 'template_vu49z25';
let emailJsInitialized = false;
function getCsrfHeader() {
const token = document.querySelector('meta[name="_csrf"]');
const header = document.querySelector('meta[name="_csrf_header"]');
return {
token: token ? token.getAttribute('content') : '',
header: header ? header.getAttribute('content') : ''
};
}
function isValidEmail(email) {
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test((email || '').trim());
}
function isValidMobile(phone) {
return /^01[016789]-?\d{3,4}-?\d{4}$/.test((phone || '').trim());
}
function onlyNumber(value) {
return value.replace(/[^0-9]/g, '');
}
function formatMobile(phone) {
const numbers = onlyNumber(phone);
if (numbers.length <= 3) {
return numbers;
}
if (numbers.length <= 7) {
return numbers.replace(/(\d{3})(\d{1,4})/, '$1-$2');
}
if (numbers.length <= 11) {
return numbers.replace(/(\d{3})(\d{3,4})(\d{1,4})/, '$1-$2-$3');
}
return numbers.substring(0, 11).replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
}
function setErrorVisible(id, visible) {
const element = document.getElementById(id);
if (!element) {
return;
}
element.style.display = visible ? 'block' : 'none';
}
function spinner(on) {
const spinnerElement = $('#spinner');
if (!spinnerElement.length) {
return;
}
spinnerElement.toggleClass('show', on);
}
function buildPayload() {
return {
name: document.getElementById('name').value.trim(),
email: document.getElementById('email').value.trim(),
phone: document.getElementById('phone').value.trim(),
subject: document.getElementById('subject').value.trim(),
message: document.getElementById('message').value.trim()
};
}
function validatePayload(payload) {
if (!payload.name || !payload.email || !payload.phone || !payload.subject || !payload.message) {
alert('필수 항목은 모두 입력해주세요.');
return false;
}
if (!isValidEmail(payload.email)) {
alert('올바른 이메일 형식이 아닙니다.');
document.getElementById('email').focus();
return false;
}
if (!isValidMobile(payload.phone)) {
alert('올바른 휴대폰번호 형식이 아닙니다.');
document.getElementById('phone').focus();
return false;
}
return true;
}
function resetForm() {
['name', 'email', 'phone', 'subject', 'message'].forEach(function (id) {
const element = document.getElementById(id);
if (element) {
element.value = '';
}
});
setErrorVisible('emailError', false);
setErrorVisible('phoneError', false);
}
function initEmailJs() {
if (emailJsInitialized) {
return true;
}
if (!window.emailjs || typeof window.emailjs.init !== 'function') {
console.warn('EmailJS SDK could not be loaded.');
return false;
}
window.emailjs.init({
publicKey: EMAIL_JS_PUBLIC_KEY
});
emailJsInitialized = true;
return true;
}
function sendContactToDatabase(payload) {
const csrf = getCsrfHeader();
return new Promise(function (resolve, reject) {
$.ajax({
url: '/api/contact',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
beforeSend: function (xhr) {
if (csrf.header && csrf.token) {
xhr.setRequestHeader(csrf.header, csrf.token);
}
}
}).done(function (response) {
resolve(response || {});
}).fail(function (xhr) {
reject(xhr);
});
});
}
function getEmailJsErrorMessage(error) {
if (!error) {
return 'Unknown EmailJS error';
}
if (typeof error === 'string') {
return error;
}
if (error.text) {
return error.text;
}
if (error.message) {
return error.message;
}
try {
return JSON.stringify(error);
} catch (ignored) {
return 'Unknown EmailJS error';
}
}
function sendContactMail(form) {
if (!initEmailJs() || !window.emailjs || typeof window.emailjs.sendForm !== 'function') {
return Promise.reject(new Error('EmailJS SDK is not available.'));
}
return window.emailjs.sendForm(
EMAIL_JS_SERVICE_ID,
EMAIL_JS_TEMPLATE_ID,
form,
{
publicKey: EMAIL_JS_PUBLIC_KEY
}
);
}
async function sendContact(event) {
event.preventDefault();
const form = event.currentTarget;
const payload = buildPayload();
if (!validatePayload(payload)) {
return;
}
const btn = document.getElementById('btnSendMail');
spinner(true);
btn.disabled = true;
try {
const response = await sendContactToDatabase(payload);
try {
await sendContactMail(form);
alert(response.message || '문의가 접수되었고 메일이 발송되었습니다.');
} catch (error) {
const errorMessage = getEmailJsErrorMessage(error);
console.error('EmailJS send failed:', error);
alert((response.message || '문의가 접수되었습니다.') + '\n메일 발송은 실패했습니다.\n사유: ' + errorMessage);
}
resetForm();
} catch (xhr) {
const response = xhr.responseJSON || {};
alert(response.message || '문의 접수 중 오류가 발생했습니다.');
} finally {
spinner(false);
btn.disabled = false;
}
}
window.addEventListener('DOMContentLoaded', function () {
const phoneInput = document.getElementById('phone');
const emailInput = document.getElementById('email');
const form = document.getElementById('contactForm');
initEmailJs();
if (phoneInput) {
phoneInput.addEventListener('input', function (e) {
e.target.value = formatMobile(e.target.value);
});
phoneInput.addEventListener('focusout', function () {
setErrorVisible('phoneError', !!phoneInput.value && !isValidMobile(phoneInput.value));
});
}
if (emailInput) {
emailInput.addEventListener('focusout', function () {
setErrorVisible('emailError', !!emailInput.value && !isValidEmail(emailInput.value));
});
}
if (form) {
form.addEventListener('submit', sendContact);
}
});
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
.login-container{
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 100%;
}
.login-container .login-box {
align-content: center;
background-color: #fff;
padding: 40px;
margin: auto 0;
border-radius: 20px;
box-shadow: 0 5px 6px rgba(0, 0, 0, .1019607843);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,659 @@
/********** Template CSS **********/
:root {
--primary: #254447;
--light: #FFFFFF;
--dark: #971f19;
--gray: #9B9B9B;
}
.fw-medium {
font-weight: 500 !important;
}
.fw-bold {
font-weight: 700 !important;
}
.fw-black {
font-weight: 900 !important;
}
.back-to-top {
position: fixed;
display: none;
right: 45px;
bottom: 45px;
z-index: 99;
}
/*** Spinner ***/
#spinner {
opacity: 0;
visibility: hidden;
transition: opacity .5s ease-out, visibility 0s linear .5s;
z-index: 99999;
}
#spinner.show {
transition: opacity .5s ease-out, visibility 0s linear 0s;
visibility: visible;
opacity: 1;
}
/*** Button ***/
.btn {
font-weight: 500;
transition: .5s;
}
.btn.btn-primary,
.btn.btn-outline-primary:hover {
color: #FFFFFF;
}
.btn-square {
width: 38px;
height: 38px;
}
.btn-sm-square {
width: 32px;
height: 32px;
}
.btn-lg-square {
width: 48px;
height: 48px;
}
.btn-square,
.btn-sm-square,
.btn-lg-square {
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: normal;
}
/*** Navbar ***/
.navbar.sticky-top {
top: -100px;
transition: .5s;
}
.navbar .navbar-brand,
.navbar a.btn {
height: 75px;
}
.navbar .navbar-nav .nav-link {
margin-right: 30px;
padding: 25px 0;
color: var(--primary);
font-size: 15px;
font-weight: 500;
text-transform: uppercase;
outline: none;
}
.navbar .navbar-nav .nav-link:hover,
.navbar .navbar-nav .nav-link.active {
color: var(--primary);
}
.navbar .dropdown-toggle::after {
border: none;
content: "\f107";
font-family: "Font Awesome 5 Free";
font-weight: 900;
vertical-align: middle;
margin-left: 8px;
}
@media (max-width: 991.98px) {
.navbar .navbar-nav .nav-link {
margin-right: 0;
padding: 10px 0;
}
.navbar .navbar-nav {
border-top: 1px solid #EEEEEE;
}
}
@media (min-width: 992px) {
.navbar .nav-item .dropdown-menu {
display: block;
border: none;
margin-top: 0;
top: 150%;
opacity: 0;
visibility: hidden;
transition: .5s;
}
.navbar .nav-item:hover .dropdown-menu {
top: 100%;
visibility: visible;
transition: .5s;
opacity: 1;
}
}
/*** Header ***/
.owl-carousel-inner {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
align-items: center;
background: rgba(0, 0, 0, .1);
}
@media (max-width: 768px) {
.header-carousel .owl-carousel-item {
position: relative;
min-height: 500px;
}
.header-carousel .owl-carousel-item img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
.header-carousel .owl-carousel-item p {
font-size: 16px !important;
}
}
.header-carousel .owl-dots {
position: absolute;
width: 60px;
height: 100%;
top: 0;
right: 30px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.header-carousel .owl-dots .owl-dot {
position: relative;
width: 45px;
height: 45px;
margin: 5px 0;
background: #FFFFFF;
box-shadow: 0 0 30px rgba(255, 255, 255, .9);
border-radius: 45px;
transition: .5s;
}
.header-carousel .owl-dots .owl-dot.active {
width: 60px;
height: 60px;
}
.header-carousel .owl-dots .owl-dot img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
padding: 2px;
border-radius: 45px;
transition: .5s;
}
.page-header {
background: linear-gradient(rgba(0, 0, 0, .1), rgba(0, 0, 0, .1)), url(../img/carousel-1.jpg) center center no-repeat;
background-size: cover;
}
.breadcrumb-item + .breadcrumb-item::before {
color: var(--light);
}
/*** About ***/
@media (min-width: 992px) {
.container.about {
max-width: 100% !important;
}
.about-text {
padding-right: calc(((100% - 960px) / 2) + .75rem);
}
}
@media (min-width: 1200px) {
.about-text {
padding-right: calc(((100% - 1140px) / 2) + .75rem);
}
}
@media (min-width: 1400px) {
.about-text {
padding-right: calc(((100% - 1320px) / 2) + .75rem);
}
}
/*** Service ***/
.service-item {
box-shadow: 0 0 45px rgba(0, 0, 0, .08);
}
.service-icon {
position: relative;
margin: -50px 0 25px 0;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
background: #FFFFFF;
border-radius: 100px;
box-shadow: 0 0 45px rgba(0, 0, 0, .08);
transition: .5s;
}
.service-item:hover .service-icon {
color: #FFFFFF;
background: var(--primary);
}
/*** Feature ***/
@media (min-width: 992px) {
.container.feature {
max-width: 100% !important;
}
.feature-text {
padding-left: calc(((100% - 960px) / 2) + .75rem);
}
}
@media (min-width: 1200px) {
.feature-text {
padding-left: calc(((100% - 1140px) / 2) + .75rem);
}
}
@media (min-width: 1400px) {
.feature-text {
padding-left: calc(((100% - 1320px) / 2) + .75rem);
}
}
/*** Project Portfolio ***/
#portfolio-flters li {
display: inline-block;
font-weight: 500;
color: var(--dark);
cursor: pointer;
transition: .5s;
border-bottom: 2px solid transparent;
}
#portfolio-flters li:hover,
#portfolio-flters li.active {
color: var(--primary);
border-color: var(--primary);
}
.portfolio-img {
position: relative;
}
.portfolio-img::before,
.portfolio-img::after {
position: absolute;
content: "";
width: 0;
height: 100%;
top: 0;
background: var(--dark);
transition: .5s;
}
.portfolio-img::before {
left: 50%;
}
.portfolio-img::after {
right: 50%;
}
.portfolio-item:hover .portfolio-img::before {
width: 51%;
left: 0;
}
.portfolio-item:hover .portfolio-img::after {
width: 51%;
right: 0;
}
.portfolio-btn {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
z-index: 1;
transition: .5s;
}
.portfolio-item:hover .portfolio-btn {
opacity: 1;
transition-delay: .3s;
}
/*** Quote ***/
@media (min-width: 992px) {
.container.quote {
max-width: 100% !important;
}
.quote-text {
padding-right: calc(((100% - 960px) / 2) + .75rem);
}
}
@media (min-width: 1200px) {
.quote-text {
padding-right: calc(((100% - 1140px) / 2) + .75rem);
}
}
@media (min-width: 1400px) {
.quote-text {
padding-right: calc(((100% - 1320px) / 2) + .75rem);
}
}
/*** Team ***/
.team-item {
box-shadow: 0 0 45px rgba(0, 0, 0, .08);
}
.team-item img {
border-radius: 8px 60px 0 0;
}
.team-item .team-social {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
background: #FFFFFF;
transition: .5s;
}
/*** Testimonial ***/
.testimonial-carousel::before {
position: absolute;
content: "";
top: 0;
left: 0;
height: 100%;
width: 0;
background: linear-gradient(to right, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
z-index: 1;
}
.testimonial-carousel::after {
position: absolute;
content: "";
top: 0;
right: 0;
height: 100%;
width: 0;
background: linear-gradient(to left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
z-index: 1;
}
@media (min-width: 768px) {
.testimonial-carousel::before,
.testimonial-carousel::after {
width: 200px;
}
}
@media (min-width: 992px) {
.testimonial-carousel::before,
.testimonial-carousel::after {
width: 300px;
}
}
.testimonial-carousel .owl-nav {
position: absolute;
width: 350px;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: space-between;
opacity: 0;
transition: .5s;
z-index: 1;
}
.testimonial-carousel:hover .owl-nav {
width: 300px;
opacity: 1;
}
.testimonial-carousel .owl-nav .owl-prev,
.testimonial-carousel .owl-nav .owl-next {
position: relative;
color: var(--primary);
font-size: 45px;
transition: .5s;
}
.testimonial-carousel .owl-nav .owl-prev:hover,
.testimonial-carousel .owl-nav .owl-next:hover {
color: var(--dark);
}
.testimonial-carousel .testimonial-img img {
width: 100px;
height: 100px;
}
.testimonial-carousel .testimonial-img .btn-square {
position: absolute;
bottom: -19px;
left: 50%;
transform: translateX(-50%);
}
.testimonial-carousel .owl-item .testimonial-text {
margin-bottom: 30px;
box-shadow: 0 0 45px rgba(0, 0, 0, .08);
transform: scale(.8);
transition: .5s;
}
.testimonial-carousel .owl-item.center .testimonial-text {
transform: scale(1);
}
/*** Contact ***/
@media (min-width: 992px) {
.container.contact {
max-width: 100% !important;
}
.contact-text {
padding-right: calc(((100% - 960px) / 2) + .75rem);
}
}
@media (min-width: 1200px) {
.contact-text {
padding-right: calc(((100% - 1140px) / 2) + .75rem);
}
}
@media (min-width: 1400px) {
.contact-text {
padding-right: calc(((100% - 1320px) / 2) + .75rem);
}
}
/*** Footer ***/
.footer .btn.btn-social {
margin-right: 5px;
color: #9B9B9B;
border: 1px solid #9B9B9B;
border-radius: 38px;
transition: .3s;
}
.footer .btn.btn-social:hover {
color: var(--primary);
border-color: var(--light);
}
.footer .btn.btn-link {
display: block;
margin-bottom: 5px;
padding: 0;
text-align: left;
color: #9B9B9B;
font-weight: normal;
text-transform: capitalize;
transition: .3s;
}
.footer .btn.btn-link::before {
position: relative;
content: "\f105";
font-family: "Font Awesome 5 Free";
font-weight: 900;
margin-right: 10px;
}
.footer .btn.btn-link:hover {
color: #FFFFFF;
letter-spacing: 1px;
box-shadow: none;
}
.footer .copyright {
padding: 25px 0;
border-top: 1px solid rgba(256, 256, 256, .1);
}
.footer .copyright a {
color: var(--light);
}
.footer .copyright a:hover {
color: var(--primary);
}
.bg-dark {
background-color: #254447 !important;
}
.text-primary {
color: #254447 !important;
}
.btn-primary:hover {
background-color: #254447!important;
border-color: #254447!important;
}
.btn-primary {
background-color: #254447!important;
border-color: #254447!important;
}
.btn-link {
color: #ffffff!important;
}
/*
a {
color: #254447!important;
}
*/
.bg-primary {
background-color: #254447!important;
}
.btn.btn-primary{
color: #ffffff!important;
}
.footer .btn.btn-social{
color: var(--light)!important;
}
.navbar .navbar-nav .nav-link:hover, .navbar .navbar-nav .nav-link.active {
color: var(--dark)!important;
}
/**
아래 부터 커스텀..
*/
.text-body {
--bs-text-opacity: 1;
color: var(--gray)!important;
}
body {
color: var(--gray);
}
.site-sub-top {
height: 483px;
background: url(../image/template/backtop2.png) top center no-repeat;
background-color: #194244;
}
/* 이미지 기본 설정 */
.card-image {
transition: transform 0.4s ease;
}
.album-list .card:hover{
cursor: pointer;
}
.album-list .card:hover img.card-image {
transform: scale(1.1);
}
.article-list .article-paging {
margin: 0;
}
.ag-paging-panel {
justify-content: center !important;
}
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 4.4 MiB

@@ -0,0 +1,21 @@
<svg id="badge_ardor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="51.836" height="42.288" viewBox="0 0 51.836 42.288">
<defs>
<clipPath id="clip-path">
<rect id="사각형_427" data-name="사각형 427" width="51.836" height="42.288" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1630" data-name="그룹 1630" clip-path="url(#clip-path)">
<path id="패스_1092" data-name="패스 1092" d="M3.621,49.015a1.9,1.9,0,1,1-1.9,1.9,1.9,1.9,0,0,1,1.9-1.9" transform="translate(-0.459 -13.049)" fill="#d7deed"/>
<path id="패스_1093" data-name="패스 1093" d="M64.557,7.664a2.529,2.529,0,1,1-2.529,2.529,2.528,2.528,0,0,1,2.529-2.529" transform="translate(-16.512 -2.041)" fill="#d7deed"/>
<path id="패스_1094" data-name="패스 1094" d="M44.1,21.6a7.275,7.275,0,0,1,2.338-5.338A7.286,7.286,0,0,1,41.1,7.039,7.206,7.206,0,0,1,35.331,6.4a7.267,7.267,0,0,1-3.455-4.676A7.242,7.242,0,0,1,26.559,4.04a7.253,7.253,0,0,1-5.337-2.317A7.262,7.262,0,0,1,17.767,6.4,7.206,7.206,0,0,1,12,7.039a7.207,7.207,0,0,1-.641,5.771,7.26,7.26,0,0,1-4.676,3.455,7.283,7.283,0,0,1,0,10.654,7.268,7.268,0,0,1,4.676,3.454A7.21,7.21,0,0,1,12,36.146a7.287,7.287,0,0,1,9.227,5.337,7.237,7.237,0,0,1,10.653,0A7.287,7.287,0,0,1,41.1,36.146a7.286,7.286,0,0,1,5.337-9.227A7.263,7.263,0,0,1,44.1,21.6" transform="translate(-1.778 -0.459)" fill="#ffb95a"/>
<path id="패스_1095" data-name="패스 1095" d="M30.191,15.367a9.857,9.857,0,1,1-9.868,9.868,9.862,9.862,0,0,1,9.868-9.868" transform="translate(-5.41 -4.091)" fill="#fcd884"/>
<rect id="사각형_423" data-name="사각형 423" width="2.528" height="2.528" transform="translate(46.779 21.536) rotate(-45)"/>
<rect id="사각형_424" data-name="사각형 424" width="2.528" height="2.528" transform="translate(44.474 39.236) rotate(-45)"/>
<rect id="사각형_425" data-name="사각형 425" width="2.528" height="2.528" transform="translate(1.265 6.366) rotate(-45)"/>
<rect id="사각형_426" data-name="사각형 426" width="2.528" height="2.528" transform="translate(0 30.387) rotate(-45)"/>
<path id="패스_1096" data-name="패스 1096" d="M31.417,42.287a1.262,1.262,0,0,1-.927-.405,5.973,5.973,0,0,0-8.8,0,1.264,1.264,0,0,1-2.16-.577,6.017,6.017,0,0,0-5.859-4.668,6.16,6.16,0,0,0-1.772.26,1.264,1.264,0,0,1-1.571-1.586,5.991,5.991,0,0,0-4.391-7.619,1.265,1.265,0,0,1-.578-2.16,6,6,0,0,0,1.912-4.39A6.031,6.031,0,0,0,5.36,16.732a1.264,1.264,0,0,1,.579-2.159,5.991,5.991,0,0,0,4.39-7.617,1.264,1.264,0,0,1,1.582-1.582,6.038,6.038,0,0,0,1.79.272,5.932,5.932,0,0,0,2.972-.8A5.963,5.963,0,0,0,19.531.982,1.264,1.264,0,0,1,21.69.4,6.023,6.023,0,0,0,26.1,2.316,6,6,0,0,0,30.489.4a1.264,1.264,0,0,1,2.159.578,5.967,5.967,0,0,0,2.857,3.863,5.937,5.937,0,0,0,2.972.8,6.038,6.038,0,0,0,1.79-.272,1.264,1.264,0,0,1,1.586,1.571,6.019,6.019,0,0,0,4.406,7.628,1.265,1.265,0,0,1,.579,2.16,5.974,5.974,0,0,0,0,8.8,1.264,1.264,0,0,1-.577,2.16,6.02,6.02,0,0,0-4.428,7.558,1.285,1.285,0,0,1,.077.437,1.265,1.265,0,0,1-1.251,1.264,1.232,1.232,0,0,1-.379-.054,6.166,6.166,0,0,0-1.773-.261A6.018,6.018,0,0,0,32.649,41.3a1.262,1.262,0,0,1-1.232.983m-17.744-8.18a8.538,8.538,0,0,1,7.661,4.775,8.471,8.471,0,0,1,9.511,0,8.542,8.542,0,0,1,7.661-4.773q.291,0,.58.019a8.531,8.531,0,0,1,4.752-8.238,8.47,8.47,0,0,1,0-9.512,8.532,8.532,0,0,1-4.754-8.224q-.3.021-.608.021a8.457,8.457,0,0,1-4.24-1.141A8.6,8.6,0,0,1,30.844,3.4,8.544,8.544,0,0,1,26.1,4.845,8.592,8.592,0,0,1,21.336,3.4a8.6,8.6,0,0,1-3.394,3.632A8.466,8.466,0,0,1,13.7,8.174c-.2,0-.4-.007-.594-.021a8.5,8.5,0,0,1-1.118,4.832A8.6,8.6,0,0,1,8.357,16.38,8.594,8.594,0,0,1,9.8,21.143a8.55,8.55,0,0,1-1.443,4.745,8.6,8.6,0,0,1,3.632,3.392,8.508,8.508,0,0,1,1.118,4.847c.188-.012.377-.018.566-.018" transform="translate(-1.319 0)"/>
<path id="패스_1097" data-name="패스 1097" d="M29.732,35.887a11.121,11.121,0,1,1,11.11-11.11,11.134,11.134,0,0,1-11.11,11.11m0-19.714a8.593,8.593,0,1,0,8.582,8.6,8.614,8.614,0,0,0-8.582-8.6" transform="translate(-4.952 -3.632)"/>
<path id="패스_1098" data-name="패스 1098" d="M3.162,53.614a3.161,3.161,0,1,1,3.16-3.161,3.165,3.165,0,0,1-3.16,3.161m0-3.793a.632.632,0,1,0,.633.632.632.632,0,0,0-.633-.632" transform="translate(-0.001 -12.59)"/>
<path id="패스_1099" data-name="패스 1099" d="M64.1,13.527a3.793,3.793,0,1,1,3.793-3.793A3.8,3.8,0,0,1,64.1,13.527m0-5.057a1.264,1.264,0,1,0,1.264,1.264A1.266,1.266,0,0,0,64.1,8.47" transform="translate(-16.054 -1.582)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,18 @@
<svg id="badge_ardor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="55.287" height="34.029" viewBox="0 0 55.287 34.029">
<defs>
<clipPath id="clip-path">
<rect id="사각형_421" data-name="사각형 421" width="55.287" height="34.029" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1626" data-name="그룹 1626" clip-path="url(#clip-path)">
<path id="패스_1073" data-name="패스 1073" d="M35.448,26.166V48c0,1.979,7.523,3.583,16.8,3.583s16.8-1.6,16.8-3.583V26.166Z" transform="translate(-24.607 -18.164)" fill="#36464f"/>
<path id="패스_1074" data-name="패스 1074" d="M50.862,50.8c-6.023,0-17.413-.877-17.413-4.195V24.778a.611.611,0,0,1,.612-.612h33.6a.611.611,0,0,1,.612.612V46.609c0,3.318-11.39,4.195-17.414,4.195M34.672,25.389v21.22c0,1.009,5.715,2.971,16.19,2.971s16.191-1.963,16.191-2.971V25.389Z" transform="translate(-23.219 -16.775)" fill="#231815"/>
<path id="패스_1075" data-name="패스 1075" d="M29.166,2.025,55.824,12.3A.374.374,0,0,1,55.8,13L29.141,21.177a.372.372,0,0,1-.219,0L2.264,13a.374.374,0,0,1-.025-.706L28.9,2.025a.374.374,0,0,1,.269,0" transform="translate(-1.388 -1.389)" fill="#4b616e"/>
<path id="패스_1076" data-name="패스 1076" d="M111.843,43.934a.611.611,0,0,1-.612-.612V32.638L91.689,28.19A.612.612,0,0,1,91.96,27l20.018,4.556a.612.612,0,0,1,.476.6V43.322a.612.612,0,0,1-.612.612" transform="translate(-63.318 -18.73)" fill="#231815"/>
<path id="패스_1077" data-name="패스 1077" d="M27.644,20.417a.986.986,0,0,1-.289-.044L.7,12.2a.986.986,0,0,1-.066-1.862L27.29.066a.982.982,0,0,1,.708,0L54.656,10.338a.985.985,0,0,1-.065,1.862L27.933,20.373a.981.981,0,0,1-.289.044M1.714,11.232l25.93,7.95,25.93-7.95L27.644,1.24Zm52.5.248h0ZM27.558,1.207h0" transform="translate(-0.001 0)" fill="#231815"/>
<path id="패스_1078" data-name="패스 1078" d="M86.45,27.586c0,.784-1.389,1.42-3.1,1.42s-3.1-.636-3.1-1.42,1.389-1.42,3.1-1.42,3.1.636,3.1,1.42" transform="translate(-55.704 -18.164)" fill="#f7546a"/>
<path id="패스_1079" data-name="패스 1079" d="M81.959,28.228c-2.152,0-3.714-.854-3.714-2.031s1.562-2.031,3.714-2.031,3.714.854,3.714,2.031-1.562,2.031-3.714,2.031m0-2.839c-1.634,0-2.491.6-2.491.808s.856.808,2.491.808,2.491-.6,2.491-.808-.857-.808-2.491-.808" transform="translate(-54.316 -16.775)" fill="#231815"/>
<path id="패스_1080" data-name="패스 1080" d="M34.716,27.5l6.02-2.176c1.837-.664,1.045-3.62-.813-2.949L33.9,24.546c-1.837.664-1.045,3.62.813,2.949" transform="translate(-22.811 -15.461)" fill="#fff"/>
<path id="패스_1081" data-name="패스 1081" d="M67.376,18.771a1.53,1.53,0,0,0,0-3.058,1.53,1.53,0,0,0,0,3.058" transform="translate(-45.745 -10.908)" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,18 @@
<svg id="badge_atten" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="46.926" height="46.926" viewBox="0 0 46.926 46.926">
<defs>
<clipPath id="clip-path">
<rect id="사각형_430" data-name="사각형 430" width="46.926" height="46.926" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1638" data-name="그룹 1638" clip-path="url(#clip-path)">
<path id="패스_1101" data-name="패스 1101" d="M47.519,24.759A22.759,22.759,0,1,1,24.759,2,22.759,22.759,0,0,1,47.519,24.759" transform="translate(-1.296 -1.296)" fill="#f0f0f0"/>
<path id="패스_1102" data-name="패스 1102" d="M51.88,32.559A17.582,17.582,0,1,1,34.3,14.977,17.582,17.582,0,0,1,51.88,32.559" transform="translate(-10.835 -9.708)" fill="#6dc2f7"/>
<path id="패스_1103" data-name="패스 1103" d="M51.46,28.738a17.581,17.581,0,1,0-27.8,17.814c11.415-2.21,21.9-8.217,27.8-17.814" transform="translate(-10.836 -9.708)" fill="#6dc2f7"/>
<path id="패스_1104" data-name="패스 1104" d="M36.441,71.907a17.579,17.579,0,0,0,27.8-17.814c-5.9,9.6-16.389,15.6-27.8,17.814" transform="translate(-23.621 -35.063)" fill="#5ca2cf"/>
<path id="패스_1105" data-name="패스 1105" d="M23.463,46.926A23.463,23.463,0,1,1,46.926,23.463,23.49,23.49,0,0,1,23.463,46.926m0-45.519A22.056,22.056,0,1,0,45.519,23.463,22.081,22.081,0,0,0,23.463,1.407" fill="#231815"/>
<path id="패스_1106" data-name="패스 1106" d="M33,51.289A18.286,18.286,0,1,1,51.288,33,18.307,18.307,0,0,1,33,51.289m0-35.164A16.879,16.879,0,1,0,49.881,33,16.9,16.9,0,0,0,33,16.124" transform="translate(-9.539 -9.54)" fill="#231815"/>
<path id="패스_1107" data-name="패스 1107" d="M49.4,53.416a.706.706,0,0,1-.282-.059l-8.685-3.8a.7.7,0,1,1,.564-1.289l8.347,3.652,10.809-6.69a.7.7,0,0,1,.741,1.2L49.766,53.311a.707.707,0,0,1-.37.105" transform="translate(-25.932 -29.25)" fill="#231815"/>
<path id="패스_1108" data-name="패스 1108" d="M36.766,29.357c1.191-2.647,3.833-3.509,6.483-4.084,2.211-.481,1.275-3.873-.935-3.392-3.473.754-7.039,2.264-8.586,5.7-.922,2.048,2.109,3.839,3.038,1.776" transform="translate(-21.75 -14.153)" fill="#fff"/>
<path id="패스_1109" data-name="패스 1109" d="M70.761,27.162a1.76,1.76,0,0,0,0-3.518,1.76,1.76,0,0,0,0,3.518" transform="translate(-44.766 -15.326)" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,39 @@
<svg id="badge_cooperation" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="59.738" height="34.392" viewBox="0 0 59.738 34.392">
<defs>
<clipPath id="clip-path">
<rect id="사각형_419" data-name="사각형 419" width="59.738" height="34.392" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1622" data-name="그룹 1622" clip-path="url(#clip-path)">
<path id="패스_1028" data-name="패스 1028" d="M29.447,9.834a1.25,1.25,0,1,1-1.772,0,1.248,1.248,0,0,1,1.772,0" transform="translate(19.716 6.833)" fill="#bf542e"/>
<path id="패스_1029" data-name="패스 1029" d="M8.242,9.349a1.269,1.269,0,0,1,.46,1.718,1.253,1.253,0,1,1-.46-1.718" transform="translate(4.595 6.633)" fill="#168f91"/>
<path id="패스_1030" data-name="패스 1030" d="M29.109,3.1l5.035,8.723L37.5,17.633,30.72,21.541l-6.759-11.7-.126-.217-1.88-3.267-.15-.25L21.7,5.925l6.783-3.919.629,1.088ZM33.5,16.985a1.25,1.25,0,1,0-1.772,0,1.248,1.248,0,0,0,1.772,0" transform="translate(15.665 1.449)" fill="#bf542e"/>
<path id="패스_1031" data-name="패스 1031" d="M33.023.285l7.451,12.9-7.022,4.055-2.416-4.183L26,4.336Z" transform="translate(18.773 0.206)" fill="#ff703d"/>
<rect id="사각형_418" data-name="사각형 418" width="14.901" height="8.105" transform="translate(0.491 13.397) rotate(-60.013)" fill="#168f91"/>
<path id="패스_1032" data-name="패스 1032" d="M13.086,1.658l6.783,3.909-.487.849-.028.024a.813.813,0,0,0-.071.064c-.022.019-.043.041-.062.06l-.007.01a.65.65,0,0,0-.065.074.75.75,0,0,0-.055.074l0,0a.85.85,0,0,0-.046.072.5.5,0,0,0-.033.059l-.021.038,0,.009a.609.609,0,0,0-.028.059.829.829,0,0,0-.048.129.947.947,0,0,0-.028.1l-.009.046a.578.578,0,0,0-.012.076l-1.744,3.022-4.53,7.838-1.462,2.538-.15.26-.129.222-.129-.077,0,0L4.068,17.285ZM10.36,16.5a1.252,1.252,0,1,0-2.166-1.255A1.252,1.252,0,1,0,10.36,16.5" transform="translate(2.937 1.197)" fill="#168f91"/>
<path id="패스_1033" data-name="패스 1033" d="M19.8,20.1l-.539.935L16.8,22.463a1.959,1.959,0,0,1-2-3.368l-1.925,1.111a1.959,1.959,0,0,1-2-3.37L8.587,18.167a1.955,1.955,0,1,1-1.958-3.385l2.945-1.708.131.076.129-.222.222-.124,1.5,2.538Z" transform="translate(4.08 9.244)" fill="#fbd5a6"/>
<path id="패스_1034" data-name="패스 1034" d="M18.955,19.607l-3,1.737a1.959,1.959,0,0,1-2-3.368l.539-.935,1.365.789,2.876,1.662a1.093,1.093,0,0,0,.133.071c.029.014.06.028.091.045" transform="translate(9.395 12.303)" fill="#fbd5a6"/>
<path id="패스_1035" data-name="패스 1035" d="M35.018,16.52l1.471,2.548L20.635,9.551,20.6,9.532c-2.867.009-5.39-.281-7.143-1.922-.033-.031-.064-.064-.095-.095a1.968,1.968,0,0,1-.174-.189,3.308,3.308,0,0,1-.215-.289h0a2.055,2.055,0,0,1-.288-.684v0a1.371,1.371,0,0,1-.019-.449.571.571,0,0,1,.01-.076l.01-.046a.975.975,0,0,1,.026-.1.9.9,0,0,1,.048-.129c.009-.019.017-.04.028-.059l0-.009a.206.206,0,0,1,.021-.038c.01-.019.021-.038.033-.057s.031-.05.048-.074l0,0,.057-.074c.021-.024.041-.05.064-.074l.009-.009.06-.062c.024-.022.046-.043.071-.064l.028-.022c.833-.692,2.688-.992,5.781-.31,2.609.58,4.341-3.089,8.36.549l1.012-.3,2.032,3.516.124.219Z" transform="translate(9.136 2.609)" fill="#ddb992"/>
<path id="패스_1036" data-name="패스 1036" d="M14.159,7.963l1.746-3.02a1.371,1.371,0,0,0,.019.449v0a1.965,1.965,0,0,0,.288.682l0,0a2.9,2.9,0,0,0,.215.288,1.968,1.968,0,0,0,.174.189c.029.031.062.064.095.095,1.753,1.641,4.276,1.93,7.143,1.922l15.888,9.536a2.246,2.246,0,0,1,.282,1.092,2.212,2.212,0,0,1-.294,1.109,2.254,2.254,0,0,1-3.046.82l-2.607-1.515a2.228,2.228,0,0,1-2.275,3.831l-2.194-1.264a2.228,2.228,0,0,1-2.276,3.831l-2.807-1.62a2.23,2.23,0,0,1-2.149,3.9c-.043-.021-.088-.045-.133-.071l-2.876-1.66-1.365-.79L9.741,21.014,8.167,18.34Z" transform="translate(5.896 3.569)" fill="#fbd5a6"/>
<path id="패스_1037" data-name="패스 1037" d="M20.5,25.242a2.446,2.446,0,0,1-2.128-1.226,2.4,2.4,0,0,1-.307-1.507l-.813.47a2.459,2.459,0,0,1-3.344-.9,2.428,2.428,0,0,1-.3-1.515l-.279.16a2.448,2.448,0,0,1-3.654-2.407l-.635.368a2.445,2.445,0,0,1-3.583-2.769,2.412,2.412,0,0,1,1.138-1.469l2.939-1.7.014-.007a.492.492,0,0,1,.487.01l.129.077a.49.49,0,0,1-.363.9L7.081,15.3a1.439,1.439,0,0,0-.682.878,1.464,1.464,0,0,0,2.147,1.66l2.29-1.329a.49.49,0,0,1,.506.839,1.492,1.492,0,0,0-.508,1.994,1.47,1.47,0,0,0,2,.536l1.927-1.111a.49.49,0,0,1,.5.844,1.469,1.469,0,0,0,1.5,2.524L19.226,20.7a.49.49,0,0,1,.5.844,1.468,1.468,0,0,0,1.5,2.523L23.763,22.6a.458.458,0,0,1,.079-.229.489.489,0,0,1,.678-.141l.065.034.015.007.029.014c.022.012.046.022.071.036a.49.49,0,0,1,.019.859L21.714,24.92a2.449,2.449,0,0,1-1.216.322" transform="translate(3.874 9.151)"/>
<path id="패스_1038" data-name="패스 1038" d="M10.009,16.036a.489.489,0,0,1-.422-.241L8.3,13.622a.48.48,0,0,1-.444-.25.489.489,0,0,1,.188-.666l.222-.126a.491.491,0,0,1,.663.177l1.5,2.538a.491.491,0,0,1-.422.74" transform="translate(5.628 9.037)"/>
<path id="패스_1039" data-name="패스 1039" d="M23.55,29.221a2.705,2.705,0,0,1-1.2-.281c-.057-.028-.112-.057-.165-.09L9.7,21.644a.509.509,0,0,1-.177-.176L7.949,18.795a.489.489,0,0,1,0-.492l1.462-2.538L15.686,4.9a.49.49,0,0,1,.911.3.915.915,0,0,0,.014.3.491.491,0,0,1-.387.575.529.529,0,0,1-.091.009L8.939,18.543,10.3,20.859,22.68,28.005a1.112,1.112,0,0,0,.107.057,1.738,1.738,0,0,0,2.271-.689,1.757,1.757,0,0,0-.6-2.357.49.49,0,0,1,.5-.842l2.807,1.62a1.734,1.734,0,0,0,2.369-.63,1.759,1.759,0,0,0-.594-2.359.49.49,0,0,1,.5-.842l2.194,1.264a1.708,1.708,0,0,0,1.31.176,1.732,1.732,0,0,0,1.061-.808,1.757,1.757,0,0,0-.6-2.357.49.49,0,0,1,.5-.842l2.609,1.515a1.751,1.751,0,0,0,2.375-.642,1.729,1.729,0,0,0,.229-.863,1.751,1.751,0,0,0-.165-.744L23.8,9.205a.49.49,0,0,1,.479-.854l.034.019.012.007,15.856,9.517a.505.505,0,0,1,.176.181,2.719,2.719,0,0,1-3.728,3.687l-.858-.5a2.71,2.71,0,0,1-4.029,2.812l-.443-.255a2.709,2.709,0,0,1-4.028,2.822l-1.052-.608a2.707,2.707,0,0,1-2.674,3.187" transform="translate(5.69 3.364)"/>
<path id="패스_1040" data-name="패스 1040" d="M13.367,6.67a.487.487,0,0,1-.339-.136l-.1-.1a.49.49,0,1,1,.694-.692c.029.028.059.057.088.086a.49.49,0,0,1-.339.844" transform="translate(9.227 4.04)"/>
<path id="패스_1041" data-name="패스 1041" d="M13.261,6.587a.5.5,0,0,1-.375-.174,3.575,3.575,0,0,1-.248-.334.49.49,0,1,1,.816-.541c.059.088.119.167.181.243a.49.49,0,0,1-.374.806" transform="translate(9.064 3.839)"/>
<path id="패스_1042" data-name="패스 1042" d="M13.166,6.583a.492.492,0,0,1-.413-.224,2.516,2.516,0,0,1-.356-.851.49.49,0,0,1,.961-.2,1.535,1.535,0,0,0,.22.515.492.492,0,0,1-.146.678.5.5,0,0,1-.265.077" transform="translate(8.942 3.552)"/>
<path id="패스_1043" data-name="패스 1043" d="M11.053,21.889a.486.486,0,0,1-.245-.065L4.028,17.915a.489.489,0,0,1-.227-.3.484.484,0,0,1,.048-.372L12.866,1.619a.49.49,0,0,1,.67-.179l6.783,3.909a.49.49,0,0,1,.181.668l-.489.849a.489.489,0,1,1-.849-.487l.245-.425-5.936-3.42L4.942,17.311l5.932,3.42.033-.059,7.737-13.4a.49.49,0,0,1,.849.491L11.477,21.645a.481.481,0,0,1-.3.227.464.464,0,0,1-.127.017" transform="translate(2.731 0.992)"/>
<path id="패스_1044" data-name="패스 1044" d="M12.872,5.655a.38.38,0,0,1-.088-.009.489.489,0,0,1-.394-.568l.009-.041,0-.014a1.478,1.478,0,0,1,.043-.16.49.49,0,1,1,.93.31.243.243,0,0,0-.01.038l-.009.041a.492.492,0,0,1-.482.4" transform="translate(8.939 3.27)"/>
<path id="패스_1045" data-name="패스 1045" d="M12.92,5.452a.494.494,0,0,1-.184-.036.485.485,0,0,1-.269-.63.967.967,0,0,1,.043-.1.491.491,0,0,1,.876.441l-.01.022a.5.5,0,0,1-.456.3" transform="translate(8.975 3.191)"/>
<path id="패스_1046" data-name="패스 1046" d="M12.939,5.411a.478.478,0,0,1-.2-.043.488.488,0,0,1-.25-.646.608.608,0,0,1,.06-.11.493.493,0,1,1,.387.8" transform="translate(8.988 3.172)"/>
<path id="패스_1047" data-name="패스 1047" d="M12.969,5.37a.491.491,0,0,1-.429-.728c.024-.041.05-.081.079-.122a.49.49,0,1,1,.794.573l-.015.026a.491.491,0,0,1-.429.251" transform="translate(9.009 3.116)"/>
<path id="패스_1048" data-name="패스 1048" d="M13,5.352a.473.473,0,0,1-.262-.077.484.484,0,0,1-.152-.668.988.988,0,0,1,.09-.122,1.2,1.2,0,0,1,.088-.1.49.49,0,1,1,.713.673.224.224,0,0,0-.033.038l-.01.012-.019.022A.5.5,0,0,1,13,5.352" transform="translate(9.031 3.053)"/>
<path id="패스_1049" data-name="패스 1049" d="M13.073,5.255a.49.49,0,0,1-.358-.825c.026-.028.053-.057.083-.083s.062-.057.093-.084a.49.49,0,1,1,.629.752l-.038.034-.024.022-.026.026a.49.49,0,0,1-.358.157" transform="translate(9.085 2.997)"/>
<path id="패스_1050" data-name="패스 1050" d="M7.824,12.385h0a1.757,1.757,0,0,1-.875-.234,1.738,1.738,0,0,1-.634-2.38,1.742,1.742,0,1,1,1.508,2.614m.005-2.5a.767.767,0,0,0-.744.959.747.747,0,0,0,.355.461.773.773,0,0,0,.384.1.758.758,0,0,0,.66-.379A.777.777,0,0,0,8.2,9.978a.762.762,0,0,0-.372-.1" transform="translate(4.389 6.427)"/>
<path id="패스_1051" data-name="패스 1051" d="M33.659,17.94a.49.49,0,0,1-.425-.246L25.783,4.788a.489.489,0,0,1,.086-.6.484.484,0,0,1,.095-.071L32.984.067a.49.49,0,0,1,.67.179l7.453,12.9a.491.491,0,0,1-.181.67L33.9,17.873a.489.489,0,0,1-.245.067M26.876,4.723,33.838,16.78l6.173-3.564L33.049,1.16Z" transform="translate(18.566 0.001)"/>
<path id="패스_1052" data-name="패스 1052" d="M36.694,19.762a.5.5,0,0,1-.253-.069L20.67,10.226h-.005c-2.69,0-5.426-.265-7.337-2.053-.041-.041-.079-.077-.114-.114a2.718,2.718,0,0,1-.2-.215,3.061,3.061,0,0,1-.251-.338l-.007-.01a2.481,2.481,0,0,1-.346-.82l-.01-.052a1.848,1.848,0,0,1-.021-.567A.776.776,0,0,1,12.4,5.94c0-.024.009-.045.014-.067a1.12,1.12,0,0,1,.036-.134,1.354,1.354,0,0,1,.069-.186c.009-.019.022-.05.036-.076a.558.558,0,0,1,.033-.064c.015-.029.033-.059.05-.088s.031-.048.052-.077l.024-.033c.017-.026.038-.052.057-.076l.01-.012c.022-.028.046-.055.071-.083l.031-.034.062-.06.01-.01c.026-.026.053-.05.081-.074l.036-.031c1.1-.907,3.3-1.054,6.2-.413a3.858,3.858,0,0,0,2.321-.456c1.5-.618,3.36-1.381,6.054.938l.752-.222a.5.5,0,0,1,.56.219l.15.25L31,8.423a.525.525,0,0,1,.048.117.447.447,0,0,1,.076.1l6,10.385a.489.489,0,0,1-.424.735M20.806,9.246a.478.478,0,0,1,.251.071l14.266,8.561L30.273,9.13a.532.532,0,0,1-.05-.117.578.578,0,0,1-.076-.1L28.315,5.728l-.649.191a.487.487,0,0,1-.467-.107c-2.349-2.127-3.749-1.551-5.231-.942a4.686,4.686,0,0,1-2.907.506c-3.642-.8-4.963-.121-5.36.208l-.01.009-.015.014c-.015.012-.029.026-.045.04l-.043.045h0l-.048.055L13.5,5.8l-.005.007-.024.038L13.44,5.9l0,0-.007.014-.015.031a.4.4,0,0,0-.026.069l-.007.022-.012.041-.007.034,0,.017,0,.04a.863.863,0,0,0,.012.288l0,.015a1.475,1.475,0,0,0,.217.5l.01.017a2.2,2.2,0,0,0,.167.222l.009.01a1.691,1.691,0,0,0,.139.152c.033.034.06.06.088.086,1.689,1.582,4.27,1.791,6.8,1.786Z" transform="translate(8.93 2.405)"/>
<path id="패스_1053" data-name="패스 1053" d="M30.926,22.236a.488.488,0,0,1-.424-.245L21.481,6.374a.486.486,0,0,1,.179-.668l6.783-3.919a.49.49,0,0,1,.668.179l9.02,15.627a.493.493,0,0,1-.179.67L31.17,22.171a.478.478,0,0,1-.245.065M22.574,6.308,31.1,21.077l5.932-3.42L28.508,2.88Z" transform="translate(15.46 1.243)"/>
<path id="패스_1054" data-name="패스 1054" d="M28.768,12.666A1.746,1.746,0,1,1,30,12.155a1.727,1.727,0,0,1-1.229.511m0-2.505a.757.757,0,0,0-.539.224.775.775,0,0,0-.224.542.745.745,0,0,0,.22.53.76.76,0,0,0,.542.227.767.767,0,0,0,.761-.758.759.759,0,0,0-.226-.542.743.743,0,0,0-.536-.224" transform="translate(19.51 6.629)"/>
<path id="패스_1055" data-name="패스 1055" d="M7.51,17.939a.469.469,0,0,1-.245-.065L.245,13.82a.49.49,0,0,1-.179-.67L7.517.246a.483.483,0,0,1,.3-.229.492.492,0,0,1,.372.05L15.2,4.12a.49.49,0,0,1,.181.668L7.933,17.694a.489.489,0,0,1-.3.229.537.537,0,0,1-.127.015M1.159,13.215,7.331,16.78,14.287,4.723,8.119,1.16Z" transform="translate(0 0.001)"/>
<path id="패스_1056" data-name="패스 1056" d="M27.84,4.465a.483.483,0,0,1-.329-.127c-2.345-2.128-3.749-1.55-5.233-.938a4.716,4.716,0,0,1-2.9.513c-3.568-.794-4.918-.143-5.333.172a.49.49,0,1,1-.594-.778c1.123-.858,3.3-.982,6.139-.351A3.86,3.86,0,0,0,21.9,2.494c1.541-.635,3.461-1.426,6.264,1.119a.489.489,0,0,1-.329.852" transform="translate(9.569 1.342)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,25 @@
<svg id="badge_cooperation" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="34.83" height="39.384" viewBox="0 0 34.83 39.384">
<defs>
<clipPath id="clip-path">
<rect id="사각형_420" data-name="사각형 420" width="34.83" height="39.384" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1624" data-name="그룹 1624" clip-path="url(#clip-path)">
<path id="패스_1057" data-name="패스 1057" d="M34.83,23.014v8.555a7.819,7.819,0,0,1-15.637,0v-8.1a7.263,7.263,0,0,1-1.767.223,7.439,7.439,0,0,1-1.789-.223v8.1A7.819,7.819,0,0,1,0,31.569V12.448A7.819,7.819,0,0,1,3.627,5.861V4.274A4.235,4.235,0,0,1,7.772,0l.086.682L7.865,0A4.235,4.235,0,0,1,12.01,4.274V5.861a7.809,7.809,0,0,1,3.627,6.587v.919a5.952,5.952,0,0,0,1.781.273,5.87,5.87,0,0,0,1.774-.273v-.919a7.8,7.8,0,0,1,3.678-6.616V4.274A4.235,4.235,0,0,1,27.015,0l.093.682V0A4.236,4.236,0,0,1,31.26,4.274V5.89a7.813,7.813,0,0,1,3.57,6.558V23.014Zm-1.35,4.159V23.287a8.809,8.809,0,0,0-6.465-2.593,9.267,9.267,0,0,0-5.4,1.645,7.271,7.271,0,0,1-1.063.632v4.2a7.812,7.812,0,0,1,12.929,0m0-5.732v-6.86A8.81,8.81,0,0,0,27.015,12a9.267,9.267,0,0,0-5.4,1.645,7.191,7.191,0,0,1-8.39,0A9.267,9.267,0,0,0,7.822,12a8.8,8.8,0,0,0-6.465,2.579v6.853a10.633,10.633,0,0,1,6.465-2.1,10.63,10.63,0,0,1,6.192,1.9,5.786,5.786,0,0,0,3.4,1.092h.007a5.754,5.754,0,0,0,3.4-1.092,11.012,11.012,0,0,1,12.664.2m0-8.677v-.316a6.465,6.465,0,0,0-3.3-5.631,6.561,6.561,0,0,0-5.524-.374.1.1,0,0,0-.043.014,6.389,6.389,0,0,0-.711.33,6.48,6.48,0,0,0-3.347,5.66v.259c.086-.057.18-.1.266-.158a10.652,10.652,0,0,1,6.2-1.9,10.542,10.542,0,0,1,6.465,2.119m0,18.805a6.465,6.465,0,1,0-6.465,6.465,6.474,6.474,0,0,0,6.465-6.465M29.9,5.186V4.274a2.873,2.873,0,0,0-2.8-2.916L27.022.682l.007.675a2.877,2.877,0,0,0-2.8,2.916v.869c.065-.022.136-.043.208-.065a3.963,3.963,0,0,1,.381-.129l.323-.086c.129-.029.259-.065.4-.086s.259-.043.388-.065.23-.036.338-.043a6.637,6.637,0,0,1,.754-.043,6.763,6.763,0,0,1,.761.043c.144.014.28.036.424.057.1.022.208.029.316.05.165.036.33.079.5.122.072.022.144.036.215.057.172.05.338.108.5.172.057.022.108.036.165.057M14.28,27.173v-4.2a7.2,7.2,0,0,1-1.056-.632,9.267,9.267,0,0,0-5.4-1.645,8.809,8.809,0,0,0-6.465,2.593v3.886a7.805,7.805,0,0,1,12.922,0m0-14.467v-.259a6.458,6.458,0,0,0-3.3-5.631,6.546,6.546,0,0,0-6.321,0,6.465,6.465,0,0,0-3.3,5.631v.316a10.542,10.542,0,0,1,6.465-2.119,10.63,10.63,0,0,1,6.192,1.9c.086.057.18.1.266.158m0,18.863a6.461,6.461,0,1,0-6.458,6.465,6.468,6.468,0,0,0,6.458-6.465m-3.62-26.4V4.274a2.873,2.873,0,0,0-2.8-2.916L7.779.682v.675a2.877,2.877,0,0,0-2.8,2.916v.891a1.448,1.448,0,0,1,.165-.05,3.906,3.906,0,0,1,.488-.165l.158-.043a7.89,7.89,0,0,1,2.033-.28,7.878,7.878,0,0,1,2.026.28,1.292,1.292,0,0,1,.158.043,3.906,3.906,0,0,1,.488.165,1.448,1.448,0,0,1,.165.05"/>
<path id="패스_1058" data-name="패스 1058" d="M41.539,31.4v3.886a7.812,7.812,0,0,0-12.929,0v-4.2a7.271,7.271,0,0,0,1.063-.632,9.267,9.267,0,0,1,5.4-1.645A8.809,8.809,0,0,1,41.539,31.4" transform="translate(-8.059 -8.116)" fill="#ffae2b"/>
<path id="패스_1059" data-name="패스 1059" d="M34.012,19.289v6.86a11.012,11.012,0,0,0-12.664-.2,5.754,5.754,0,0,1-3.4,1.092h-.007a5.786,5.786,0,0,1-3.4-1.092,10.63,10.63,0,0,0-6.192-1.9,10.633,10.633,0,0,0-6.465,2.1V19.289A8.8,8.8,0,0,1,8.355,16.71a9.267,9.267,0,0,1,5.4,1.645,7.191,7.191,0,0,0,8.39,0,9.267,9.267,0,0,1,5.4-1.645,8.81,8.81,0,0,1,6.465,2.579" transform="translate(-0.532 -4.707)" fill="#fff0bf"/>
<path id="패스_1060" data-name="패스 1060" d="M41.539,14.8v.316A10.542,10.542,0,0,0,35.075,13a10.652,10.652,0,0,0-6.2,1.9c-.086.057-.18.1-.266.158V14.8a6.48,6.48,0,0,1,3.347-5.66,6.389,6.389,0,0,1,.711-.33.1.1,0,0,1,.043-.014,6.561,6.561,0,0,1,5.524.374,6.465,6.465,0,0,1,3.3,5.631" transform="translate(-8.059 -2.355)" fill="#ffae2b"/>
<path id="패스_1061" data-name="패스 1061" d="M35.075,34.95a6.465,6.465,0,1,1-6.465,6.465,6.474,6.474,0,0,1,6.465-6.465m5.086,6.465A5.086,5.086,0,1,0,35.075,46.5a5.095,5.095,0,0,0,5.086-5.086" transform="translate(-8.059 -9.845)" fill="#50b1c2"/>
<path id="패스_1062" data-name="패스 1062" d="M35.616,36.87a5.086,5.086,0,1,1-5.086,5.086,5.095,5.095,0,0,1,5.086-5.086m3.728,5.086a3.728,3.728,0,1,0-3.728,3.728,3.734,3.734,0,0,0,3.728-3.728" transform="translate(-8.6 -10.386)"/>
<path id="패스_1063" data-name="패스 1063" d="M39.4,4.541v.912c-.057-.022-.108-.036-.165-.057-.165-.065-.33-.122-.5-.172-.072-.022-.144-.036-.215-.057-.172-.043-.338-.086-.5-.122-.108-.022-.215-.029-.316-.05-.144-.022-.28-.043-.424-.057a6.763,6.763,0,0,0-.761-.043,6.637,6.637,0,0,0-.754.043c-.108.007-.223.029-.338.043s-.259.036-.388.065-.266.057-.4.086l-.323.086a3.963,3.963,0,0,0-.381.129c-.072.022-.144.043-.208.065V4.541a2.877,2.877,0,0,1,2.8-2.916L36.524.95l.079.675a2.873,2.873,0,0,1,2.8,2.916" transform="translate(-9.502 -0.268)" fill="#a1f1ff"/>
<path id="패스_1064" data-name="패스 1064" d="M36.148,38.76a3.728,3.728,0,1,1-3.728,3.728,3.734,3.734,0,0,1,3.728-3.728M37.7,41.806a.679.679,0,0,0-1-.919l-1.925,2.09a.672.672,0,0,0,.036.955.664.664,0,0,0,.46.18.685.685,0,0,0,.5-.215Z" transform="translate(-9.133 -10.919)" fill="#a1f1ff"/>
<path id="패스_1065" data-name="패스 1065" d="M38.509,41.6a.673.673,0,0,1,.043.955l-1.925,2.09a.685.685,0,0,1-.5.215.664.664,0,0,1-.46-.18.672.672,0,0,1-.036-.955l1.925-2.09a.672.672,0,0,1,.955-.036" transform="translate(-9.985 -11.667)"/>
<path id="패스_1066" data-name="패스 1066" d="M14.812,31.087v4.2a7.805,7.805,0,0,0-12.922,0V31.4A8.809,8.809,0,0,1,8.355,28.81a9.267,9.267,0,0,1,5.4,1.645,7.2,7.2,0,0,0,1.056.632" transform="translate(-0.532 -8.116)" fill="#ffae2b"/>
<path id="패스_1067" data-name="패스 1067" d="M14.812,14.8v.259c-.086-.057-.18-.1-.266-.158A10.63,10.63,0,0,0,8.355,13,10.542,10.542,0,0,0,1.89,15.118V14.8a6.465,6.465,0,0,1,3.3-5.631,6.546,6.546,0,0,1,6.321,0,6.458,6.458,0,0,1,3.3,5.631" transform="translate(-0.532 -2.354)" fill="#ffae2b"/>
<path id="패스_1068" data-name="패스 1068" d="M8.355,34.95A6.465,6.465,0,1,1,1.89,41.415,6.468,6.468,0,0,1,8.355,34.95m5.078,6.465A5.082,5.082,0,1,0,8.355,46.5a5.089,5.089,0,0,0,5.078-5.086" transform="translate(-0.532 -9.845)" fill="#50b1c2"/>
<path id="패스_1069" data-name="패스 1069" d="M8.9,36.87A5.086,5.086,0,1,1,3.81,41.956,5.089,5.089,0,0,1,8.9,36.87m3.728,5.086A3.728,3.728,0,1,0,8.9,45.684a3.734,3.734,0,0,0,3.728-3.728" transform="translate(-1.073 -10.386)"/>
<path id="패스_1070" data-name="패스 1070" d="M9.428,38.76A3.728,3.728,0,1,1,5.7,42.488,3.734,3.734,0,0,1,9.428,38.76m1.552,3.046a.679.679,0,0,0-1-.919l-1.925,2.09a.672.672,0,0,0,.036.955.678.678,0,0,0,.955-.036Z" transform="translate(-1.606 -10.919)" fill="#a1f1ff"/>
<path id="패스_1071" data-name="패스 1071" d="M12.612,4.541v.891a1.448,1.448,0,0,0-.165-.05,3.906,3.906,0,0,0-.488-.165,1.292,1.292,0,0,0-.158-.043,7.878,7.878,0,0,0-2.026-.28,7.89,7.89,0,0,0-2.033.28l-.158.043a3.906,3.906,0,0,0-.488.165,1.448,1.448,0,0,0-.165.05V4.541a2.877,2.877,0,0,1,2.8-2.916V.95l.079.675a2.873,2.873,0,0,1,2.8,2.916" transform="translate(-1.952 -0.268)" fill="#a1f1ff"/>
<path id="패스_1072" data-name="패스 1072" d="M11.789,41.6a.673.673,0,0,1,.043.955L9.9,44.644a.676.676,0,0,1-.991-.919l1.925-2.09a.672.672,0,0,1,.955-.036" transform="translate(-2.458 -11.667)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,13 @@
<svg id="badge_good" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="38.051" height="36.759" viewBox="0 0 38.051 36.759">
<defs>
<clipPath id="clip-path">
<rect id="사각형_431" data-name="사각형 431" width="38.051" height="36.759" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1655" data-name="그룹 1655" clip-path="url(#clip-path)">
<path id="패스_1111" data-name="패스 1111" d="M139.275,21c3.9,0,3.81,5.81,0,5.81h-2.381c3.81.191,3.714,5.905-.1,5.905h-1.715c3.81.19,3.714,5.9-.1,5.9h-2.667c3.81.1,3.714,5.905-.1,5.905H117.941L114.6,42.168V21.4h1.152c4.858-1.333,5.429-7.065,5.525-9.827a5.994,5.994,0,0,1,.095-1.809,6.8,6.8,0,0,1,2.953-.381c3.714.856,3.619,9.714.857,11.618Z" transform="translate(-104.908 -8.561)" fill="#ffd8a8"/>
<path id="패스_1112" data-name="패스 1112" d="M18.255,141.172v22.821h-8.9v-23.81h8.9Z" transform="translate(-8.565 -128.33)" fill="#407ed1"/>
<path id="패스_1113" data-name="패스 1113" d="M123.661,36.76H109.376a.791.791,0,0,1-.457-.145l-3.343-2.361a.791.791,0,1,1,.913-1.292l3.138,2.216h14.034a2.04,2.04,0,0,0,2.114-2.148,2.017,2.017,0,0,0-2.038-2.175.791.791,0,0,1,.02-1.582h2.667a2.023,2.023,0,0,0,2.113-2.081,2.076,2.076,0,0,0-2.058-2.242.791.791,0,0,1,.04-1.581h1.715a2.023,2.023,0,0,0,2.113-2.081,2.076,2.076,0,0,0-2.058-2.243.791.791,0,0,1,.04-1.581h2.381a1.99,1.99,0,0,0,1.571-.649,2.279,2.279,0,0,0,.531-1.491,1.967,1.967,0,0,0-2.1-2.087h-14.1a.791.791,0,0,1-.449-1.443c1.311-.9,2.021-4.269,1.489-7.06-.159-.831-.675-2.792-2.05-3.131a6.568,6.568,0,0,0-2.094.194c-.009.317-.009.788-.009,1.22,0,.01,0,.018,0,.028a17.2,17.2,0,0,1-1.03,5.71,7.494,7.494,0,0,1-5.075,4.852.785.785,0,0,1-.21.028h-1.152a.791.791,0,0,1,0-1.582h1.042c3.036-.888,4.665-3.932,4.843-9.049,0-.526,0-.98.013-1.313a4.078,4.078,0,0,1,.037-.466.878.878,0,0,1,.516-.738v0a7.528,7.528,0,0,1,3.4-.438l.046.009c1.644.379,2.806,1.935,3.273,4.381a11.653,11.653,0,0,1-.9,7.217h12.4a3.56,3.56,0,0,1,3.684,3.67,3.585,3.585,0,0,1-3.136,3.689,3.946,3.946,0,0,1,.671,2.315,3.63,3.63,0,0,1-2.566,3.469,3.929,3.929,0,0,1,.756,2.434,3.6,3.6,0,0,1-3.39,3.613,3.956,3.956,0,0,1,.629,2.205,3.619,3.619,0,0,1-3.7,3.71M112.879,1.994c-.023,0-.047,0-.071,0,.024,0,.048,0,.071,0" transform="translate(-96.343 0)"/>
<path id="패스_1114" data-name="패스 1114" d="M9.69,156.219H.791A.791.791,0,0,1,0,155.428v-23.81a.791.791,0,0,1,.791-.791h8.9a.791.791,0,0,1,.791.791v23.81a.791.791,0,0,1-.791.791m-8.109-1.582H8.9V132.409H1.582Z" transform="translate(0 -119.765)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,21 @@
<svg id="badge_sincerity" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="65.894" height="44.597" viewBox="0 0 65.894 44.597">
<defs>
<clipPath id="clip-path">
<rect id="사각형_422" data-name="사각형 422" width="60.64" height="26.287" transform="translate(0 0)" fill="none"/>
</clipPath>
</defs>
<g id="그룹_1629" data-name="그룹 1629" transform="translate(0 19.742) rotate(-19)">
<g id="그룹_1628" data-name="그룹 1628" transform="translate(0 0)" clip-path="url(#clip-path)">
<path id="패스_1082" data-name="패스 1082" d="M169.987,2.233s6.583-1.949,8.555,4.51-4.578,8.519-4.578,8.519Z" transform="translate(-118.876 -1.369)" fill="#ffabbf"/>
<path id="패스_1083" data-name="패스 1083" d="M2,66.808l7.865.819L7.892,61.163Z" transform="translate(-1.399 -42.773)" fill="#38322e"/>
<path id="패스_1084" data-name="패스 1084" d="M36.043,51.141,33.3,44.854,27.58,42.092,21.6,47.826l1.973,6.464,7.989.831Z" transform="translate(-15.102 -29.436)" fill="#f7dccb"/>
<path id="패스_1085" data-name="패스 1085" d="M8.466,66.829a.545.545,0,0,1-.062,0L.539,66.007a.6.6,0,0,1-.354-1.033L6.077,59.33a.6.6,0,0,1,.991.259l1.973,6.464a.6.6,0,0,1-.575.777M1.955,64.945l5.672.59L6.2,60.874Z" transform="translate(0 -41.374)" fill="#231815"/>
<path id="패스_1086" data-name="패스 1086" d="M30.158,54.324c-.02,0-.041,0-.062,0l-7.988-.832a.6.6,0,0,1-.513-.422L19.621,46.6a.6.6,0,0,1,.159-.61l5.984-5.734a.6.6,0,0,1,.678-.107l5.716,2.762a.6.6,0,0,1,.289.3L35.195,49.5a.6.6,0,0,1-.152.691l-4.486,3.981a.6.6,0,0,1-.4.152m-7.529-1.99,7.328.763,3.961-3.515L31.435,43.9,26.3,41.417,20.88,46.606Z" transform="translate(-13.703 -28.037)" fill="#231815"/>
<path id="패스_1087" data-name="패스 1087" d="M84.109,15.9,45.476,27.7l1.12-3.9-4.733-2.114,2.859-4.027L41.5,14.666,80.132,2.875Z" transform="translate(-29.021 -2.011)" fill="#f78f4a"/>
<path id="패스_1088" data-name="패스 1088" d="M44.077,26.9a.6.6,0,0,1-.588-.726l.837-3.95-4.055-1.375a.6.6,0,0,1-.3-.918l2.553-3.6-2.835-2.624a.6.6,0,0,1,.233-1.017L78.558.9a.6.6,0,0,1,.751.4L83.286,14.33a.6.6,0,0,1-.4.751L44.253,26.872a.6.6,0,0,1-.176.026m-2.655-6.93,3.8,1.287a.6.6,0,0,1,.4.694l-.736,3.475L81.96,14.106,78.334,2.227,41.277,13.537l2.455,2.272a.6.6,0,0,1,.082.789Z" transform="translate(-27.623 -0.612)" fill="#231815"/>
<path id="패스_1089" data-name="패스 1089" d="M50.819,25.292a.6.6,0,0,1-.175-1.177L87.161,12.969a.6.6,0,1,1,.351,1.15L50.995,25.266a.607.607,0,0,1-.176.026" transform="translate(-35.118 -9.051)" fill="#231815"/>
<path id="패스_1090" data-name="패스 1090" d="M56.477,43.829a.6.6,0,0,1-.176-1.177L92.819,31.507a.6.6,0,0,1,.351,1.15L56.653,43.8a.607.607,0,0,1-.176.026" transform="translate(-39.075 -22.015)" fill="#231815"/>
<path id="패스_1091" data-name="패스 1091" d="M172.566,14.5a.6.6,0,0,1-.576-.426L168.013,1.04a.6.6,0,0,1,.4-.752c.071-.021,7.18-2.038,9.3,4.912s-4.9,9.246-4.973,9.268a.588.588,0,0,1-.18.028M169.352,1.306l3.6,11.793c1.456-.644,5.076-2.77,3.617-7.548-1.464-4.8-5.65-4.528-7.216-4.244" transform="translate(-117.478 0)" fill="#231815"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="584.988" height="145.001" viewBox="0 0 584.988 145.001">
<g id="그룹_6116" data-name="그룹 6116" transform="translate(16262.985 -10083.552)">
<path id="교차_6" data-name="교차 6" d="M188.841-1298.9q.035-.663.035-1.336a25.136,25.136,0,0,0-25.108-25.107,25.135,25.135,0,0,0-25.106,25.107q0,.672.035,1.336H101.014q-.014-.667-.014-1.336a62.387,62.387,0,0,1,4.932-24.432,62.846,62.846,0,0,1,5.788-10.662,63.167,63.167,0,0,1,7.664-9.289,63.173,63.173,0,0,1,9.29-7.664,62.75,62.75,0,0,1,10.662-5.788A62.381,62.381,0,0,1,163.768-1363a62.382,62.382,0,0,1,24.432,4.932,62.818,62.818,0,0,1,10.662,5.788,63.181,63.181,0,0,1,9.289,7.664,63.23,63.23,0,0,1,7.665,9.289,62.841,62.841,0,0,1,5.787,10.662,62.372,62.372,0,0,1,4.933,24.432q0,.669-.014,1.336Z" transform="translate(-16363.985 11527.449)" fill="#fff" opacity="0.1"/>
<path id="교차_7" data-name="교차 7" d="M250.96-1259.9a42.993,42.993,0,0,0-42.691-38.645,42.993,42.993,0,0,0-42.692,38.645H101.084a106.683,106.683,0,0,1,8.345-37.49,107.487,107.487,0,0,1,9.89-18.249,108.16,108.16,0,0,1,13.1-15.9,108.074,108.074,0,0,1,15.875-13.118,107.215,107.215,0,0,1,18.221-9.905A106.466,106.466,0,0,1,208.268-1363a106.462,106.462,0,0,1,41.753,8.443,107.215,107.215,0,0,1,18.221,9.905,108.035,108.035,0,0,1,15.875,13.118,108.16,108.16,0,0,1,13.1,15.9,107.551,107.551,0,0,1,9.89,18.249,106.683,106.683,0,0,1,8.345,37.49Z" transform="translate(-15576.913 8823.656) rotate(-180)" fill="#fff" opacity="0.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,16 @@
<svg id="eraser" xmlns="http://www.w3.org/2000/svg" width="125.5" height="29" viewBox="0 0 125.5 29">
<g id="그룹_4510" data-name="그룹 4510" transform="translate(-2337 -6920)">
<g id="사각형_599" data-name="사각형 599" transform="translate(2337 6920)" fill="#54b175" stroke="#263b3a" stroke-width="3">
<rect width="61" height="29" rx="5" stroke="none"/>
<rect x="1.5" y="1.5" width="58" height="26" rx="3.5" fill="none"/>
</g>
<g id="사각형_600" data-name="사각형 600" transform="translate(2363 6921)" fill="#fff" stroke="#263b3a" stroke-width="3">
<rect width="10" height="27" rx="5" stroke="none"/>
<rect x="1.5" y="1.5" width="7" height="24" rx="3.5" fill="none"/>
</g>
</g>
<g id="사각형_1680" data-name="사각형 1680" transform="translate(125.5 19) rotate(90)" fill="#fff" stroke="#263b3a" stroke-width="3">
<rect width="10" height="44" rx="5" stroke="none"/>
<rect x="1.5" y="1.5" width="7" height="41" rx="3.5" fill="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Some files were not shown because too many files have changed in this diff Show More