Merge remote-tracking branch 'origin/devs/ums' into main-design-layout

# Conflicts:
#	src/main/resources/templates/views/apps/login/login.html
This commit is contained in:
현성필
2025-11-14 11:13:46 +09:00
46 changed files with 2258 additions and 426 deletions
+139
View File
@@ -0,0 +1,139 @@
# Cursor Rules for EAPIM Portal
> 이 파일은 CLAUDE.md를 기반으로 합니다. 내용 수정 시 CLAUDE.md와 동기화해주세요.
## 프로젝트 개요
광주은행 EAPIM Portal - 엔터프라이즈 API 포털 관리 시스템
- Spring Boot 2.7.18, Java 8, Gradle 7.x
- Oracle 12c 이중 데이터베이스 (EMS, Gateway)
- Spring Security, JPA/Hibernate, MapStruct, Lombok
## 빌드 & 실행
```bash
# gradlew 사용 금지 - offline gradle 사용
gradle bootRun # 개발 환경 실행
gradle bootWar # 실행 가능 WAR
gradle war # 서버용 WAR (JEUS/WebLogic)
gradle test # 테스트
```
## 아키텍처 핵심
### 외부 모듈 의존성
- elink-online-core-jpa: Gateway 데이터 모델
- elink-portal-common: 공통 유틸리티
- kjb-safedb: SafeDB 암호화 라이브러리
### 패키지 구조 (기능 모듈 방식)
```
apps/
├── user/ # 사용자 관리
├── app/ # API 키 관리
├── approval/ # 승인 워크플로우
├── apis/ # API 카탈로그
├── proxy/ # API 테스트 프록시
└── ...
```
각 모듈: controller/ service/ repository/ dto/ mapper/
### 이중 DB
- EMS (Portal): jdbc/dsOBP_EMS, com.eactive.apim.portal.apps.*
- Gateway: jdbc/dsOBP_AGW, com.eactive.eai.data.entity.onl.*
- Atomikos JTA로 분산 트랜잭션
### 프로파일
- dev: 개발 (192.168.240.177:1599, SQL 로깅)
- stage: 스테이징 (JNDI, proxy to inter-dapiwas01)
- prod: 운영 (JNDI, proxy to inter-apiwas00, 캐싱)
## 개발 패턴
### 새 기능 모듈
1. apps/ 하위에 패키지 생성
2. controller/, service/, repository/, dto/, mapper/ 구조
3. Entity는 AbstractAuditingEntity 확장
4. DTO는 Lombok @Data
5. Mapper는 MapStruct
6. Service는 @Transactional
7. Thymeleaf 템플릿 추가
8. application.yml의 pages 섹션에 라우트 설정 (또는 @Controller 직접 매핑)
### 보안
- config/PortalConfigSecurity.java
- PortalAuthenticationManager (커스텀 인증)
- CSRF, XSS 방어, 세션 고정 공격 방어
- 역할 계층: ROLE_USER < ROLE_CORP_USER < ROLE_CORP_MANAGER
### 트랜잭션
- 기본: @Transactional (REQUIRED)
- 읽기 전용: @Transactional(readOnly = true)
- 다중 DB는 자동 처리 (Atomikos JTA)
### 로깅
- SLF4J with Lombok @Slf4j
- 로그 경로: /Log/App/eapim/
- 개발 환경만 SQL 로깅
### 쿼리
- 우선: Spring Data JPA 메서드
- 복잡한 쿼리: QueryDSL
## 주요 기능
### 사용자 관리 (apps/user/)
- 내부 사용자 (Staff), 기업 사용자 (Enterprise)
- 가입 → 이메일 인증 → 관리자 승인 → 활성화
- 비밀번호 정책: 90일 만료, 복잡도 규칙
### API 키 관리 (apps/app/)
- 승인 워크플로우
- 개발/운영 환경 지원
- eLink Gateway ClientID/ClientSecret 연동
### 감사 추적
- AbstractAuditingEntity (createdBy, createdDate, lastModifiedBy, lastModifiedDate)
- @Audited로 변경 이력
- Spring Data Envers로 조회
## 중요 사항
### 한글 지원
- Thymeleaf 템플릿 한글
- DB 데이터 UTF-8
- messages_ko.properties
### Forward Proxy
- Dev: http://localhost:10000
- Stage: http://inter-dapiwas01:10000
- Prod: http://inter-apiwas00:10000
### 비밀번호 정책 (PortalUserValidator)
- 최소 8자, 대소문자+숫자+특수문자
- 연속 문자 불가, 최근 3개 재사용 불가
- 90일 만료
### 세션
- 15분 타임아웃
- 단일 세션 강제
- Redis/Ehcache 클러스터링 (stage/prod)
### 파일 업로드
- 최대 8MB
- /Log/App/eapim/files/
- XSS 스캐닝
## 배포
- JEUS, WebLogic, GlassFish 6.3, Tomcat 지원
- JNDI 설정 필수 (stage/prod)
- Docker 지원
## 관련 프로젝트
- ../eapim-admin/
- ../eapim-online/
- ../kjb-eapim-sql/
---
상세 내용은 CLAUDE.md 참조
Binary file not shown.
+1
View File
@@ -101,3 +101,4 @@ TODO.txt
*.lck
*.log
.claude
+456
View File
@@ -0,0 +1,456 @@
# CLAUDE.md
이 파일은 Claude Code(claude.ai/code)가 이 저장소의 코드를 작업할 때 참고하는 지침을 제공합니다.
## 프로젝트 개요
**EAPIM Portal**은 광주은행을 위한 엔터프라이즈 API 포털 관리 시스템입니다. API 서비스 관리, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
**기술 스택:**
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
- Java 8 (targetCompatibility: 1.8)
- Gradle 8.7 빌드 시스템
- Oracle 19c 데이터베이스 (JNDI를 통한 이중 데이터소스)
- Spring Security (커스텀 인증)
- JPA/Hibernate 5.6.15 with Envers (감사 추적)
- MapStruct (DTO 매핑), Lombok (보일러플레이트 제거)
## 빌드 명령어
### 애플리케이션 실행
- gradlew 사용 금지 - offline gradle 단독 실행
- msi-gf63 컴퓨터에서는 /c/eactive/workspaces/shell-scripts/kjb-gradle.sh 로 gradle 실행 (jdk, gradle 등 환경 변수 세팅 후 gradle 실행하는 스크립트)
```bash
# dev 프로파일로 실행 (기본값)
gradle bootRun
# 특정 프로파일로 실행
gradle bootRun --args='--spring.profiles.active=stage'
```
### 빌드
```bash
# Spring Boot 실행 가능 WAR 빌드
gradle bootWar
# 출력: build/libs/eapim-portal-boot.war
# 애플리케이션 서버용 표준 WAR 빌드 (JEUS/WebLogic)
gradle war
# 출력: build/libs/eapim-portal.war
# 클린 후 빌드
gradle clean build
```
### 테스트
```bash
# 모든 테스트 실행
gradle test
# 커버리지와 함께 테스트 실행
gradle test --info
# 특정 테스트 클래스 실행
gradle test --tests "com.eactive.apim.portal.apps.user.AccountControllerTest"
# 패턴에 매칭되는 테스트 실행
gradle test --tests "*Controller*"
```
### 개발
```bash
# 현재 프로파일 확인
gradle printProfile
# 소스 세트 설정 확인
gradle printSourceSets
# Docker 이미지 빌드
./build_docker.sh
```
## 아키텍처
### 멀티 모듈 구조
이 프로젝트는 Gradle composite build를 통해 3개의 외부 모듈에 의존합니다:
1. **elink-online-core-jpa** (`../eapim-online/elink-online-core-jpa`)
- Gateway 데이터 모델 및 JPA 엔티티
- API 명세 엔티티 및 리포지토리 제공
- 패키지: `com.eactive.eai.data.entity.onl.*`
2. **elink-portal-common** (`../elink-portal-common`)
- 공유 포털 유틸리티 및 공통 컴포넌트
- 기본 리포지토리 구현, QueryDSL 지원
- 공통 예외 핸들러 및 보안 유틸리티
3. **kjb-safedb** (`../kjb-safedb`)
- 광주은행 SafeDB 암호화 라이브러리
- 데이터베이스 레벨 민감 데이터 암호화
- 암호화 컬럼(사용자 비밀번호, API 키 등)에 필수
### 패키지 구조
코드는 기술 계층이 아닌 **기능 모듈**(수직 분할) 방식으로 구성됩니다:
```
com.eactive.apim.portal/
├── apps/ # 기능 모듈
│ ├── agreements/ # API 약관
│ ├── apis/ # API 카탈로그 & 문서
│ ├── apiservice/ # API 서비스 그룹핑
│ ├── app/ # API 키 관리
│ ├── approval/ # 승인 워크플로우
│ ├── auth/ # 인증
│ ├── community/ # FAQ, 공지사항, Q&A, 제휴문의
│ ├── dashboard/ # 통계 & 분석
│ ├── file/ # 파일 업로드/다운로드
│ ├── login/ # 로그인/로그아웃
│ ├── proxy/ # API 테스트용 Forward Proxy
│ ├── sample/ # 샘플 코드 생성
│ └── user/ # 사용자 관리
├── common/ # 공통 관심사
├── config/ # Spring 설정
└── gateway/ # Gateway DB 직접 접근
└── data/ # Gateway 리포지토리
```
각 기능 모듈은 일반적으로 다음을 포함합니다:
- `controller/` - Spring MVC 컨트롤러 (@Controller)
- `service/` - 비즈니스 로직 (@Service, @Transactional)
- `repository/` - Spring Data JPA 리포지토리
- `dto/` - 데이터 전송 객체 (Lombok @Data)
- `mapper/` - MapStruct 인터페이스 (entity ↔ DTO 변환)
### 이중 데이터베이스 아키텍처
애플리케이션은 두 개의 Oracle 데이터베이스를 사용합니다:
1. **EMS Database** (Portal/Admin)
- JNDI: `jdbc/dsOBP_EMS`
- Schema: `EMSADM`
- Entities: `com.eactive.apim.portal.apps.*`
- 목적: 포털 사용자, 조직, API 키, 승인
2. **Gateway Database** (API Specs)
- JNDI: `jdbc/dsOBP_AGW`
- Schema: `AGWADM`
- Entities: `com.eactive.eai.data.entity.onl.*`
- 목적: API 명세, 서비스, 메시지
설정: `config/PortalDatasourceConfiguration.java`
- 각 데이터베이스별 별도 EntityManager
- Atomikos JTA를 통한 분산 트랜잭션
- 트랜잭션 로그: `/Log/eapim/portal/`
### 설정 프로파일
환경별 설정 파일: `src/main/resources/application-{profile}.yml`
- **dev**: 개발 환경 (Oracle at 192.168.240.177:1599, DevTools 활성화, SQL 로깅)
- **stage**: 스테이징 환경 (JNDI 데이터소스, proxy to inter-dapiwas01)
- **prod**: 운영 환경 (JNDI 데이터소스, proxy to inter-apiwas00, 캐싱 활성화)
- **gf63**: 개인 개발 환경
- **kjb_rinjae**: 개인 개발 환경
주요 설정:
- 세션 타임아웃: 15분
- 파일 업로드 최대: 8MB
- 비밀번호 만료: 90일
- 인증 토큰 TTL: 5분 (300초)
- 사용자 승인 필수: true
## 주요 기능 및 비즈니스 로직
### 사용자 관리 (`apps/user/`)
두 가지 사용자 유형:
1. **내부 사용자 (Staff)**: 포털 관리자 및 운영자
2. **기업 사용자 (Enterprise)**: 외부 API 소비자
사용자 라이프사이클:
- 가입 → 이메일 인증 → 관리자 승인 → 활성화
- 비밀번호 정책 적용 (90일 만료, 복잡도 규칙)
- 로그인 실패 시 계정 잠금
- 휴면 계정 관리
### API 키 관리 (`apps/app/`)
API 키는 승인 워크플로우와 함께 관리됩니다:
- 기업 관리자가 `AppRequestController`를 통해 키 요청
- 관리자가 `ApprovalController`를 통해 승인/반려
- 키는 개발/운영 환경 지원
- 키는 eLink Gateway ClientID/ClientSecret과 연결
### 승인 워크플로우 (`apps/approval/`)
다음 항목에 대한 범용 승인 시스템:
- 사용자 등록 승인
- API 키 생성/수정
- 제휴 신청
엔티티: `Approval`, `Approver`, `ApprovalStatus` (WAITING, APPROVED, REJECTED)
### API 테스트 (`apps/proxy/`, `apps/sample/`)
- Gateway 서버로의 Forward Proxy
- 요청/응답 로깅이 있는 API 테스터
- 여러 언어의 샘플 코드 생성
- API 문서를 위한 Swagger UI 통합
### 감사 추적 (Hibernate Envers)
모든 엔티티는 자동 감사를 위해 `AbstractAuditingEntity`를 확장합니다:
- `createdBy`, `createdDate`
- `lastModifiedBy`, `lastModifiedDate`
- `@Audited` 어노테이션으로 변경 이력 관리
- Spring Data Envers를 통한 이력 조회
## 공통 개발 패턴
### 새 기능 모듈 추가하기
1. `apps/` 하위에 패키지 생성 (예: `apps/newfeature/`)
2. `controller/`, `service/`, `repository/`, `dto/`, `mapper/`로 구조화
3. `repository/entity/`에 JPA 엔티티 생성 (`AbstractAuditingEntity` 확장)
4. `dto/`에 Lombok `@Data`를 사용한 DTO 생성
5. `mapper/`에 MapStruct 매퍼 인터페이스 생성
6. `@Service``@Transactional`을 사용한 서비스 계층 구현
7. `@Controller``@RequestMapping`을 사용한 컨트롤러 생성
8. `src/main/resources/templates/views/newfeature/`에 Thymeleaf 템플릿 추가
9. `application.yml``pages` 섹션에 라우트 설정 (또는 `@GetMapping`/`@PostMapping`으로 직접 매핑)
### MapStruct DTO 매핑
타입 안전한 DTO 변환을 위해 MapStruct 사용:
```java
@Mapper(componentModel = "spring")
public interface UserMapper {
PortalUserDto toDto(PortalUser entity);
PortalUser toEntity(PortalUserDto dto);
List<PortalUserDto> toDtoList(List<PortalUser> entities);
}
```
생성된 구현체는 `build/generated/sources/annotationProcessor/java/main/`에 위치
### 보안 및 인증
보안 설정: `config/PortalConfigSecurity.java`
- 커스텀 인증 관리자: `PortalAuthenticationManager`
- `changeSessionId()`를 통한 세션 고정 공격 방어
- 쿠키 기반 토큰을 사용한 CSRF 방어
- Lucy 필터를 통한 XSS 방어
역할 계층:
```
ROLE_USER → [ROLE_INQUIRY, ROLE_ACCOUNT]
ROLE_CORP_USER → [ROLE_INQUIRY, ROLE_APP, ROLE_ACCOUNT]
ROLE_CORP_MANAGER → [ROLE_API_KEY_REQUEST, ROLE_INQUIRY, ROLE_APP,
ROLE_ACCOUNT, ROLE_CORP_API, ROLE_DASHBOARD,
ROLE_USER_MANAGER]
```
### 데이터베이스 쿼리
Spring Data JPA 리포지토리 메서드 사용 권장:
```java
public interface PortalUserRepository extends JpaRepository<PortalUser, Long> {
Optional<PortalUser> findByEmail(String email);
@Query("SELECT u FROM PortalUser u WHERE u.status = :status")
List<PortalUser> findByStatus(@Param("status") UserStatus status);
}
```
복잡한 쿼리는 QueryDSL 사용:
```java
QPortalUser user = QPortalUser.portalUser;
return queryFactory.selectFrom(user)
.where(user.email.eq(email)
.and(user.status.eq(UserStatus.ACTIVE)))
.fetchOne();
```
### 트랜잭션 관리
적절한 propagation과 함께 `@Transactional` 사용:
- 기본값: `REQUIRED` (기존 트랜잭션에 참여하거나 새로 생성)
- 읽기 전용 작업: 최적화를 위해 `@Transactional(readOnly = true)`
- 다중 데이터베이스: Atomikos JTA가 자동으로 분산 트랜잭션 처리
### 에러 처리
`common/exception/`의 글로벌 예외 처리:
- `@ControllerAdvice`가 적용된 `GlobalExceptionHandler`
- 커스텀 예외는 `RuntimeException` 확장
- Thymeleaf 에러 뷰 또는 AJAX 요청에는 JSON 반환
### 로깅
`application.yml`의 로깅 설정:
```yaml
logging:
file:
path: /Log/App/eapim/
level:
com.eactive.apim.portal: DEBUG
org.hibernate.SQL: DEBUG (개발 환경만)
```
Lombok의 `@Slf4j`와 함께 SLF4J 사용:
```java
@Slf4j
@Service
public class UserService {
public void someMethod() {
log.debug("디버그 메시지");
log.info("정보 메시지");
log.error("에러 메시지", exception);
}
}
```
## 테스트 가이드
### 테스트 구조
`src/test/java/com/eactive/apim/portal/`의 테스트:
- `BaseWebTest` - Spring Boot 테스트 컨텍스트를 가진 기본 클래스
- 컨트롤러 테스트는 `BaseWebTest` 확장
- 통합 테스트는 `@SpringBootTest` 사용
- 독립적인 컨트롤러 테스트는 `@WebMvcTest` 사용
### 일반적인 테스트 패턴
```java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest extends BaseWebTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void testUserRegistration() throws Exception {
mockMvc.perform(post("/user/register")
.param("email", "test@example.com")
.with(csrf()))
.andExpect(status().isOk())
.andExpect(view().name("user/register-success"));
}
}
```
### SafeDB 테스트
테스트에서는 기본적으로 SafeDB 암호화가 비활성화됩니다. 필요한 경우:
```bash
gradle test -Dsafedb=/path/to/safedb
```
## 중요 사항
### 한글 지원
이 포털은 한글을 광범위하게 사용합니다:
- Thymeleaf 템플릿은 한글 레이블 사용
- 데이터베이스는 한글 데이터 포함 (UTF-8)
- 한글 메시지 프로퍼티 (`messages_ko.properties`)
- 한글 사용자 가이드 (`개발자포탈.md`)
### Forward Proxy 설정
API 테스트는 Gateway로의 Forward Proxy 사용:
- Dev: `http://localhost:10000`
- Stage: `http://inter-dapiwas01:10000`
- Prod: `http://inter-apiwas00:10000`
설정: `apps/proxy/service/ProxyService.java`
### 비밀번호 정책
`PortalUserValidator`에서 적용:
- 최소 8자
- 대문자, 소문자, 숫자, 특수문자 포함 필수
- 연속된 문자 사용 불가
- 최근 3개 비밀번호 재사용 불가
- 90일 후 만료
### 세션 관리
- 사용자당 단일 세션 강제
- 세션 타임아웃: 15분
- 로그인 시 세션 고정 공격 방어
- Redis/Ehcache를 통한 세션 클러스터링 (스테이징/운영)
### 파일 업로드 제한
- 최대 파일 크기: 8MB
- `FileService`에서 허용 확장자 설정
- 파일 저장 위치: `/Log/App/eapim/files/`
- 업로드 시 XSS 스캐닝
## 배포
### 애플리케이션 서버 지원
WAR 파일 호환 서버:
- **JEUS** (`jeus-web-dd.xml` 필요)
- **WebLogic** (`weblogic.xml` 필요)
- **Tomcat** (Spring Boot 내장)
### JNDI 설정 필수
스테이징/운영 환경에서 JNDI 리소스 설정:
- `jdbc/dsOBP_EMS` → Portal 데이터베이스
- `jdbc/dsOBP_AGW` → Gateway 데이터베이스
### 환경 변수
```bash
# 선택사항: 기본 설정 오버라이드
JAVA_OPTS="-Xmx2g -Xms1g -Dspring.profiles.active=prod"
```
### Docker 배포
```bash
# WAR 빌드
gradle bootWar
# Docker 이미지 빌드
./build_docker.sh
# 컨테이너 실행
docker run -p 8080:8080 \
-e JAVA_OPTS="-Dspring.profiles.active=prod" \
-v /Log:/Log \
eapim-portal:latest
```
## 관련 모듈
다음 항목에 영향을 주는 변경 시:
- **Gateway API 명세**: `elink-online-core-jpa` 모듈 확인
- **공통 유틸리티**: `elink-portal-common` 모듈 확인
- **암호화**: `kjb-safedb` 모듈 확인
- **Admin 포털**: `../eapim-admin/` 관련 프로젝트
- **Online 포털**: `../eapim-online/` 관련 프로젝트
## 추가 자료
- 사용자 가이드 (한글): `개발자포탈.md`
- 빌드 스크립트: `build-gf63.sh`, `deploy_portal.sh`, `deploy_portal2.sh`
- Docker: `Dockerfile`, `build_docker.sh`
- SQL 스크립트: `../kjb-eapim-sql/`
+142
View File
@@ -0,0 +1,142 @@
# EAPIM Portal
광주은행 엔터프라이즈 API 포털 관리 시스템
## 프로젝트 개요
**EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
## 기술 스택
- Spring Boot 2.7.18
- Java 8
- Gradle 8.7
- Oracle 19c
- Spring Security
- JPA/Hibernate 5.6.15 with Envers
- MapStruct, Lombok
## 빌드 및 실행
```bash
# 개발 환경 실행
gradle bootRun
# WAR 빌드
gradle bootWar
# 테스트
gradle test
```
**주의**: gradlew 사용 금지 - offline gradle 단독 실행
## 프로젝트 구조
```
src/main/java/com/eactive/apim/portal/
├── apps/ # 기능 모듈 (user, app, approval, apis, etc.)
├── common/ # 공통 관심사
├── config/ # Spring 설정
└── gateway/ # Gateway DB 접근
```
## 외부 모듈
이 프로젝트는 다음 모듈에 의존합니다:
- `elink-online-core-jpa`: Gateway 데이터 모델
- `elink-portal-common`: 공통 유틸리티
- `kjb-safedb`: SafeDB 암호화 라이브러리
## AI 코딩 어시스턴트 지침 파일
이 프로젝트는 여러 AI 코딩 어시스턴트를 지원합니다. 각 도구는 다음 파일을 참조합니다:
### 지침 파일 위치
| AI 도구 | 지침 파일 경로 | 용도 |
|---------|---------------|------|
| **Claude Code** | `CLAUDE.md` | 마스터 지침 파일 (한글) |
| **GitHub Copilot** | `.github/copilot-instructions.md` | Copilot용 지침 |
| **Cursor** | `.cursorrules` | Cursor용 간결한 규칙 |
### 지침 파일 관리 방법
1. **마스터 파일**: `CLAUDE.md`
- 가장 상세한 프로젝트 지침을 포함
- 한글로 작성되어 한국 개발팀이 이해하기 쉬움
- 수정 시 이 파일을 먼저 업데이트
2. **동기화**:
```bash
# CLAUDE.md 수정 후 다른 파일들 동기화
cp CLAUDE.md .github/copilot-instructions.md
# .cursorrules는 간결한 버전이므로 필요시 수동 업데이트
```
3. **새로운 AI 도구 지원 추가**:
- Windsurf: `.windsurfrules` 파일 생성
- 기타 도구: 해당 도구의 규칙 파일명 확인 후 생성
### 지침 파일에 포함된 내용
- 프로젝트 개요 및 기술 스택
- 빌드 명령어 및 개발 환경 설정
- 아키텍처 구조 (멀티 모듈, 이중 DB)
- 주요 기능 및 비즈니스 로직
- 개발 패턴 및 코딩 컨벤션
- 보안, 트랜잭션, 로깅 가이드
- 테스트 패턴
- 배포 방법
## 환경 설정
### 프로파일
- **dev**: 개발 환경
- **stage**: 스테이징 환경
- **prod**: 운영 환경
- **gf63**: 개인 개발 환경
- **kjb_rinjae**: 개인 개발 환경
### 데이터베이스
- **EMS Database**: Portal 사용자, 조직, API 키
- **Gateway Database**: API 명세, 서비스
## 주요 기능
- 사용자 관리 (내부/기업)
- API 키 관리 및 승인 워크플로우
- API 카탈로그 및 문서화
- API 테스트 프록시
- 감사 추적 (Hibernate Envers)
## 배포
### 지원 서버
- JEUS
- WebLogic
- Tomcat (내장)
### Docker
```bash
./build_docker.sh
docker run -p 8080:8080 eapim-portal:latest
```
## 관련 프로젝트
- `../eapim-admin/` - Admin 포털
- `../eapim-online/` - Online 포털
- `../kjb-eapim-sql/` - SQL 스크립트
## 문서
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
- **사용자 가이드**: `개발자포탈.md` (한글)
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
## 라이선스
© 광주은행 (Kwangju Bank)
+1
View File
@@ -27,6 +27,7 @@ dependencies {
implementation project(':elink-online-core-jpa')
implementation project(':elink-portal-common')
implementation project(':kjb-safedb')
implementation('org.springframework.boot:spring-boot-starter')
implementation 'org.springframework.boot:spring-boot-starter-jta-atomikos'
+13 -2
View File
@@ -8,7 +8,18 @@
### 디비 접속 방법 Direct -> JNDI 로 변경
- 은행 내부 환경에선 모두 JNDI 사용하기 때문에 서버환경에서 동작하는 profile 내 application.yaml 에서 디비 접속 정보 삭제
## 긴급 로깅이 필요할 경우 JVM 파라미터 추가
## 서버 운영 중 긴급 로깅이 필요할 경우 JVM 파라미터 추가 하여 재기동
- DEBUG 레벨 root 로그 / debug.log 로 파일 작성
```
-Dlogback.configurationFile=classpath:logback-debug.xml
-Dlogging.config=classpath:logback-debug.xml
```
## 참고자료
### 개발용 파라미터
```
-Dlogging.config=classpath:logback-rinjae.xml
-Dinst.Name=devSvr98
-Duser.language=en
-Duser.country=US
-Dkjb_safedb.mode=fake
```
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import java.security.SecureRandom;
@Component
@@ -18,6 +19,12 @@ public class AuthNumberGenerator {
}
@PostConstruct
public void init() {
log.warn("가상 인증코드 활성화 상태 - {}", portalProperties.getAuthVirtualCode());
}
public String generateAuthNumber() {
if (StringUtils.hasLength(portalProperties.getAuthVirtualCode())) {
log.info("가상 인증코드 활성화 상태 - {}", portalProperties.getAuthVirtualCode());
@@ -22,10 +22,10 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private final AuthNumberGenerator generator;
private final MessageSender messageSender;
@Value("${auth-ttl:300}")
@Value("${portal.auth-ttl:300}")
private int authNumberExpirationTime;
@Value("${auth.resend.limit.seconds:30}")
@Value("${portal.auth.resend_limit_seconds:30}")
private int resendLimitSeconds;
@Autowired
@@ -39,7 +39,6 @@ public class MessageSender {
} else {
throw new IllegalArgumentException("Unsupported message type: " + msgType);
}
messageHandlerService.publishEvent(code, recipient, params);
}
}
@@ -46,6 +46,7 @@ public class AccountController {
private final UserFacade userFacade;
private final OrgRegisterFacade orgRegisterFacade;
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
@PostMapping("/confirm_password")
@@ -326,4 +327,115 @@ public class AccountController {
return "redirect:/mypage";
}
}
@GetMapping("/mypage/verification-email")
public String showVerificationEmailPage(Model model) {
try {
// 현재 로그인한 사용자 정보 가져오기
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser == null) {
return "redirect:/login";
}
// 사용자 이메일 주소를 모델에 추가
model.addAttribute("email", currentUser.getUsername());
model.addAttribute("userId", currentUser.getId());
return "apps/mypage/verificationEmail";
} catch (Exception e) {
logger.error("이메일 인증 페이지 로드 실패", e);
return "redirect:/";
}
}
@PostMapping("/mypage/send-verification-code")
public ResponseEntity<ValidationResponse> sendVerificationCode(
@org.springframework.web.bind.annotation.RequestBody java.util.Map<String, String> request,
HttpSession session) {
try {
String email = request.get("email");
// 현재 로그인한 사용자의 이메일과 일치하는지 확인
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser == null || !currentUser.getUsername().equals(email)) {
ValidationResponse response = new ValidationResponse();
response.setValid(false);
response.setMessage("유효하지 않은 요청입니다.");
return ResponseEntity.badRequest().body(response);
}
// 이전 세션 데이터 정리
session.removeAttribute("emailVerified");
// 이메일 인증 코드 발송
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
if (response.isValid()) {
session.setAttribute("verificationEmail", email);
}
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("이메일 인증 코드 발송 실패", e);
ValidationResponse response = new ValidationResponse();
response.setValid(false);
response.setMessage("인증 코드 발송에 실패했습니다.");
return ResponseEntity.status(500).body(response);
}
}
@PostMapping("/mypage/verify-email-code")
public ResponseEntity<ValidationResponse> verifyEmailCode(
@org.springframework.web.bind.annotation.RequestBody java.util.Map<String, String> request,
HttpSession session) {
try {
String email = request.get("email");
String code = request.get("code");
// 현재 로그인한 사용자의 이메일과 일치하는지 확인
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser == null || !currentUser.getUsername().equals(email)) {
ValidationResponse response = new ValidationResponse();
response.setValid(false);
response.setMessage("유효하지 않은 요청입니다.");
return ResponseEntity.badRequest().body(response);
}
// 세션에 저장된 이메일과 일치하는지 확인
String sessionEmail = (String) session.getAttribute("verificationEmail");
if (sessionEmail == null || !sessionEmail.equals(email)) {
ValidationResponse response = new ValidationResponse();
response.setValid(false);
response.setMessage("인증 요청된 이메일과 일치하지 않습니다.");
return ResponseEntity.badRequest().body(response);
}
// 인증 코드 확인
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
session.setAttribute("emailVerified", true);
// 사용자 상태를 ACTIVE로 변경
userFacade.activateUserByEmail(email);
// 이메일 인증 관련 세션 정보 제거
session.removeAttribute("emailVerificationRequired");
session.removeAttribute("redirectUrl");
logger.info("이메일 인증 완료: {}", email);
}
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("이메일 인증 코드 확인 실패", e);
ValidationResponse response = new ValidationResponse();
response.setValid(false);
response.setMessage("인증 코드 확인에 실패했습니다.");
return ResponseEntity.status(500).body(response);
}
}
}
@@ -3,8 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
import com.eactive.apim.portal.common.validator.CellPhone;
import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRule;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotEmpty;
@@ -13,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@Data
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
@PasswordRuleForKjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
/**
@@ -1,8 +1,7 @@
package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRule;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbank;
import com.eactive.apim.portal.common.validator.UniqueId;
import com.eactive.apim.portal.portaluser.entity.UserStatus;
import lombok.Data;
@@ -14,7 +13,7 @@ import java.io.Serializable;
@PasswordMatch(input = "password", confirm = "password2")
@Data
@PasswordRuleForKbank(loginId = "userId", password = "password", mobile = "mobilePhone")
@PasswordRuleForKjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
public class UserRegisterDTO implements Serializable {
@@ -19,8 +19,8 @@ import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.event.UserVerificationEmailEvent;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
@@ -206,7 +206,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
messageHandlerService.publishEvent(UserVerificationEmailEvent.KEY, recipient, params);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
@@ -15,4 +15,6 @@ public interface UserFacade {
void updateCorporateManager(PortalUserDTO portalUserDTO);
void withdrawUser(String userId);
void activateUserByEmail(String email);
}
@@ -198,4 +198,23 @@ public class UserFacadeImpl implements UserFacade {
throw new IllegalArgumentException("새 비밀번호와 확인 비밀번호가 일치하지 않습니다.");
}
}
@Override
@Transactional
public void activateUserByEmail(String email) {
PortalUser user = portalUserService.findByEmailAddr(email);
if (user == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
}
if (user.getUserStatus() == PortalUserEnums.UserStatus.ACTIVE) {
throw new IllegalArgumentException("이미 활성화된 계정입니다.");
}
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
portalUserService.save(user);
log.info("사용자 이메일 인증 완료 - 활성화: {}", email);
}
}
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
import com.eactive.apim.portal.invitation.event.UserInvitationEvent;
import com.eactive.apim.portal.portaluser.event.UserManagerAssignedEvent;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
@@ -183,7 +184,29 @@ public class UserManFacade {
throw new IllegalStateException("Invitation is not in a cancelable state");
}
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
String name = portalUserRepository.findById(invitation.getInvitationEmail())
.map(PortalUser::getUserName)
.orElse(invitation.getInvitationEmail());
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
if (name.contains("@")) {
name = name.substring(0, name.indexOf("@"));
}
invitation.setStatus(InvitationStatus.CANCELED);
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
MessageRecipient.builder()
.username(name)
.userId(invitationId)
.build(),
new HashMap<String, Object>() {{
put("corpName", user.getPortalOrg().getOrgName());
put("managerName", user.getUserName());
}}
);
userInvitationRepository.save(invitation);
}
@@ -16,8 +16,8 @@ import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.event.UserVerificationEmailEvent;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
@@ -146,7 +146,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
sendEmailActivation(newUser);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
}
@@ -219,7 +220,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
params.put("token", tokenValue);
messageHandlerService.publishEvent(UserVerificationEmailEvent.KEY, recipient, params);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
// try {
// String encToken = encryptionUtil.encrypt(tokenValue);
@@ -96,9 +96,10 @@ public class PortalUserAuthService implements UserDetailsService {
public void initializePassword(String loginId, String userName, String mobileNumber) {
PortalUser portalUser = portalUserRepository.findByLoginIdAndUserNameAndMobileNumber(loginId, userName, mobileNumber).orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
PortalUser portalUser = portalUserRepository.findByLoginIdAndUserNameAndMobileNumber(loginId, userName, mobileNumber)
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
String tempPassword = EncryptionUtil.generateNewPasswordForKjbank(loginId);
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
portalUserRepository.save(portalUser);
@@ -49,6 +49,15 @@ public class PortalUserService {
return portalUserRepository.findByLoginId(loginId);
}
public PortalUser findByEmailAddr(String emailAddr) {
return portalUserRepository.findPortalUserByEmailAddr(emailAddr)
.orElseThrow(() -> new IllegalArgumentException("해당 이메일 주소의 사용자를 찾을 수 없습니다."));
}
public PortalUser save(PortalUser user) {
return portalUserRepository.save(user);
}
public boolean existsByLoginId(String loginId) {
return portalUserRepository.existsByLoginId(loginId);
}
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.user.validator;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
import com.eactive.apim.portal.common.validator.PasswordRuleForKjbankValidator;
import org.springframework.stereotype.Component;
@Component
@@ -16,7 +17,7 @@ public class PasswordValidator {
}
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
PasswordRuleForKbankValidator validator = new PasswordRuleForKbankValidator();
PasswordRuleForKjbankValidator validator = new PasswordRuleForKjbankValidator();
return validator.isValid(password, loginId, mobileNumber);
}
@@ -40,7 +40,7 @@ public class PortalAuthenticatedUser extends PortalUser implements UserDetails {
@Override
public boolean isAccountNonLocked() {
return true;
return !"Y".equalsIgnoreCase(this.getAccountLockYn());
}
@Override
@@ -30,7 +30,30 @@ public class EncryptionUtil {
return newpassword.toString();
}
@Value("${encryption.key:kbank_portal_application_1357902}")
/**
* 광주은행용 임시패스워드 생성
* - @ + 이메일 주소 앞 5자리 + 랜덤숫자 3자리 + !
* @return
*/
public static String generateNewPasswordForKjbank(String loginId) {
StringBuilder newpassword = new StringBuilder();
newpassword.append("@");
if (loginId.length() <= 5) {
newpassword.append(loginId);
} else {
newpassword.append(loginId.substring(0, 5));
}
for (int i = 1; i <= 3; i++) {
newpassword.append(RandomUtil.getRandomNum(0, 9));
}
newpassword.append("!");
return newpassword.toString();
}
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
private static final String ALGORITHM = "AES";
@@ -0,0 +1,24 @@
package com.eactive.apim.portal.common.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleForKjbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PasswordRuleForKjbank {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
}
@@ -0,0 +1,144 @@
package com.eactive.apim.portal.common.validator;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created by Sungpil Hyun
*/
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
// 최소 8자, 최대 20자 상수 선언
private static final int MIN = 8;
private static final int MAX = 20;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1";
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
@Override
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
// 정규식 검사객체
Matcher matcher;
// 공백 체크
if (password == null || "".equals(password)) {
return false;
}
// ASCII 문자 비교를 위한 UpperCase
String tmpPw = password.toUpperCase();
// 문자열 길이
int strLen = tmpPw.length();
// 글자 길이 체크
if (strLen > 20 || strLen < 8) {
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 비밀번호 정규식 체크
matcher = Pattern.compile(REGEX).matcher(tmpPw);
if (!matcher.find()) {
return false;
}
// 동일한 문자 3개 이상 체크
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 연속된 문자 / 숫자 3개 이상 체크
// ASCII Char를 담을 배열 선언
int[] tmpArray = new int[strLen];
// Make Array
for (int i = 0; i < strLen; i++) {
tmpArray[i] = tmpPw.charAt(i);
}
// Validation Array
for (int i = 0; i < strLen - 2; i++) {
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
return false;
}
}
// Validation Complete
return true;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
}
static boolean isContinuous(int first, int second, int third) {
// 배열의 연속된 수 검사
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
}
}
@@ -73,6 +73,7 @@ public class BaseDatasourceConfiguration {
properties.put("hibernate.default_schema", prop.getSchema());
properties.put("javax.persistence.validation.mode", "none");
properties.put("javax.persistence.transactionType", "JTA");
properties.put("hibernate.format_sql", "true");
if (prop instanceof EmsDatasourceProperty) {
persistenceUnit = "portal";
@@ -95,7 +96,7 @@ public class BaseDatasourceConfiguration {
}
// 개발 환경용
if (env.matchesProfiles("dev", "local")) {
if (env.matchesProfiles("local")) {
properties.put("hibernate.hbm2ddl.auto", "validate");
}
@@ -1,7 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.eai.data.jpa.BaseRepositoryImpl;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -9,20 +8,14 @@ import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jndi.JndiObjectFactoryBean;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import javax.annotation.PostConstruct;
import javax.naming.NamingException;
import javax.sql.DataSource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
@Configuration
@EntityScan(basePackages = {"com.eactive.apim.gateway"})
@@ -78,28 +71,6 @@ public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration
return createDataSource(gatewayProp);
}
@Primary
@Bean
public DataSource portalDataSource(EmsDatasourceProperty emsProp) {
// JNDI 이용
if (StringUtils.isNotEmpty(emsProp.getJndiName())) {
try {
return this.jndiLookup(emsProp.getJndiName());
} catch (NamingException e) {
log.warn("JNDI lookup failed.", e);
}
}
log.info("개발환경을 위해 JNDI 대신 직접 DB 접속 시도");
// JNDI 실패시 직접 접속 이용
if (StringUtils.isEmpty(emsProp.getJdbcUrl())) {
throw new IllegalArgumentException("DB 접속을 위한 JNDI 또는 서버 접속 정보가 설정되어 있지 않음");
}
return createDataSource(emsProp);
}
@Bean(name = "gatewayEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean gatewayEntityManagerFactory(
EntityManagerFactoryBuilder builder,
@@ -107,18 +78,4 @@ public class GatewayDatasourceConfiguration extends BaseDatasourceConfiguration
GatewayDataSourceProperty gatewayProp) {
return createEntityManagerFactory(builder, dataSource, gatewayProp);
}
@Primary
@Bean(name = "entityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder,
@Qualifier("gatewayDataSource") DataSource dataSource,
EmsDatasourceProperty emsProp) {
return createEntityManagerFactory(builder, dataSource, emsProp);
}
@Bean
public AuditorAware<String> auditorProvider() {
return new AuditorAwareImpl();
}
}
@@ -5,11 +5,10 @@ import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import org.apache.groovy.util.Maps;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
@@ -20,6 +19,11 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
/**
* Created by Sungpil Hyun
*/
@@ -30,10 +34,15 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
private static final Logger logger = LoggerFactory.getLogger(PortalAuthenticationFailureHandler.class);
private final PortalUserRepository portalUserRepository;
private final PortalUserLogService userLogService;
private final MessageHandlerService messageHandlerService;
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository, PortalUserLogService userLogService) {
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
PortalUserLogService userLogService,
MessageHandlerService messageHandlerService) {
this.portalUserRepository = portalUserRepository;
this.userLogService = userLogService;
this.messageHandlerService = messageHandlerService;
}
@Override
@@ -53,8 +62,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
if (user.getLoginFailureCount() >= 5) {
user.setAccountLockYn("Y");
// 계정 잠금 알림
messageHandlerService.publishEvent(
MessageCode.USER_ACCOUNT_LOCKED,
MessageRecipient.of(user),
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
}
portalUserRepository.save(user);
} catch (UserNotFoundException e) {
logger.error("{} login try {}", username, e.getMessage());
}
@@ -5,8 +5,12 @@ import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.groovy.util.Maps;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
@@ -25,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
private final PortalUserAuthService portalUserAuthService;
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private String decodePassword(String encodedPassword) {
try {
@@ -46,9 +51,22 @@ public class PortalAuthenticationManager implements AuthenticationManager {
PortalAuthenticatedUser user = (PortalAuthenticatedUser) portalUserAuthService.loadUserByUsername(username);
if (!user.isAccountNonLocked()) {
if (user.getLoginFailureCount() >= 5) {
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
}
throw new LockedException("로그인 할 수 없습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
}
// 비밀번호 검증
if (!passwordEncoder.matches(decodedPassword, user.getPassword())) {
throw new BadCredentialsException("아이디 또는 비밀번호가 일치하지 않습니다.");
// 암호화가 되어 있지 않을 수도 있어 그대로 비교 검증
if (!user.getPassword().equals(decodedPassword)) {
throw new BadCredentialsException("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
// DORMANT 상태 체크
@@ -64,9 +82,10 @@ public class PortalAuthenticationManager implements AuthenticationManager {
throw new DisabledException("사용자 승인 대기중입니다. ");
}
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.READY)) {
throw new DisabledException("이메일 인증이 완료되지 않았습니다. 인증 메일을 확인해주세요.");
}
// 25.11.13 - 이메일 인증 로그인은 허용
// if (user.getUserStatus().equals(PortalUserEnums.UserStatus.READY)) {
// throw new DisabledException("이메일 인증이 완료되지 않았습니다. 인증 메일을 확인해주세요.");
// }
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ADMINBLOCK)) {
throw new DisabledException("법인 관리자에 의해 비활성화된 계정입니다. 법인 관리자에게 문의하시기 바랍니다.");
@@ -79,9 +98,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
}
}
if (!user.isAccountNonLocked()) {
throw new LockedException("로그인할 수 없습니다. 관리자에게 문의하세요.");
}
UsernamePasswordAuthenticationToken authorityToken =
new UsernamePasswordAuthenticationToken(user, decodedPassword, user.getAuthorities());
@@ -55,7 +55,11 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
HttpSession session = request.getSession();
// 세션에 상태 저장
if (isDormantAccount(user)) {
if (isEmailVerificationRequired(user)) {
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
session.setAttribute("emailVerificationRequired", true);
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
} else if (isDormantAccount(user)) {
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
session.setAttribute("dormantAccount", true);
session.setAttribute("dormantLoginId", username);
@@ -114,4 +118,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
}
private boolean isEmailVerificationRequired(PortalUser user) {
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.spring.DatabaseSessionVerifier;
import com.eactive.eai.data.jpa.BaseRepositoryImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -57,23 +58,39 @@ public class PortalDatasourceConfiguration extends BaseDatasourceConfiguration {
@Primary
@Bean
public DataSource portalDataSource(EmsDatasourceProperty emsProp) {
DataSource dataSource = null;
log.debug("Portal datasource initialize.");
// JNDI 이용
if (StringUtils.isNotEmpty(emsProp.getJndiName())) {
try {
return this.jndiLookup(emsProp.getJndiName());
log.debug("Try get JNDI datasource : {}", emsProp.getJndiName());
dataSource = this.jndiLookup(emsProp.getJndiName());
} catch (NamingException e) {
log.warn("JNDI lookup failed.", e);
}
}
log.info("개발환경을 위해 JNDI 대신 직접 DB 접속 시도");
if (dataSource == null) {
log.info("개발환경을 위해 JNDI 대신 직접 DB 접속 시도");
// JNDI 실패시 직접 접속 이용
if (StringUtils.isEmpty(emsProp.getJdbcUrl())) {
throw new IllegalArgumentException("DB 접속을 위한 JNDI 또는 서버 접속 정보가 설정되어 있지 않음");
// JNDI 실패시 직접 접속 이용
if (StringUtils.isEmpty(emsProp.getJdbcUrl())) {
throw new IllegalArgumentException("DB 접속을 위한 JNDI 또는 서버 접속 정보가 설정되어 있지 않음");
}
dataSource = createDataSource(emsProp);
}
return createDataSource(emsProp);
if (dataSource != null && env.matchesProfiles("db_check")) {
log.debug("Activated 'db_check' profile.");
DatabaseSessionVerifier verifier = new DatabaseSessionVerifier(dataSource);
verifier.afterSingletonsInstantiated();
}
return dataSource;
}
@Primary
@@ -0,0 +1,100 @@
package com.eactive.apim.portal.spring;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import javax.sql.DataSource;
import java.sql.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
//@Configuration
//@Profile("db_check")
@Slf4j
public class DatabaseSessionVerifier implements SmartInitializingSingleton {
private final DataSource emsDataSource;
public DatabaseSessionVerifier(DataSource emsDataSource) {
this.emsDataSource = emsDataSource;
}
@Override
public void afterSingletonsInstantiated() {
log.info("DB 검증 작업 시작 - {}", now());
log.info("---------------------------------------------------");
log.info("1. 커넥션 확인");
try (Connection conn = emsDataSource.getConnection()) {
try (PreparedStatement statement = conn.prepareStatement("select 1 from dual")) {
ResultSet rs = statement.executeQuery();
if (rs.next()) {
log.info("DB 커넥션 확인됨");
}
}
log.info("\n2. Table 권한 확인 - {}", now());
Map<String, Set<String>> grantTables = this.printTablePrivileges(conn);
log.info("\n3. PTL_PROPERTY 테이블 확인 - {}", now());
if (!grantTables.containsKey("PTL_PROPERTY")) {
log.info("PTL_PROPERTY 테이블 확인 안됨. ROLE 재로딩 시도");
try (Statement stmt = conn.createStatement()) {
stmt.execute("SET ROLE_RL_EMS_ALL");
}
this.printTablePrivileges(conn);
throw new InterruptedException("DB 검증 실패로 강제 종료");
} else {
log.info("PTL_PROPERTY 테이블 확인 됨.");
}
log.info("검증 작업 종료 - {}", now());
} catch (Exception e) {
log.error("DB 세션 검증 실패", e);
throw new RuntimeException(e);
}
throw new RuntimeException("db_check profile end.");
}
private LinkedHashMap<String, Set<String>> printTablePrivileges(Connection conn) throws SQLException {
LinkedHashMap<String, Set<String>> tables = new LinkedHashMap<>();
try (PreparedStatement stmt = conn.prepareStatement("SELECT * FROM ROLE_TAB_PRIVS WHERE role = ? ORDER BY table_name ASC")) {
stmt.setString(1, "RL_EMS_ALL");
ResultSet rs = stmt.executeQuery();
while(rs.next()) {
Set<String> privileges;
if (tables.containsKey(rs.getString("table_name"))) {
privileges = tables.get(rs.getString("table_name"));
} else {
privileges = new LinkedHashSet<>();
tables.put(rs.getString("table_name").toUpperCase(), privileges);
}
privileges.add(rs.getString("privilege"));
}
log.info("| Table | Privileges |");
log.info("|---|---|");
tables.forEach((key, value) -> log.info(String.format("| %-20s | %-36s |", key, value)));
}
return tables;
}
private String now() {
return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
}
@@ -0,0 +1,28 @@
package com.eactive.apim.portal.tools;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.sql.SQLException;
@Component
@Aspect
@Slf4j
public class JpaErrorLoggingAspect {
@PersistenceContext
private EntityManager em;
@AfterThrowing(pointcut = "@annotation(org.springframework.transaction.annotation.Transactional)", throwing = "ex")
public void onError(SQLException ex) {
try {
String user = (String) em.createNativeQuery("select user from dual").getSingleResult();
log.error("DB USER = {}, SQL ERROR = {}", user, ex.getMessage() );
} catch (Exception e) {
log.error("Failed to fetch DB User", e);
}
}
}
+17
View File
@@ -18,6 +18,23 @@ spring:
thymeleaf:
cache: false
ems:
datasource:
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: EMSAPP
password: EMSAPP123!
gateway:
datasource:
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: AGWAPP
password: AGWAPP123!
portal:
auth-virtual-code: 654321
+4 -4
View File
@@ -19,8 +19,8 @@ spring:
ems:
datasource:
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: EMSAPP
password: EMSAPP123!
@@ -28,8 +28,8 @@ ems:
gateway:
datasource:
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/PAPM
driver-class-name: oracle.jdbc.OracleDriver
username: AGWAPP
password: AGWAPP123!
@@ -0,0 +1,51 @@
server.port: '30200'
spring:
jta:
enabled: true
jpa:
properties:
hibernate:
show_sql: false
format_sql: true
use_sql_comments: true
devtools:
restart:
enabled: false
livereload:
enabled: true
thymeleaf:
cache: false
ems:
datasource:
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
jdbc-url: jdbc:oracle:thin:@//10.14.8.30:11521/ELINKDB
driver-class-name: oracle.jdbc.OracleDriver
username: EMSAPP
password: EMSAPP123!
gateway:
datasource:
# jdbc-url: jdbc:oracle:thin:@//192.168.240.177:1599/DAPM
jdbc-url: jdbc:oracle:thin:@//10.14.8.30:11521/ELINKDB
driver-class-name: oracle.jdbc.OracleDriver
username: AGWAPP
password: AGWAPP123!
portal:
logging:
log-path: C:\eactive\logs
auth-virtual-code: 654321
proxy:
targets:
stg: http://localhost:10000/monitoring
prod: http://localhost:10000/monitoring
# stg: http://inter-ndapiap01.k-bank.com:7090/monitoring
# prod: http://inter-ndapiap01.k-bank.com:7090/monitoring
timeout:
connect: 5000
read: 5000
+19
View File
@@ -18,6 +18,25 @@ spring:
portal:
auth-virtual-code: 654321
ems:
datasource:
jndi-name: jdbc/dsOBP_EMS
schema: EMSADM
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.apim.portal
gateway:
datasource:
jndi-name: jdbc/dsOBP_AGW
schema: AGWADM
hibernate-dialect: org.hibernate.dialect.Oracle12cDialect
hibernate-physical-naming-strategy: com.eactive.apim.portal.common.entity.CustomPhysicalNamingStrategy
entity-package: com.eactive.eai.data.entity.onl
#proxy:
# targets:
# stg: http://inter-dapiwas01.k-bank.com:7090/monitoring
+3
View File
@@ -14,6 +14,7 @@ server:
whitelabel:
enabled: false
path: /error
forward-headers-strategy: native
spring:
@@ -74,6 +75,8 @@ portal:
log-path: /Log/App/eapim/
auth-ttl: 300
auth:
resend_limit_seconds: 30
user-approval: true
password-expiration-days: 90
+68 -7
View File
@@ -1,21 +1,82 @@
<configuration scan="true" scanPeriod="5 seconds">
<property name="LOG_PATH" value="/Log/App/eapim//${inst.Name:-devSvrUD}"/>
<appendar name="FILE_DEBUG" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appendar>
<property name="PATTERN" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<property name="PATTERN_DATE" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%-15thread] %-5level %logger - %msg%n" />
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" level="DEBUG" additivity="false">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>${PATTERN_DATE}</pattern></encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/debug.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/debug.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<appender name="FILE_DEBUG" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE_HIBERNATE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/debug-hibernate.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/debug-hibernate.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
<pattern>${PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE_REPORT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/debug-report.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/debug-report.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="FILE"/>
</root>
<logger name="com.eactive.eapim" level="DEBUG" />
<logger name="weblogic" level="INFO" />
<logger name="org.springframework" level="info" />
<logger name="_org.springframework" level="info" />
<!-- Hibernate SQL Logging -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.transaction" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction.interceptor" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.orm.jpa" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
</configuration>
+1
View File
@@ -40,6 +40,7 @@
<logger name="com.eactive.apim" level="DEBUG" />
<logger name="org.thymeleaf.templateparser" level="DEBUG" />
<!-- Hibernate SQL Logging -->
+82
View File
@@ -0,0 +1,82 @@
<configuration scan="true" scanPeriod="5 seconds">
<property name="LOG_PATH" value="/eactive/logs/${inst.Name:-devSvr_undefined}"/>
<property name="PATTERN" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<property name="PATTERN_DATE" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>${PATTERN_DATE}</pattern></encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/root.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/root.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder><pattern>${PATTERN}</pattern></encoder>
</appender>
<appender name="FILE_HIBERNATE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/hibernate.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE_REPORT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/report.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/report.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.eactive.eapim" level="DEBUG" />
<!-- hibernate 로그 -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.transaction.internal.TransactionImpl" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.Transaction" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.transaction.interceptor" level="TRACE" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.atomikos" level="DEBUG" additivity="false">
<appender-ref ref="FILE_HIBERNATE" />
</logger>
<logger name="com.eactive.apim.portal.spring.DatabaseSessionVerifier" level="debug">
<appender-ref ref="FILE_REPORT" />
</logger>
</configuration>
+1 -1
View File
@@ -102,7 +102,7 @@
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
></logger>
</logger>
<logger name="com.eactive.eapim" level="DEBUG"/>
<logger name="kjb.safedb" level="DEBUG" additivity="false">
+3 -1
View File
@@ -163,7 +163,9 @@ document.addEventListener('DOMContentLoaded', function() {
const sub = document.getElementById('sub');
// 초기에 서브 메뉴 숨기기
sub.style.display = 'none';
if (sub == null) {
sub.style.display = 'none';
}
// 마우스가 메인 네비게이션에 진입했을 때
mainNav.addEventListener('mouseenter', function() {
@@ -1,372 +1,476 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_index_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_index_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="hero-section">
<div class="hero-patterns">
<div class="pattern-circle pattern-1"></div>
<div class="pattern-circle pattern-2"></div>
<div class="pattern-dots"></div>
</div>
<div class="container">
<div class="hero-content">
<div class="hero-badge">
<span class="badge-icon">🚀</span>
<span>Wa bank 생태계에 오신 것을 환영합니다!</span>
<section layout:fragment="contentFragment" class="content main">
<script th:src="@{/js/htmx.min.js}"></script>
<!-- main slick -->
<div class="slider-container">
<div class="slider m-only">
<div class="slider_box">
<img th:src="@{/img/img_mainslick_m.png}" alt="슬라이드 1">
<div class="slick_txt">
<p>디지털 금융 리더 <span>케이뱅크</span><br/>새로운 비즈니스 기회를<br/>제공합니다.</p>
</div>
<h1 class="main-headline">
<span class="headline-wa">Wa한</span> 금융 혁신,<br>
여러분의 아이디어로 시작됩니다
</h1>
<h2 class="sub-headline">
광주은행 오픈 API로 새로운 금융 서비스를 만들어보세요.<br>
안전하고 빠르게, 그리고 쉽게 연동할 수 있습니다.
</h2>
<div class="hero-buttons">
<a href="/apis" class="btn btn-primary">
<i class="fas fa-code"></i> API 둘러보기
</a>
<a href="#get-started" class="btn btn-secondary">
<i class="fas fa-play"></i> 5분 만에 시작하기
</a>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick2_m.png}" alt="슬라이드 2">
<div class="slick_txt">
<p>차별화된 <span>API와<br> 편리한 테스트 환경</span><br> 지원합니다</p>
</div>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick3_m.png}" alt="슬라이드 3">
<div class="slick_txt">
<p><span>디지털 금융 혁신</span><br> 케이뱅크가<br> 함께합니다</p>
</div>
</div>
</div>
</section>
<!-- API 검색 섹션 -->
<section class="api-search-section">
<div class="container">
<div class="search-container">
<form th:action="@{/apis}" method="get" class="search-form">
<div class="search-wrapper">
<input type="text" class="search-input" placeholder="원하는 API를 검색해보세요" id="apiSearchInput" name="keyword">
<button class="search-button" type="submit">
<i class="fas fa-search"></i>
<span>검색하기</span>
</button>
</div>
</form>
<div class="hashtag-suggestions" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<span class="hashtag-label">추천 검색어:</span>
<a href="#" class="hashtag" th:each="tag : ${hashtags}"
th:data-search="${tag}"
th:text="${'#' + tag}">
</a>
<div class="slider pc-only">
<div class="slider_box">
<img th:src="@{/img/img_mainslick_pc.png}" alt="슬라이드 1">
<div class="slick_txt">
<p><span>케이뱅크</span>가 새로운 비즈니스<br> 기회를 제공합니다</p>
</div>
<!-- Fallback hashtags if no hashtags from controller -->
<div class="hashtag-suggestions" th:unless="${hashtags != null and not #lists.isEmpty(hashtags)}">
<span class="hashtag-label">추천 검색어:</span>
<a href="#" class="hashtag" data-search="계좌조회">#계좌조회</a>
<a href="#" class="hashtag" data-search="송금API">#송금API</a>
<a href="#" class="hashtag" data-search="실시간결제">#실시간결제</a>
<a href="#" class="hashtag" data-search="오픈뱅킹">#오픈뱅킹</a>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick2_pc.png}" alt="슬라이드 2">
<div class="slick_txt">
<p>차별화된 <span>API</span><br/>
<span>편리한 테스트 환경</span><br/>지원합니다</p>
</div>
</div>
<div class="slider_box">
<img th:src="@{/img/img_mainslick3_pc.png}" alt="슬라이드 3">
<div class="slick_txt">
<p><span>디지털 금융 혁신</span><br>케이뱅크가<br>함께합니다</p>
</div>
</div>
</div>
</section>
<button class="slick-prev" aria-label="Previous" type="button">이전</button>
<button class="slick-next" aria-label="Next" type="button">다음</button>
<ul class="slick-dots">
<li>
<button type="button">1</button>
</li>
<li>
<button type="button">2</button>
</li>
<li>
<button type="button">3</button>
</li>
</ul>
</div>
<!-- // main slick -->
<section class="api-showcase">
<div class="container">
<div class="section-header">
<h2 class="section-title">
<span class="title-highlight">인기 API</span>로 바로 시작하세요
</h2>
<p class="section-subtitle">가장 많이 사용되는 API를 확인하고 바로 테스트해보세요</p>
<div class="main-container">
<div class="search_type">
<form th:action="@{/apis}" id="searchForm" method="get">
<div class="search-container">
<input type="text" name="keyword" class="search-box" id="search-box" placeholder="어떤 API를 찾고 계신가요?">
<button class="search-button" type="submit"></button>
</div>
</form>
</div>
<div class="hashtag">
<ul>
<li th:each="tag: ${hashtags}"><a href="#" th:text="${tag}">#인증</a></li>
</ul>
</div>
<div class="main_keyword">
<div class="api_tit">
<h2>가장 많이 찾는 API</h2>
<p class="total_view">
<a href="/apis">전체보기</a>
</p>
</div>
<div class="api-grid">
<!-- Dynamic API Cards from Controller (max 4) -->
<div th:each="api, iterStat : ${apis}"
th:if="${iterStat.index < 4}"
class="api-card"
th:classappend="${iterStat.index == 0} ? 'api-card-popular' : (${iterStat.index == 3} ? 'api-card-new' : '')">
<!-- Show badge for first (인기) and last (NEW) items -->
<div th:if="${iterStat.index == 0}" class="api-badge">인기</div>
<div th:if="${iterStat.index == 3}" class="api-badge new">NEW</div>
<!-- API Icon - using different icons based on index or API type -->
<div class="api-icon">
<!-- Icon selection based on API name or service type -->
<i th:if="${api.apiName != null}"
th:class="${#strings.containsIgnoreCase(api.apiName, '계좌') or #strings.containsIgnoreCase(api.apiName, '조회')} ? 'fas fa-wallet' :
(${#strings.containsIgnoreCase(api.apiName, '송금') or #strings.containsIgnoreCase(api.apiName, '이체')} ? 'fas fa-exchange-alt' :
(${#strings.containsIgnoreCase(api.apiName, '카드') or #strings.containsIgnoreCase(api.apiName, '결제')} ? 'fas fa-credit-card' :
(${#strings.containsIgnoreCase(api.apiName, 'AI') or #strings.containsIgnoreCase(api.apiName, '분석')} ? 'fas fa-chart-line' :
(${iterStat.index == 0} ? 'fas fa-wallet' :
(${iterStat.index == 1} ? 'fas fa-exchange-alt' :
(${iterStat.index == 2} ? 'fas fa-credit-card' : 'fas fa-chart-line'))))))"></i>
</div>
<!-- API Name -->
<h3 class="api-name" th:text="${api.apiName}">API 이름</h3>
<!-- API Description -->
<p class="api-description" th:text="${api.apiSimpleDescription != null ? api.apiSimpleDescription : api.description}">API 설명</p>
<!-- API Features/Method Info -->
<div class="api-features">
<span class="feature-tag" th:if="${api.apiMethod != null}" th:text="${api.apiMethod}">GET</span>
<span class="feature-tag" th:if="${api.service != null}" th:text="${api.service}">서비스</span>
</div>
<!-- Test Link -->
<a th:href="@{/apis/detail(id=${api.apiId})}" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
<div class="api_keyword">
<ul class="keyword_list tab_list">
<li>
<a href="#"
hx-get="/api_fragments"
hx-target="#api_spec_list"
hx-trigger="click"
onclick="setActive(this)">전체<span class="number" th:text="${totalApiCount}"></span></a>
</li>
<li th:each="item : ${services}">
<a href="#"
th:hx-get="@{/api_fragments(id=${item.id})}"
hx-target="#api_spec_list"
hx-trigger="click"
onclick="setActive(this)">
[[${item.groupName}]]<span class="number" th:text="${item.apiGroupApiList.size()}"></span>
</a>
</li>
</ul>
<div class="more_type m-only">
<button class="showmore" type="button">
<img th:src="@{/img/icon/icon_more_btn.png}" alt="더보기 버튼">
</button>
</div>
<div class="more_type pc-only">
<button class="showmore" type="button">
<img th:src="@{/img/icon/icon_more_btn2.png}" alt="더보기 버튼">
</button>
</div>
</div>
<!-- Fallback: Show static cards if no APIs from controller -->
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card api-card-popular">
<div class="api-badge">인기</div>
<div class="api-icon">
<i class="fas fa-wallet"></i>
<div class="api_info_container box_list" id="api_spec_list">
<div class="api_info_box" th:each="api : ${apis}" th:data-href="@{/apis/detail(id=${api.apiId})}">
<div class="api_list" th:data-href="@{/apis/detail(id=${api.apiId})}">
<p class="txt1" th:text="${api.apiName}"></p>
<p class="txt2" th:text="${api.apiSimpleDescription}"></p>
</div>
<h3 class="api-name">계좌 조회 API</h3>
<p class="api-description">실시간 잔액 조회와 거래내역을 안전하게 확인</p>
<div class="api-features">
<span class="feature-tag">실시간</span>
<span class="feature-tag">OAuth 2.0</span>
<div class="api_img" style="height: 48px;">
<a th:href="@{/apis/detail(id=${api.apiId})}" class="btn btn-primary">
<img th:if="${#strings.isEmpty(api.mainIcon) == false}" th:src="${api.mainIcon}" alt="">
</a>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card">
<div class="api-icon">
<i class="fas fa-exchange-alt"></i>
</div>
<h3 class="api-name">송금 API</h3>
<p class="api-description">간편하고 안전한 송금 서비스 구현</p>
<div class="api-features">
<span class="feature-tag">간편송금</span>
<span class="feature-tag">실명확인</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card">
<div class="api-icon">
<i class="fas fa-credit-card"></i>
</div>
<h3 class="api-name">카드 결제 API</h3>
<p class="api-description">온/오프라인 결제 솔루션 통합</p>
<div class="api-features">
<span class="feature-tag">PG연동</span>
<span class="feature-tag">간편결제</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
<div th:if="${apis == null or #lists.isEmpty(apis)}" class="api-card api-card-new">
<div class="api-badge new">NEW</div>
<div class="api-icon">
<i class="fas fa-chart-line"></i>
</div>
<h3 class="api-name">AI 신용평가 API</h3>
<p class="api-description">머신러닝 기반 실시간 신용 분석</p>
<div class="api-features">
<span class="feature-tag">AI/ML</span>
<span class="feature-tag">실시간</span>
</div>
<a href="#" class="api-link">
테스트하기 <i class="fas fa-arrow-right"></i>
</a>
</div>
</div>
</div>
</section>
<!-- 정보 섹션 -->
<section class="info-section">
<div class="container">
<div class="info-wrapper">
<div class="image-container">
<div class="image-placeholder">
<i class="fas fa-chart-line"></i>
<span>금융 혁신</span>
</div>
</div>
<div class="main_con">
<p class="con_tit m-only"><img th:src="@{/img/img_con_tit.png}" alt="Kbank API PORTAL"></p>
<p class="con_tit pc-only"><img th:src="@{/img/img_con_tit2.png}" alt="Kbank API PORTAL"></p>
<p class="con_img m-only"><img th:src="@{/img/img_mask_group.png}" alt="Kbank API PORTAL"></p>
<p class="con_img pc-only"><img th:src="@{/img/img_mask_group2.png}" alt="Kbank API PORTAL"></p>
<p class="con_txt m-only">
<span>Kbank API Portal</span>은 기업이 금융서비스를<br>
편리하게 개발할 수 있도록 차별화된<br>
API와 테스트환경을 제공합니다.<br>
</p>
<p class="con_txt pc-only">
<span>Kbank API Portal</span>은 기업이 금융서비스를 편리하게 개발할 수 있도록 차별화된 API와 테스트환경을 제공합니다.
</p>
<div class="main_btn">
<a th:href="@{/intro}" class="common_btn_type_5"><span>처음 만나는 케이뱅크 API</span></a>
<a th:href="@{/partnership}" class="common_btn_type_5 m_btn"><span>사업 제휴 문의</span></a>
</div>
</div>
</div>
<div class="info-content">
<p class="highlight-text">
광주은행 <span class="text-accent">API Portal</span>은 기업이 금융서비스를 편리하게 개발할 수 있도록<br>
차별화된 API와 테스트환경을 제공합니다.
<div class="tiding_box">
<div class="api_type">
<div class="api_tiding">
<div class="api_tit">
<h2>새로운 케이뱅크 API 소식을<br> 만나보세요</h2>
<p class="total_view">
<a href="/portalnotice">전체보기</a>
</p>
<div class="action-buttons">
<a href="#" class="action-btn">
<span class="btn-number">1</span>
<span class="btn-text">처음 만나는 광주뱅크 API</span>
<i class="fas fa-arrow-right"></i>
</div>
<div class="api_tiding_list">
<ul>
<li th:each="notice : ${portalNotices}">
<a th:href="@{/portalnotice/detail(id=${notice.id})}">
<p th:text="${notice.noticeSubject}"></p>
</a>
<span th:text="${#temporals.format(notice.createdDate, 'yyyy.MM.dd')}"></span>
</li>
</ul>
</div>
</div>
<div class="api_info">
<ul>
<li class="img1">
<a th:href="@{/faq_list}">
<p>FAQ</p>
<span>자주 묻는 질문과 답변을<br> 확인해보세요</span>
<img th:src="@{/img/icon/icon_petals01.png}" alt="faq 이미지">
</a>
<a href="#" class="action-btn">
<span class="btn-number">2</span>
<span class="btn-text">사업 제휴 문의</span>
<i class="fas fa-arrow-right"></i>
</li>
<li class="img2">
<a th:href="@{/inquiry}">
<p>Q&amp;A</p>
<span>문의사항에<br> 친절하게 답변 드리겠습니다</span>
<img th:src="@{/img/icon/icon_petals02.png}" alt="Q&A 이미지">
</a>
</div>
</div>
</li>
<li class="img3">
<a th:href="@{/partnership}">
<p>사업제휴</p>
<span>온라인 비즈니스 혁신을 위한<br> 사업 제안을 환영합니다</span>
<img th:src="@{/img/icon/icon_petals03.png}" alt="사업제휴 이미지">
</a>
</li>
</ul>
</div>
</div>
</section>
</div>
<!-- 지원 센터 섹션 -->
<section class="support-center">
<div class="container">
<div class="section-header">
<h2 class="section-title">
도움이 <span class="title-highlight">필요하신가요?</span>
</h2>
<p class="section-subtitle">다양한 방법으로 필요한 정보를 찾아보세요</p>
</div>
<div class="support-grid">
<a th:href="@{/portalnotice}" class="support-card">
<div class="card-icon">
<i class="fas fa-bullhorn"></i>
</div>
<h3>공지사항</h3>
<p>최신 업데이트와 중요한 소식을 확인하세요</p>
<div class="card-arrow">
<i class="fas fa-arrow-right"></i>
</div>
</a>
<a th:href="@{/faq_list}" class="support-card">
<div class="card-icon">
<i class="fas fa-question-circle"></i>
</div>
<h3>자주 묻는 질문</h3>
<p>자주 묻는 질문과 답변을 빠르게 찾아보세요</p>
<div class="card-arrow">
<i class="fas fa-arrow-right"></i>
</div>
</a>
<a th:href="@{/inquiry}" class="support-card">
<div class="card-icon">
<i class="fas fa-comments"></i>
</div>
<h3>문의하기</h3>
<p>추가 도움이 필요하시면 직접 문의해주세요</p>
<div class="card-arrow">
<i class="fas fa-arrow-right"></i>
</div>
</a>
<div class="api_count">
<div class="api_total_box">
<p class="txt m-only">우리곁의 수많은 서비스들이<br> <em>케이뱅크API</em><br> 함께하고 있습니다</p>
<p class="txt pc-only">우리곁의 수많은 서비스들이<br> <em>케이뱅크API와 함께</em>하고 있습니다</p>
<div class="api_total">
<ul>
<li>
<img th:src="@{/img/icon/icon_api01.png}" alt="서비스 이용 수">
<div class="api_num">
<p th:text="${serviceCount}"></p>
<span>서비스 이용</span>
</div>
</li>
<li>
<img th:src="@{/img/icon/icon_api02.png}" alt="API 이용건수">
<div class="api_num">
<p th:text="${selectedApiCount}"></p>
<span>API 이용</span>
</div>
</li>
<li>
<img th:src="@{/img/icon/icon_api03.png}" alt="API 활용기업">
<div class="api_num">
<p th:text="${approvedOrgCount}"></p>
<span>API 활용 기업</span>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
<!-- API 활용 현황 섹션 -->
<section class="api-stats-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">
우리곁의 수많은 서비스들이<br>
<span class="title-highlight">광주은행 API</span>와 함께하고 있습니다
</h2>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-store"></i>
</div>
<div class="stat-content">
<div class="stat-number" th:attr="data-target=${serviceCount != null ? serviceCount : 0}" th:text="${serviceCount != null ? serviceCount : 0}">0</div>
<div class="stat-label">서비스 이용 수</div>
<div class="stat-description">다양한 분야의 서비스가 활용중</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-exchange-alt"></i>
</div>
<div class="stat-content">
<div class="stat-number" th:attr="data-target=${totalApiCount != null ? totalApiCount : 0}" th:text="${totalApiCount != null ? totalApiCount : 0}">0</div>
<div class="stat-label">API 이용 건수</div>
<div class="stat-description">일일 평균 API 호출 수</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-building"></i>
</div>
<div class="stat-content">
<div class="stat-number" th:attr="data-target=${approvedOrgCount != null ? approvedOrgCount : 0}" th:text="${approvedOrgCount != null ? approvedOrgCount : 0}">0</div>
<div class="stat-label">API 활용 기업</div>
<div class="stat-description">혁신적인 파트너사와 함께</div>
</div>
</div>
</div>
</div>
</section>
</th:block>
<div id="main_bg"></div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script>
// 숫자 카운트업 애니메이션
document.addEventListener('DOMContentLoaded', function() {
const observerOptions = {
threshold: 0.5,
rootMargin: '0px 0px -100px 0px'
};
const animateNumbers = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const numbers = entry.target.querySelectorAll('.stat-number');
numbers.forEach(number => {
const target = parseInt(number.getAttribute('data-target'));
const duration = 2000; // 2초
const increment = target / (duration / 16);
let current = 0;
const updateNumber = () => {
current += increment;
if (current < target) {
number.textContent = Math.floor(current).toLocaleString('ko-KR');
requestAnimationFrame(updateNumber);
} else {
number.textContent = target.toLocaleString('ko-KR');
}
};
updateNumber();
});
observer.unobserve(entry.target);
}
});
};
const observer = new IntersectionObserver(animateNumbers, observerOptions);
const statsSection = document.querySelector('.api-stats-section');
if (statsSection) {
observer.observe(statsSection);
<script th:inline="javascript">
$(document).ready(function() {
// 기존 success 메시지 처리
var successMsg = [[${success}]];
console.log("Success message:", successMsg);
if (successMsg) {
console.log("Showing alert");
customPopups.showAlert(successMsg);
}
// API 검색 기능
const searchInput = document.getElementById('apiSearchInput');
const searchForm = document.querySelector('.search-form');
const hashtags = document.querySelectorAll('.hashtag');
// 이메일 인증 체크
const emailVerificationRequired = [[${session.emailVerificationRequired}]];
if (emailVerificationRequired) {
const emailVerifyMsg = /*[[${session.success}]]*/ '';
const redirectUrl = /*[[${session.redirectUrl}]]*/ '';
// 해시태그 클릭 이벤트
hashtags.forEach(tag => {
tag.addEventListener('click', function(e) {
e.preventDefault();
const searchTerm = this.getAttribute('data-search');
searchInput.value = searchTerm;
// 폼 제출
if (searchForm) {
searchForm.submit();
console.log('emailVerifyMsg:', emailVerifyMsg);
console.log('redirectUrl:', redirectUrl);
customPopups.showAlert(emailVerifyMsg);
$('#customAlertOkButton').off('click.custom').on('click.custom', function () {
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
return; // 다른 체크 중단
}
// 장기미사용 계정 체크
const dormantAccount = [[${session.dormantAccount}]];
if (dormantAccount) {
const dormantMsg = /*[[${session.success}]]*/ '';
const redirectUrl = /*[[${session.redirectUrl}]]*/ '';
console.log('dormantMsg:', dormantMsg);
console.log('redirectUrl:', redirectUrl);
customPopups.showAlert(dormantMsg);
$('#customAlertOkButton').off('click.custom').on('click.custom', function () {
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
return; // 다른 체크 중단
}
// 비밀번호 만료 체크 추가
const passwordExpired = [[${session.passwordExpired}]];
if (passwordExpired) {
const pwdExpiredMsg = /*[[${session.success}]]*/ '';
const formattedMsg = pwdExpiredMsg.replace(/\n/g, '<br>');
const redirectUrl = /*[[${session.redirectUrl}]]*/ '';
console.log('pwdExpiredMsg:', pwdExpiredMsg);
console.log('redirectUrl:', redirectUrl);
$('#customAlertMessage').html(formattedMsg);
$('#customAlert').css('display','flex');
$('#customAlertOkButton').off('click.custom').on('click.custom', function () {
customPopups.hideAlert('#customAlert');
window.location.href = redirectUrl;
});
}
});
</script>
<script>
function setActive(element) {
// Remove 'active' class from all links
document.querySelectorAll('.keyword_list a').forEach(el => {
el.classList.remove('active');
});
// Add 'active' class to clicked link
element.classList.add('active');
}
document.addEventListener('DOMContentLoaded', (event) => {
const ITEMS_PER_PAGE = 4;
let isExpanded = false;
// 더보기 버튼 초기화
function initMoreButton() {
const mobileMoreBtn = document.querySelector('.more_type.m-only .showmore');
const pcMoreBtn = document.querySelector('.more_type.pc-only .showmore');
const apiInfoBoxes = document.querySelectorAll('.api_info_box');
// 5개 미만이면 더보기 버튼 숨기기
const mobileMoreContainer = document.querySelector('.more_type.m-only');
const pcMoreContainer = document.querySelector('.more_type.pc-only');
console.log('더보기 버튼 초기화:', {
항목개수: apiInfoBoxes.length,
보여질상태: apiInfoBoxes.length >= 5 ? 'block' : 'none'
});
// 더보기 버튼 표시 여부 설정
if (apiInfoBoxes.length < 5) {
mobileMoreContainer.style.setProperty('display', 'none', 'important');
pcMoreContainer.style.setProperty('display', 'none', 'important');
} else {
mobileMoreContainer.style.setProperty('display', 'block', 'important');
pcMoreContainer.style.setProperty('display', 'block', 'important');
}
// 초기 상태 설정
updateApiListVisibility();
// 더보기 버튼 이벤트 리스너 설정
[mobileMoreBtn, pcMoreBtn].forEach(btn => {
if (btn) {
btn.removeEventListener('click', handleMoreClick);
btn.addEventListener('click', handleMoreClick);
}
});
}
function handleMoreClick(e) {
e.preventDefault();
console.log('더보기 버튼 클릭');
isExpanded = !isExpanded; // 상태 토글
// 버튼 이미지/텍스트 변경
const mobileMoreBtn = document.querySelector('.more_type.m-only .showmore img');
const pcMoreBtn = document.querySelector('.more_type.pc-only .showmore img');
if (mobileMoreBtn) {
mobileMoreBtn.alt = isExpanded ? "접기 버튼" : "더보기 버튼";
}
if (pcMoreBtn) {
pcMoreBtn.alt = isExpanded ? "접기 버튼" : "더보기 버튼";
}
updateApiListVisibility();
}
// API 리스트 표시 상태 업데이트
function updateApiListVisibility() {
const apiInfoBoxes = document.querySelectorAll('.api_info_box');
const endIndex = isExpanded ? apiInfoBoxes.length : ITEMS_PER_PAGE;
console.log('업데이트 - API 목록:', {
총개수: apiInfoBoxes.length,
표시상태: isExpanded ? '전체표시' : '일부표시',
표시할개수: endIndex
});
apiInfoBoxes.forEach((box, index) => {
box.style.display = index < endIndex ? 'block' : 'none';
});
}
// HTMX 로드 완료 이벤트 리스너
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'api_spec_list') {
console.log('HTMX 컨텐츠 로드됨 - 페이지 초기화');
isExpanded = false;
initMoreButton();
}
});
document.body.addEventListener('htmx:afterSettle', function(evt) {
const apiListItems = document.querySelectorAll('.api_list');
apiListItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
const apiItems = document.querySelectorAll('.api_info_box');
apiItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
});
// 초기 실행
console.log('초기 실행');
initMoreButton();
// All 카테고리를 기본으로 active 설정
document.querySelector('.keyword_list li:first-child a').classList.add('active');
const apiListItems = document.querySelectorAll('.api_list');
apiListItems.forEach(function(item) {
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
const apiItems = document.querySelectorAll('.api_info_box');
apiItems.forEach(function(item) {
item.removeEventListener('click',null);
item.addEventListener('click', function() {
const href = this.getAttribute('data-href');
if (href) {
window.location.href = href;
}
});
});
G
const hashtagItems = document.querySelectorAll('.hashtag ul li a');
const searchBox = document.getElementById('search-box');
hashtagItems.forEach(function(item) {
item.addEventListener('click', function(e) {
e.preventDefault();
// Remove the # symbol if present and set the text in search box
const searchText = this.textContent.replace('#', '').trim();
searchBox.value = searchText;
document.getElementById("searchForm").submit();
});
});
const infoItems = document.querySelectorAll('.api_info ul li');
infoItems.forEach(function (item) {
item.style.cursor = 'pointer';
item.addEventListener('click', function() {
const link = this.querySelector('a');
if (link) {
location.href = link.getAttribute('href');
}
});
});
});
</script>
</th:block>
@@ -0,0 +1,232 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<!-- 타이틀 -->
<div class="sub_title2" id="title-verification-email">
<h2 class="title add5">이메일 인증</h2>
</div>
<div class="inner i_cs10 h_inner2">
<div class="form_type">
<form id="verificationEmailForm" role="form" name="verificationEmailForm" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" id="userId" name="userId" th:value="${userId}">
<!-- 이메일 주소 표시 (readonly) -->
<div class="info1 p_top2">
<p class="title w_tit">
<span class="dot">이메일 주소</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp4">
<input type="email" id="email" name="email" class="common_input_type_1"
th:value="${email}" readonly style="background-color: #f5f5f5;">
</div>
</div>
<p class="info_txt" style="margin-top: 10px; color: #666;">
위 이메일 주소로 인증코드가 발송됩니다.
</p>
</div>
<!-- 인증코드 받기 버튼 -->
<div class="info1 pt28">
<div class="btn_check" style="width: 100%;">
<a class="common_btn_type_2 cbt btn_send_code" style="width: 100%;"><span>인증코드 받기</span></a>
</div>
</div>
<!-- 인증번호 입력 -->
<div class="info1 pt28 auth-code-container" id="authCodeContainer" style="display: none;">
<p class="title w_tit">
<span class="dot">인증코드 입력</span>
</p>
<div class="info_line">
<div class="info_box1 w_inp5">
<div class="certify_box">
<input type="text" id="authCode" class="common_input_type_1 w_input"
placeholder="이메일로 받은 인증코드를 입력하세요" maxlength="6" required>
<span class="certify_time" id="certify_time">05:00</span>
</div>
</div>
<div class="btn_check">
<a class="common_btn_type_2 cbt btn_verify_code"><span>인증코드 확인</span></a>
</div>
</div>
</div>
<!-- 제출 버튼 -->
<div class="btn_application m_top">
<a type="button" class="common_btn_type_1 gray btn_cancel"><span>취소</span></a>
<!-- <div class="btn_gap"></div>-->
<!-- <button type="button" class="common_btn_type_1 btn_complete" disabled><span>인증 완료</span></button>-->
</div>
</form>
</div>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function() {
let timerInterval;
let resendTimerInterval;
let isVerified = false;
// CSRF 토큰 설정
const csrfHeaderName = /*[[${_csrf.headerName}]]*/ 'X-CSRF-TOKEN';
const csrfToken = /*[[${_csrf.token}]]*/ '';
// 서버 설정값
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
// 취소 버튼 클릭 시 메인 페이지로 이동
$('.btn_cancel').on('click', function() {
if (confirm('이메일 인증을 취소하고 메인 페이지로 이동하시겠습니까?')) {
location.href = '/';
}
});
// 타이머 시작 함수 (인증 코드 유효 시간)
function startTimer(duration) {
let timer = duration;
clearInterval(timerInterval);
timerInterval = setInterval(function () {
let minutes = parseInt(timer / 60, 10);
let seconds = parseInt(timer % 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
$('#certify_time').text(minutes + ":" + seconds);
if (--timer < 0) {
clearInterval(timerInterval);
$('#certify_time').text("00:00").css('color', 'red');
customPopups.showAlert("인증 시간이 만료되었습니다. 인증코드를 다시 받아주세요.");
}
}, 1000);
}
// 재발송 타이머 시작 함수
function startResendTimer() {
let resendTimer = resendLimitSeconds;
const $button = $('.btn_send_code');
clearInterval(resendTimerInterval);
$button.prop('disabled', true);
resendTimerInterval = setInterval(function () {
if (resendTimer > 0) {
$button.find('span').text('재발송 가능 (' + resendTimer + '초)');
resendTimer--;
} else {
clearInterval(resendTimerInterval);
$button.prop('disabled', false).find('span').text('인증코드 재발송');
}
}, 1000);
}
// 인증코드 받기 버튼 클릭
$('.btn_send_code').on('click', function(e) {
e.preventDefault();
const email = $('#email').val();
const $button = $(this);
$button.prop('disabled', true).find('span').text('발송 중...');
$.ajax({
url: '/mypage/send-verification-code',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ email: email }),
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
customPopups.showAlert('인증코드가 이메일로 발송되었습니다.');
$('#authCodeContainer').show();
startTimer(300); // 5분 타이머
startResendTimer(); // 재발송 타이머 시작 (1분)
},
error: function(xhr) {
const errorMsg = xhr.responseJSON?.error || '인증코드 발송에 실패했습니다.';
customPopups.showAlert(errorMsg);
$button.prop('disabled', false).find('span').text('인증코드 받기');
}
});
});
// 인증코드 확인 버튼 클릭
$('.btn_verify_code').on('click', function(e) {
e.preventDefault();
const authCode = $('#authCode').val().trim();
const email = $('#email').val();
if (!authCode) {
customPopups.showAlert('인증코드를 입력해주세요.');
return;
}
if (authCode.length !== 6) {
customPopups.showAlert('6자리 인증코드를 입력해주세요.');
return;
}
$.ajax({
url: '/mypage/verify-email-code',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
email: email,
code: authCode
}),
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
clearInterval(timerInterval);
isVerified = true;
customPopups.showAlert('이메일 인증이 완료되었습니다!');
// 알림 확인 후 메인 페이지로 이동
$('#customAlertOkButton').off('click.emailVerify').on('click.emailVerify', function () {
customPopups.hideAlert('#customAlert');
location.href = '/';
});
},
error: function(xhr) {
const errorMsg = xhr.responseJSON?.error || '인증코드가 일치하지 않습니다.';
customPopups.showAlert(errorMsg);
}
});
});
// 인증 완료 버튼 클릭
$('.btn_complete').on('click', function(e) {
e.preventDefault();
if (!isVerified) {
customPopups.showAlert('이메일 인증을 먼저 완료해주세요.');
return;
}
// 메인 페이지로 이동
customPopups.showAlert('이메일 인증이 완료되었습니다. 메인 페이지로 이동합니다.');
setTimeout(function() {
location.href = '/';
}, 1500);
});
});
</script>
</th:block>
</html>
+1
View File
@@ -13,6 +13,7 @@
<package-name>org.apache.log4j.*</package-name>
<package-name>org.apache.commons.logging.*</package-name>
</prefer-application-packages>
<!-- <prefer-web-inf-classes>true</prefer-web-inf-classes>-->
</container-descriptor>
<session-descriptor>